/* ── Lightbox Module ──────────────────────────────── */ .overlay { position: fixed; inset: 0; z-index: 1000; background: var(--bg-overlay); display: flex; align-items: center; justify-content: center; animation: lightbox-fade-in var(--transition-normal) ease forwards; } /* The overlay is dark in both modes — see "Studio-Inspired Lightbox Defaults" below and the studio-modern block, which both paint it near-black. In light mode the page's text tokens are near-black too, so every control that reads them (close, arrows, counter, the bottom-bar buttons, and the focus ring in app/tokens.css, which is var(--text-primary)) sat at 1.1–2:1. Inside the overlay they are pointed at the lightbox tokens instead. Dark mode is left alone: its tokens are already light, and tinted per preset. */ :global([data-theme='light']) .overlay { --text-primary: var(--lightbox-text); --text-secondary: var(--lightbox-text-secondary); --text-muted: var(--lightbox-text-muted); --border-subtle: var(--lightbox-border); } @keyframes lightbox-fade-in { from { opacity: 0; } to { opacity: 1; } } .overlay.closing { animation: lightbox-fade-out var(--transition-fast) ease forwards; } @keyframes lightbox-fade-out { from { opacity: 1; } to { opacity: 0; } } .imageContainer { position: relative; max-width: 95vw; max-height: 90vh; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); overflow: hidden; } .image { max-width: 95vw; max-height: 90vh; object-fit: contain; border-radius: var(--radius-sm); user-select: none; -webkit-user-drag: none; opacity: 0; transform: scale(0.96); transition: opacity var(--transition-normal), transform 600ms cubic-bezier(0.16, 1, 0.3, 1); } .imageLoaded { opacity: 1; transform: scale(1); } /* * The photograph enters with a fade and a slight scale. Under a slideshow * that repeats every few seconds, which is exactly the motion this preference * is about — so it simply appears instead. It still fades in from nothing, so * a half-loaded image is never shown. */ @media (prefers-reduced-motion: reduce) { .image { transform: none; transition: none; } .imageLoaded { transform: none; } } .close { position: fixed; top: 20px; right: 24px; z-index: 1001; background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 8px; transition: color var(--transition-fast); } .close:hover { color: var(--text-primary); } .close svg { width: 28px; height: 28px; } .nav { position: fixed; top: 50%; transform: translateY(-50%); z-index: 1001; background: var(--lightbox-control-bg); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid var(--lightbox-control-border); border-radius: 50%; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-secondary); transition: background var(--transition-fast), color var(--transition-fast); } .nav:hover { background: var(--lightbox-control-bg-hover); color: var(--text-primary); } .navPrev { left: 20px; } .navNext { right: 20px; } .nav svg { width: 20px; height: 20px; } /* ── Bottom bar ───────────────────────────────────── */ /* * One row rather than five separately anchored controls. * * Each button used to carry its own fixed offset, which collided in two ways: * the info button widens when its label becomes "Hide info" and grew into the * favourite button beside it, and the left-hand controls ran into the centred * counter once the viewport was narrow enough. A flex row cannot collide with * itself, and a control that is conditionally absent now leaves no hole. */ .bottomBar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 1001; display: flex; align-items: center; gap: 12px; padding: 16px 24px; /* The bar spans the viewport, so it must not swallow clicks on the photo. */ pointer-events: none; } .bottomBarGroup { display: flex; align-items: center; gap: 8px; /* Equal flex on both sides is what keeps the counter centred. */ flex: 1 1 0; min-width: 0; pointer-events: auto; } .bottomBarLeft { justify-content: flex-start; } .bottomBarRight { justify-content: flex-end; } .counter { flex: 0 0 auto; font-size: 0.85rem; color: var(--text-muted); font-weight: 300; letter-spacing: 0.1em; } /* ── EXIF Info Panel ──────────────────────────────── */ .infoToggle { /* Icon and label sit on one line — the buttons carried an emoji before, which needed no alignment of its own. */ display: inline-flex; align-items: center; gap: 6px; z-index: 1001; background: var(--lightbox-control-bg); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid var(--lightbox-control-border); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); cursor: pointer; font-size: 0.8rem; font-family: var(--font-sans); transition: background var(--transition-fast), color var(--transition-fast); } .infoToggle:hover { background: var(--lightbox-control-bg-hover); color: var(--text-primary); } /* A saved favourite. Set by class rather than inline, so a preset's own colour for the toggle (studio-modern's) still applies to the unsaved state — the inline `inherit` it used to fall back to picked up the page's near-black text in light mode (1.08:1). The extra class outranks that preset block, which comes later in this file, hover included: studio-modern hovers in the accent, which a dark custom accent drops to about 1.3:1. */ .bottomBar .infoToggle.favActive, .bottomBar .infoToggle.favActive:hover { color: var(--lightbox-fav); font-weight: 600; } /* Anchors are not buttons; strip the underline so it matches its neighbours. */ .downloadToggle { text-decoration: none; } /* Shown only when the clipboard API is unavailable. */ .copyManual { position: fixed; bottom: 76px; left: 24px; z-index: 1001; display: flex; flex-direction: column; gap: 6px; max-width: min(28rem, calc(100vw - 48px)); padding: 12px; background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: var(--radius-md); } .copyManualLabel { font-family: var(--font-sans); font-size: 0.75rem; color: var(--text-muted); } .copyManualInput { width: 100%; padding: 6px 8px; background: rgba(0, 0, 0, 0.35); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: var(--radius-sm); color: var(--text-primary); font-family: var(--font-sans); font-size: 0.8rem; } @media (max-width: 600px) { /* * Four controls, a counter and the close button do not fit on one line at * phone width. Wrapping is the honest answer: the bottom edge already * belongs to the EXIF strip in some presets, so there is nowhere to move to. */ .bottomBar { padding: 12px 16px; gap: 8px; flex-wrap: wrap; } .bottomBarGroup { flex: 0 1 auto; } /* Must not grow — a stretched counter pushes the row into the close button. */ .counter { flex: 0 0 auto; } .copyManual { left: 16px; bottom: 66px; } } .exifPanel { /* Both floating panels paint their own dark surface, so their contents have to stay light-on-dark. In dark mode the theme tokens already are, and each preset tints them (warm #6b6359 in editorial, neutral #555 in monograph) — so they are used as-is and dark mode is untouched. Only light mode, where --text-primary is near-black and put dark text on that dark panel (#511), swaps in fixed light values below. .exifCaption never had the bug because it was hardcoded. */ --lb-panel-text: var(--text-primary); --lb-panel-text-dim: var(--text-secondary); --lb-panel-muted: var(--text-muted); --lb-panel-border: var(--border-subtle); position: fixed; bottom: 76px; right: 24px; z-index: 1001; background: rgba(20, 20, 20, 0.92); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid var(--lb-panel-border); border-radius: var(--radius-lg); padding: 20px; min-width: 240px; animation: exif-slide-up var(--transition-normal) ease forwards; } /* EXPERIMENTAL: editorial caption (Immich asset description) above the tech rows */ .exifCaption { margin: 0 0 12px; max-width: 280px; font-family: var(--font-caption); font-size: 0.85rem; line-height: 1.5; font-style: italic; color: rgba(255, 255, 255, 0.85); } @keyframes exif-slide-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .exifRow { display: flex; /* `space-between` distributes the *free* space, so a value long enough to leave none ran straight into its own label (#514). An explicit gap is the floor; the panel has no max-width and grows to keep it. */ gap: 16px; justify-content: space-between; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--lb-panel-border); } .exifRow:last-child { border-bottom: none; } .exifLabel { font-size: 0.75rem; color: var(--lb-panel-muted); text-transform: uppercase; letter-spacing: 0.08em; } .exifValue { font-size: 0.85rem; color: var(--lb-panel-text); font-weight: 400; /* Right-aligned so a value that does wrap stays flush with the ones above it, rather than starting a ragged second column. */ text-align: right; } /* Marks the ISO row so a preset can keep its label — "200" alone reads as nothing once the labels are hidden. */ .exifRowIso { display: flex; } /* ── Keyboard shortcuts ───────────────────────────── The panel has no trigger of its own — `?` or `h` opens it. It sits where a trigger would have been, bottom left, clear of the EXIF panel opposite. */ .shortcutsPanel { /* Both floating panels paint their own dark surface, so their contents have to stay light-on-dark. In dark mode the theme tokens already are, and each preset tints them (warm #6b6359 in editorial, neutral #555 in monograph) — so they are used as-is and dark mode is untouched. Only light mode, where --text-primary is near-black and put dark text on that dark panel (#511), swaps in fixed light values below. .exifCaption never had the bug because it was hardcoded. */ --lb-panel-text: var(--text-primary); --lb-panel-text-dim: var(--text-secondary); --lb-panel-muted: var(--text-muted); --lb-panel-border: var(--border-subtle); position: fixed; bottom: 76px; left: 24px; z-index: 1001; animation: exif-slide-up var(--transition-normal) ease forwards; min-width: 240px; padding: 16px 20px; background: rgba(20, 20, 20, 0.92); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid var(--lb-panel-border); border-radius: var(--radius-lg); } .shortcutsTitle { margin: 0 0 10px; font-family: var(--font-sans); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--lb-panel-muted); } .shortcutsList { margin: 0; } /* Two columns, not space-between: a ragged left edge on the descriptions is what makes a shortcut list hard to scan. */ .shortcutsRow { display: flex; align-items: baseline; gap: 16px; padding: 6px 0; border-bottom: 1px solid var(--lb-panel-border); } .shortcutsRow:last-child { border-bottom: none; } .shortcutsKeys { display: flex; flex: 0 0 66px; gap: 4px; } .shortcutsLabel { margin: 0; font-size: 0.8rem; color: var(--lb-panel-text); } .kbd { min-width: 22px; padding: 2px 6px; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: var(--radius-sm); font-family: var(--font-caption); font-size: 0.75rem; line-height: 1.4; text-align: center; color: var(--lb-panel-text-dim); } /* The panels stay dark while the page around them turns light, so their contents cannot follow the theme here. Preset tinting is lost in light mode; an unreadable panel is the worse trade (#511). */ :global([data-theme='light']) .exifPanel, :global([data-theme='light']) .shortcutsPanel { --lb-panel-text: rgba(255, 255, 255, 0.92); --lb-panel-text-dim: rgba(255, 255, 255, 0.62); --lb-panel-muted: rgba(255, 255, 255, 0.5); --lb-panel-border: rgba(255, 255, 255, 0.12); } /* No keyboard, nothing to show. */ @media (hover: none) { .shortcutsPanel { display: none; } } /* ── Responsive ──────────────────────────────────── */ @media (max-width: 640px) { .nav { width: 40px; height: 40px; } .navPrev { left: 8px; } .navNext { right: 8px; } } /* ── Studio-Inspired Lightbox Defaults ───────────── */ .overlay { background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); } .close, .nav, .counter { opacity: 0.5; transition: opacity 400ms ease; } .overlay:hover .close, .overlay:hover .nav, .overlay:hover .counter { opacity: 1; } /* ── Watermark Overlay ────────────────────────────── */ .watermark { position: absolute; z-index: 10; pointer-events: none; font-family: var(--font-sans); font-size: 0.85rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: #ffffff; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9), 0 0 10px rgba(0, 0, 0, 0.7); padding: 12px 16px; user-select: none; } .watermark_bottom_right { bottom: 12px; right: 16px; } .watermark_bottom_left { bottom: 12px; left: 16px; } .watermark_center { top: 50%; left: 50%; transform: translate(-50%, -50%); } @media (hover: none) { .close, .nav, .counter { opacity: 1; } } .imageContainer { border-radius: 0; } .image { border-radius: 0; } /* ══ Studio Modern ═══════════════════════════════════════════════ Topbar with a mono counter, square outlined controls and a film-edge EXIF strip along the bottom instead of a popup panel. */ :global([data-preset='studio-modern']) .overlay { background: rgba(10, 10, 10, 0.92); backdrop-filter: none; -webkit-backdrop-filter: none; } :global([data-preset='studio-modern']) .imageContainer, :global([data-preset='studio-modern']) .image { max-width: 88vw; max-height: 78vh; } /* Counter → top left, monospace */ /* * This preset puts the viewer's chrome along the top edge. That is now said * once, on the bar, instead of by each control: when every button carried its * own offset, a button that changed width walked into its neighbour. */ :global([data-preset='studio-modern']) .bottomBar { top: 0; bottom: auto; /* The close button owns the top-right corner, so the bar stops short of it. That is the 76px the individual controls used to reserve by hand. */ padding: 20px 76px 20px 32px; } /* Counter first, so it keeps the left edge it had in this preset. */ :global([data-preset='studio-modern']) .counter { order: -1; font-family: var(--font-caption); font-size: 0.6875rem; letter-spacing: 0.15em; color: rgba(255, 255, 255, 0.6); } :global([data-preset='studio-modern']) .close { top: 20px; right: 32px; color: rgba(255, 255, 255, 0.7); } :global([data-preset='studio-modern']) .close:hover { color: var(--accent); } :global([data-preset='studio-modern']) .close svg { width: 24px; height: 24px; } /* Square outlined navigation */ :global([data-preset='studio-modern']) .nav { width: 52px; height: 52px; border-radius: 0; background: none; backdrop-filter: none; -webkit-backdrop-filter: none; border: 1px solid rgba(255, 255, 255, 0.15); color: rgba(255, 255, 255, 0.6); transition: border-color var(--transition-fast), color var(--transition-fast); } :global([data-preset='studio-modern']) .nav:hover { background: none; border-color: var(--accent); color: var(--accent); } :global([data-preset='studio-modern']) .navPrev { left: 24px; } :global([data-preset='studio-modern']) .navNext { right: 24px; } /* Info + favourite toggles move up into the topbar */ :global([data-preset='studio-modern']) .infoToggle { background: none; backdrop-filter: none; -webkit-backdrop-filter: none; border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 0; padding: 8px 14px; font-family: var(--font-caption); font-size: 0.625rem; letter-spacing: 0.15em; text-transform: uppercase; color: rgba(255, 255, 255, 0.7); } :global([data-preset='studio-modern']) .infoToggle:hover { background: none; border-color: var(--accent); color: var(--accent); } /* The EXIF strip owns the bottom edge in this preset, so the panel opens from the top instead. */ :global([data-preset='studio-modern']) .shortcutsPanel { top: 58px; bottom: auto; left: 32px; border-radius: 0; background: rgba(10, 10, 10, 0.94); backdrop-filter: none; -webkit-backdrop-filter: none; } :global([data-preset='studio-modern']) .shortcutsTitle, :global([data-preset='studio-modern']) .kbd { border-radius: 0; font-family: var(--font-caption); } /* Film-edge strip replaces the popup panel */ :global([data-preset='studio-modern']) .exifPanel { top: auto; bottom: 0; left: 0; right: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0 12px; min-width: 0; padding: 18px 32px; background: rgba(10, 10, 10, 0.9); backdrop-filter: none; -webkit-backdrop-filter: none; border: none; border-top: 1px solid rgba(255, 255, 255, 0.08); border-radius: 0; } :global([data-preset='studio-modern']) .exifPanel::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; margin-right: 5px; } :global([data-preset='studio-modern']) .exifRow { display: inline-flex; align-items: baseline; padding: 0; border-bottom: none; } :global([data-preset='studio-modern']) .exifRow + .exifRow::before { content: '·'; margin-right: 12px; color: rgba(255, 255, 255, 0.35); } :global([data-preset='studio-modern']) .exifLabel { display: none; } :global([data-preset='studio-modern']) .exifRowIso .exifLabel { display: inline; margin-right: 6px; font-family: var(--font-caption); font-size: 0.6875rem; letter-spacing: 0.06em; color: rgba(255, 255, 255, 0.85); } :global([data-preset='studio-modern']) .exifValue { font-family: var(--font-caption); font-size: 0.6875rem; letter-spacing: 0.06em; text-transform: uppercase; color: rgba(255, 255, 255, 0.85); } /* The "Loading…" / "no EXIF" fallback keeps its own label visible */ :global([data-preset='studio-modern']) .exifRow:only-child .exifLabel { display: inline; font-family: var(--font-caption); font-size: 0.6875rem; letter-spacing: 0.06em; color: rgba(255, 255, 255, 0.6); } @media (max-width: 640px) { :global([data-preset='studio-modern']) .bottomBar { /* Same reserved corner as on desktop: the close button sits there. */ padding: 16px 76px 16px 16px; } :global([data-preset='studio-modern']) .navPrev { left: 8px; } :global([data-preset='studio-modern']) .navNext { right: 8px; } }