/* --- desktop chrome scale: the knobs ---------------------------------
   The fixed-size chrome (sidebar, content header, info-panel type) is
   what reads as oversized at 100% browser zoom, so its sizes live here
   as the one place to tune by eye. Grid tiles are 1fr and are
   deliberately NOT in this list: they just fill whatever width the
   sidebar leaves, so a narrower sidebar means wider tiles, never more
   columns.
   Calibrated to what the pre-retune chrome looked like at 80% browser
   zoom -- the macOS-native scale. The anchor is the sidebar item: it was
   1rem, which at 80% renders at 12.8px, and macOS system UI text is
   ~13px. So 13px is the target, and the rest of the ladder steps down
   with it. The SHELL sizes (sidebar width, header height) are close to a
   straight 0.8x of the old values, because a fixed box at 80% zoom
   really is 0.8x its CSS px -- but this is not a flat 0.8x pass over the
   whole file: zoom also hands the page ~25% more CSS pixels of viewport,
   so rescaling everything at 100% would give a different composition,
   not a reproduction. Type is rounded to real px steps (13/12/11), not
   to 0.8x exactly, since fractional font sizes just blur.
   photos-mobile.css pins --header-*, --title-* and --viewer-* back to
   their original numbers: the phone's viewer header shares those rules
   and must not move when this block is retuned.

   --desktop-scale then shrinks the WHOLE desktop page on top of that, the
   way browser zoom would: 0.9 makes 100% look like the 90%-zoom view.
   It works through the root font size -- nearly every size in this file
   is rem, so a 90% root scales all of them, grid padding and gaps and
   tile badges included -- plus the two px knobs (sidebar width, header
   height) multiplied by it directly. Desktop only (same 700px line as
   photos-mobile.css's media attribute); the phone layout keeps a 16px
   rem. Set it back to 1 to undo. */
:root {
    --desktop-scale: 0.9;
    --sidebar-width: calc(192px * var(--desktop-scale)); /* was 240px -> 200px; 0.8x of the 240 this file used to carry */
    --sidebar-item-size: 0.8125rem; /* 13px -- macOS system UI size, and what the old 1rem rendered at under 80% zoom. Deliberately BELOW the sourced text-sm (14px). */
    --sidebar-item-line: 1.125rem; /* 18px -- paired with the size above; was 1.375rem */
    --sidebar-item-pad-y: 0.375rem; /* 6px; sourced py-1.5. 0.8x of the old 8px. */
    --sidebar-icon-size: 0.875rem; /* 14px; was 1.125rem, sourced w-4 (16px) */
    --sidebar-heading-size: 0.6875rem; /* 11px -- macOS sidebar section headers; was 0.8125rem, sourced text-xs */

    --header-height: calc(52px * var(--desktop-scale)); /* was 69px (their PHOTOS_HEADER_HEIGHT_PX) -- exactly fits the title block below plus its 0.5rem padding */
    --header-icon-size: 1rem; /* 16px = 0.8x their h-5 w-5 (20px) */
    --title-size: 0.875rem; /* "Library" h1 and Years/Months group headers; was 1.125rem (text-lg) */
    --title-line: 1.25rem;
    --subtitle-size: 0.6875rem; /* the date range under "Library"; was 0.75rem (text-xs) */
    --viewer-date-size: 0.8125rem; /* open photo's header: was 0.875rem (text-sm) */
    --viewer-counter-size: 0.6875rem; /* "1 of 13"; was 0.75rem (text-xs) */

    /* info panel under an open photo -- the same 13/12/11 ladder as the sidebar */
    --info-size-caption: 0.8125rem; /* was 1rem */
    --info-size-project: 0.75rem; /* was 0.9375rem */
    --info-size-detail: 0.6875rem; /* was 0.8125rem */
}

/* The rem half of --desktop-scale (see above). A percentage of the
   browser's own default rather than a fixed px, so a reader who has set
   a larger default text size still gets it, scaled the same way. */
@media (min-width: 700px) {
    html {
        font-size: calc(100% * var(--desktop-scale));
    }
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html, body {
    /* Chrome/Edge on macOS treat a horizontal trackpad swipe as a
       request to navigate the page back/forward (an OS/browser-level
       gesture, nothing to do with our own JS) whenever the page has
       nowhere left to scroll horizontally -- which is always true
       here, since nothing on this page scrolls sideways. That's what
       was dragging the WHOLE window with the swipe: the browser was
       interpreting the gesture before our wheel handler's own
       preventDefault() ever got a chance to run on it (the handler
       only calls preventDefault once it's confident the gesture is
       horizontal -- the first, still-ambiguous events in a gesture
       don't call it, and the browser's own navigation gesture doesn't
       wait for us). overscroll-behavior-x suppresses that at the
       browser level unconditionally, regardless of timing. */
    overscroll-behavior-x: none;
}

/* The gallery's whole screen (index.html's second section; see
   site.css). These were body's rules when the gallery was its own page --
   now that it shares the page with the intro, they live on its screen so
   the intro keeps its own font and layout. */
.photos-screen {
    background: #ffffff; /* real value from alanagoyal.com's compiled CSS: --background: 0 0% 100% */
    /* Re-checked their compiled CSS directly -- their real font stack
       is more specific than what we had: it leads with the literal
       "SF Pro"/"SF Pro Display"/"SF Pro Text" family names (not just
       the generic -apple-system/BlinkMacSystemFont keywords, which
       only resolve to San Francisco indirectly), then falls through
       ui-sans-serif/system-ui before a plain sans-serif, plus emoji
       fallbacks. Swapped in verbatim -- this is what the sidebar,
       header, and the new details-page tiers below all inherit. */
    font-family: -apple-system, "SF Pro", "SF Pro Display", "SF Pro Text", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
    display: flex;
    /* 100dvh, not 100vh: on iOS Safari 100vh is the viewport with the
       address bar COLLAPSED, so while the bar is showing it's taller
       than what's actually visible and the bottom of the window (the
       grid's last row, the viewer's lower edge) sits cut off under the
       toolbar. dvh tracks the real visible height as the bar shows and
       hides. The 100vh line comes first as the fallback: a browser that
       doesn't know dvh drops the second declaration and keeps the first.
       Nothing downstream needs its own fix -- .window is height:100%
       of this, .viewer is absolute/inset:0 inside .main, .photo-pane/
       .photo-stage are height:100%, and sizeSlideMedia measures
       .photo-stage's clientHeight rather than any viewport unit. */
    height: 100vh;
    height: 100dvh;
    /* no padding -- the window fills the entire viewport edge to edge now */
}

/* Real answer to "should rounding be one overall thing or per-piece":
   ONE. Their actual window frame (components/desktop/window.tsx) is a
   single container -- "absolute inset-0 overflow-hidden shadow-2xl
   flex flex-col" + "rounded-xl" (dropping to rounded-none only when
   maximized, since a fullscreen window has no visible corners) -- that
   every app, including Photos, renders inside. The sidebar/header/grid
   have NO rounding of their own; they're just clipped into shape by
   this one parent's overflow: hidden. Rounding each piece separately
   would need every radius kept in sync by hand and risks visible
   seams where two independently-rounded pieces don't quite match --
   one shared radius + clipping avoids both problems entirely.
   shadow-2xl is included deliberately, not as scope creep: without it,
   rounding a white window against a white page is nearly invisible --
   their own class pairs the two together for exactly that reason.
   No max-width here (unlike a real floating window) -- ours should
   take up the full available desktop space.
   real value: their window frame also has "border border-black/10"
   alongside rounded-xl/shadow-2xl -- a 1px hairline (10% opacity
   black) right on the window edge, which is what actually makes the
   rounded corner read clearly now that there's no padding/backdrop
   gap to see it against. */
.window {
    display: flex;
    width: 100%;
    height: 100%;
    border-radius: 0.75rem; /* rounded-xl */
    overflow: hidden;
    box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); /* shadow-2xl */
    border: 1px solid rgba(0, 0, 0, 0.1); /* border border-black/10 */
}

/* --- sidebar -----------------------------------------------------
   Structure, spacing, and colors all pulled from the real
   sidebar.tsx + its compiled CSS, not guessed:
   - container: bg-muted, 220px width (we run --sidebar-width, see the
     knobs at the top of the file), px-2 py-2 padding
   - section header: text-xs uppercase font-semibold tracking-wide,
     color = --muted-foreground (0 0% 45.1% => #737373)
   - item button: flex gap-2, px-3 py-1.5, rounded-lg, text-sm
   - active item: bg-zinc-200/70 (#e4e4e7 @ 70%) + text-blue-500 (#3b82f6)
   Traffic lights are a separate real component (WindowControls, in
   their nav bar) -- added here as plain decorative dots since we
   have no actual window frame yet, just the visual landmark. */
.sidebar {
    width: var(--sidebar-width); /* sourced 220px; we run narrower -- see the knobs at the top */
    flex-shrink: 0;
    background: #f5f5f5; /* bg-muted, light mode */
    padding: 0.5rem;
    overflow-y: auto;
    /* real value: app.tsx wraps <Sidebar> in a div with
       "border-r dark:border-foreground/20" -- in light mode (no dark:
       override) that falls back to their base --border variable,
       which is hsl(0 0% 89.8%) = #e5e5e5. A light-gray hairline, not
       literally black -- it just reads as "the dark line" since it's
       the only visible edge against an all-white background. */
    border-right: 1px solid #e5e5e5;
}

/* History: this block used to be a deliberate size-up from the sourced
   values (240px sidebar, 1rem items, 0.8125rem headings, 69px header --
   "a taste calibration against the reference screenshots", not a found
   number). That size-up is what made the desktop chrome read as
   oversized at 100% zoom, so it's been undone: the sidebar and header
   below are back at (or just under) their sourced values, set through
   the knobs at the top of the file. */

.traffic-lights {
    padding: 5px 10px 14px; /* outer spacing from the sidebar's edges only -- 0.8x the 6/12/18 this file used to carry, in step with the chrome around it. The DOTS themselves stay at their sourced w-3 (12px), which is also real macOS's size. */
}

/* tight wrapper around just the three dots -- no padding of its own,
   so its box is exactly as big as the dots + the small gaps between
   them. Hovering is scoped to THIS, not the padded .traffic-lights
   above, so the reveal only engages over the actual colored dots
   instead of the whole padded margin around them. */
.traffic-lights-group {
    display: inline-flex;
    gap: 0.375rem; /* real value: their gap-1.5 */
}

/* real values from components/window-controls.tsx: w-3 (12px) dots,
   exact Tailwind red-500/yellow-500/green-500 (not the macOS-specific
   hex used here before). Each dot is `relative` so its hover-revealed
   icon (below) can be absolutely centered on top of it. */
.traffic-lights button {
    position: relative;
    width: 0.75rem;
    height: 0.75rem;
    border-radius: 50%;
    border: none;
    padding: 0;
    cursor: default; /* non-interactive here -- there's no real window to close/minimize/maximize yet */
}

.traffic-lights .red { background: #ef4444; }
.traffic-lights .yellow { background: #eab308; }
.traffic-lights .green { background: #22c55e; }

/* the actual "hover reveal" mechanism: their real icon wrapper is
   "absolute inset-0 ... opacity-0 group-hover:opacity-100" -- same
   here, just via a plain :hover on the shared parent instead of their
   custom Tailwind hover-capable-device variant, since our version
   doesn't need to special-case touch devices */
.traffic-lights .control-icon {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    color: #00000080; /* text-black/50 */
    pointer-events: none;
    transition: opacity 0.1s ease;
}

/* (hover: hover) here and on every other :hover rule in this file: on
   iOS a tap doesn't just fail to trigger :hover, it turns it ON and
   leaves it stuck until something else is tapped. Desktop pointers all
   match (hover: hover), so nothing changes there. */
@media (hover: hover) {
    .traffic-lights-group:hover .control-icon {
        opacity: 1;
    }
}

/* No per-dot nudges here anymore. There used to be three (red
   translate(-0.5px,-0.5px), yellow and green translate(0.5px,-0.5px)),
   added because each icon was reported as reading slightly off-center
   within its circle. All three are gone, because the nudges were the
   problem rather than the fix: every one of these glyphs is already
   mathematically centered in its own 10x10 viewBox -- red's X spans
   1.5-8.5 on both axes, yellow's stroked rule 2-8 by 4.2-5.8, green's
   two triangles 2-8 by 2-8, all centered on (5,5) -- and .control-icon
   is inset:0 flex-centered on the dot, so the only thing a half-pixel
   transform could do was shift the glyph onto fractional device pixels
   and smear its antialiasing asymmetrically. That reads as off-center
   far more than a true-centered glyph ever did. Centering is left
   entirely to the flex box above, which lands on whole pixels now that
   the size below divides evenly into the dot. */

/* 8px in a 12px dot -- was 10px (their w-2.5), which left a 1px margin
   and read as a glyph crammed into its circle rather than sitting in
   it. The drawn ink is smaller again than the box: yellow's and green's
   art fills 6 of its 10 viewBox units, red's X 7, so the visible mark
   is now ~4.8px (red ~5.6px) across a 12px dot -- close to real macOS's
   own glyph-to-dot proportion. 8px also leaves a clean 2px on every
   side, so nothing is centered on a half pixel. */
.traffic-lights .control-icon svg {
    width: 0.5rem;
    height: 0.5rem;
}

.sidebar-section {
    margin-bottom: 1rem;
}

.sidebar-heading {
    font-size: var(--sidebar-heading-size); /* see the knobs at the top */
    line-height: 0.9375rem;
    font-weight: 600; /* font-semibold */
    text-transform: uppercase;
    letter-spacing: 0.025em; /* tracking-wide */
    color: #737373; /* --muted-foreground */
    padding: 0.25rem 0.75rem; /* px-3 py-1 */
}

.sidebar-item {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 0.5rem; /* real value: their gap-2 */
    padding: var(--sidebar-item-pad-y) 0.75rem; /* real value: px-3 py-1.5 = 0.375rem 0.75rem */
    border-radius: 0.5rem; /* rounded-lg = their --radius */
    font-size: var(--sidebar-item-size); /* see the knobs at the top */
    line-height: var(--sidebar-item-line);
    text-align: left;
    color: #333333; /* --foreground, light mode */
    background: none;
    border: none;
    cursor: pointer;
    font-family: inherit;
    margin-bottom: 0.125rem; /* a touch of breathing room between rows */
}

.sidebar-item svg {
    width: var(--sidebar-icon-size); /* their w-4 (16px); see the knobs at the top */
    height: var(--sidebar-icon-size);
    flex-shrink: 0;
}

/* The Videos icon's own viewBox is 30x24, not the 24x24 every other
   sidebar icon uses (see the comment on it in index.html) -- widened
   to give its flag room at full size instead of compressed to fit a
   square canvas. Height stays matched to every other icon (so the row
   still aligns/sits the same height); width is scaled up by the same
   30:24 ratio as the viewBox, so the glyph renders at its real size
   instead of being letterboxed down to fit a square box. */
.sidebar-item[data-view="videos"] svg {
    width: calc(var(--sidebar-icon-size) * 1.25); /* the 30:24 ratio */
}

.sidebar-item.active {
    background-color: #e4e4e7b3; /* bg-zinc-200/70, exact hex+alpha from their compiled CSS */
    color: #3b82f6; /* text-blue-500 */
}

/* Applied by photos.js's applyFilterVisibility to both a grid .tile and
   its corresponding .photo-slide when the row it represents doesn't
   match the active filters. display:none (not visibility/opacity) is
   load-bearing for the .photo-slide case specifically -- it removes the
   slide from .photo-stage's flex flow entirely, which is what lets the
   viewer's native scroll-snap only ever stop on a visible photo. */
.tile.filtered-out,
.photo-slide.filtered-out,
.filmstrip-thumb.filtered-out {
    display: none !important;
}

/* The viewer's thumbnail strip, the grid's Pinned Collections row and
   the grid header's back-to-Library button (see index.html) are
   phone-only: this is the desktop default, and
   photos-mobile.css turns them on. */
.filmstrip,
.pinned-collections,
.collection-back {
    display: none;
}

.main {
    flex: 1;
    min-width: 0;
    /* overflow-y lives on .grid-scroll below now, NOT here -- .main
       itself no longer scrolls at all. .content-header used to be a
       position:sticky child of THIS element while it was the scroll
       container, which worked most of the time but visibly got dragged
       along during macOS/Safari's elastic overscroll bounce (sticky
       positioning is relative to the scrolling viewport, which
       momentarily overshoots past its bounds on a rubber-band bounce
       and springs back, carrying sticky children with it). Structured
       to match .viewer/.viewer-header/.viewer-body instead: the header
       sits OUTSIDE the actual scrolling box entirely, as a plain
       sibling of it, the same way .viewer-header never scrolls because
       it's a sibling of .viewer-body rather than living inside it. */
    display: flex;
    flex-direction: column;
    position: relative; /* containing block for .viewer below -- keeps it scoped to just this content pane, not the whole window */
}

/* The actual scroll container for the grid now -- everything .main
   used to do itself. flex:1;min-height:0 is the same pattern
   .viewer-body uses to take up the remaining vertical space below its
   own header. */
.grid-scroll {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
}

/* --- content header ----------------------------------------------
   This is their real header.tsx + the title/controls block built
   inside photos-grid.tsx, not the sidebar -- structure, spacing, and
   every color pulled from their actual source/compiled CSS:
   - shell: fixed height (theirs is 69px, PHOTOS_HEADER_HEIGHT_PX; we run
     --header-height, see the knobs at the top), bg-muted at 50%
     opacity (hsl(var(--muted)/.5)), same border-bottom hairline
     as the sidebar
   - title: font-semibold ("Library", their text-lg; we run --title-size);
     subtitle: text-xs
     text-muted-foreground (a date range, in their case)
   - zoom pill + time-filter pill: both bg-muted, rounded-lg, p-0.5,
     sitting absolutely centered in the header regardless of how wide
     the title or the right-side icons are
   - right-side icon actions: rounded-md, p-1, disabled (dimmed) until
     a photo is selected -- exactly their real disabled={!selectedPhoto} */
.content-header {
    height: var(--header-height);
    min-height: var(--header-height);
    flex-shrink: 0;
    /* Plain relative, not sticky -- .grid-scroll (see .main above) is
       the actual scroll container now, and this header is a sibling of
       it, not a child, so it never needs to fake staying in place via
       sticky positioning; it just structurally never scrolls at all,
       the same way .viewer-header (which shares this exact class)
       never scrolls either. Still needed so .content-header-center's
       position:absolute below has a containing block. No z-index
       needed either -- .grid-scroll clips its own contents (including
       a tile's temporary overflow:visible during its close-animation
       FLIP, see closeViewer in photos.js) at its own box edge, well
       before anything could reach up into this header's separate,
       non-overlapping space above it. */
    position: relative;
    display: flex;
    align-items: center;
    padding: 0.5rem 1rem; /* their py-3 px-4; py cut to 0.5rem so the title block fits the shorter --header-height */
    /* Opaque, not the original bg-muted/50 translucency -- that 50%
       alpha was designed to composite over a STATIC white page
       background, but now that this header is sticky (see position
       above) and grid tiles scroll past underneath it, translucency
       meant tile colors showed straight through it. Same hue/lightness
       as the original bg-muted reference, just fully opaque, so it
       reads as a solid bar the same way .viewer-header does (that one
       never had this problem since it's a separate flex row above the
       photo viewer's own scroll area, never overlapping scrolled
       content at all). */
    background: hsl(0, 0%, 96.1%);
    border-bottom: 1px solid #e5e5e5; /* same --border hairline as the sidebar */
}

.content-header-title {
    min-width: 0;
}

.content-header-title h1 {
    font-size: var(--title-size); /* their text-lg; see the knobs at the top */
    line-height: var(--title-line);
    font-weight: 600; /* font-semibold */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis; /* truncate */
}

.content-header-title p {
    font-size: var(--subtitle-size); /* their text-xs (12px); see the knobs at the top */
    line-height: 1rem;
    color: #737373; /* text-muted-foreground */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* absolutely centered in the header, independent of the title's or
   the action icons' widths -- matches their real layout exactly */
.content-header-center {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
}

/* Matches their real nesting exactly: a `relative` wrapper around
   BOTH the zoom pill and the time-filter pill, sized to just the
   time-filter's width (zoom-controls is absolutely positioned and
   out of flow, so it doesn't contribute to this wrapper's width).
   .zoom-controls' `right: calc(100% + 0.5rem)` below is calculated
   against THIS wrapper -- without it, that calc runs against the
   much wider .content-header-center instead, pushing the zoom pill
   off the left edge of the header entirely (invisible). */
.header-center-inner {
    position: relative;
    pointer-events: auto;
}

.zoom-controls {
    position: absolute;
    right: calc(100% + 0.5rem);
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    background: #f5f5f5; /* bg-muted */
    border-radius: 0.5rem; /* rounded-lg */
    padding: 0.125rem; /* p-0.5 */
}

.zoom-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 1.25rem; /* their h-6 (24px), scaled with the header around it */
    width: 1.5rem; /* their w-7 (28px) */
    border-radius: 0.375rem; /* rounded-md = --radius - 2px */
    border: none;
    background: none;
    color: #737373; /* text-muted-foreground */
    cursor: pointer;
}

.zoom-btn svg {
    width: 0.875rem; /* was 1rem, in step with the smaller pill above */
    height: 0.875rem;
}

@media (hover: hover) { /* see .traffic-lights-group:hover */
    .zoom-btn:hover:not(:disabled) {
        background: #ffffff; /* hover:bg-background */
        color: #333333; /* hover:text-foreground */
    }
}

.zoom-btn:disabled {
    opacity: 0.35;
    cursor: default;
}

.time-filter {
    display: flex;
    gap: 0.125rem; /* gap-0.5 */
    background: #f5f5f5; /* bg-muted */
    border-radius: 0.5rem; /* rounded-lg */
    padding: 0.125rem; /* p-0.5 */
}

.time-filter-btn {
    border: none;
    background: none;
    border-radius: 0.375rem; /* rounded-md */
    padding: 0.1875rem 0.625rem; /* their py-1 px-3, tightened with the header around it */
    font-size: var(--subtitle-size); /* their text-xs; shares the subtitle knob -- both are the header's small text */
    color: #737373; /* text-muted-foreground */
    cursor: pointer;
    text-transform: capitalize;
    font-family: inherit;
}

.time-filter-btn.active {
    background: #ffffff; /* bg-background */
    color: #333333; /* text-foreground */
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); /* shadow-sm */
}

.content-header-actions {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: 0.25rem; /* gap-1 */
}

.action-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0.25rem; /* p-1 */
    border-radius: 0.375rem; /* rounded-md */
    border: none;
    background: none;
    color: #333333; /* text-foreground */
    cursor: pointer;
}

.action-btn svg {
    width: var(--header-icon-size); /* their h-5 w-5 = 1.25rem; see the knobs at the top */
    height: var(--header-icon-size);
}

@media (hover: hover) { /* see .traffic-lights-group:hover -- a tapped heart would otherwise keep its grey square, reading as stuck "pressed" */
    .action-btn:hover:not(:disabled) {
        background: rgba(51, 51, 51, 0.1); /* hover:bg-foreground/10 */
    }
}

.action-btn:disabled {
    cursor: default;
    color: rgba(115, 115, 115, 0.35); /* disabled:text-muted-foreground/35 */
}

/* #action-favorite / #viewer-action-favorite reflecting whichever photo
   is currently selected/open (see setFavoriteButtonState in photos.js).
   Both buttons already render the same heart <path fill="none"> --
   toggling just the fill, rather than swapping in a second icon, means
   an identical outline heart turns solid without any extra markup.
   Purely visual: nothing here writes to `favorite`, same as every other
   currently-inert action button (share, rotate). */
.action-btn.is-favorite svg {
    fill: currentColor;
}

/* Fixed column counts (not a fluid auto-fill grid) because that's the
   real mechanism alanagoyal.com/photos actually uses. Mobile always
   gets 3 columns regardless of zoom (their isMobileView check ignores
   density entirely); desktop's column count depends on the zoom
   pill's current setting -- data-grid-size below matches their real
   "compact/standard/comfortable" -> 6/5/4 column mapping exactly. */
.grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 0.5rem; /* real value: their grid uses Tailwind's gap-2 = 0.5rem (8px) */
    /* real value: the outer frame is a SEPARATE wrapper in their markup
       using p-4 (1rem/16px) -- double the gap-2 used between tiles,
       not the same value. That's the "bigger border" you're seeing. */
    padding: 1rem;
}

/* same fix as .viewer[hidden] earlier: without this, the UA's default
   [hidden]{display:none} loses to this file's own .grid{display:grid}
   (same specificity, later in the cascade), so the grid would stay
   visibly rendered even while `hidden` is set from JS. */
.grid[hidden] {
    display: none;
}

@media (min-width: 700px) {
    .grid[data-grid-size="compact"] {
        grid-template-columns: repeat(6, 1fr);
    }

    .grid[data-grid-size="standard"] {
        grid-template-columns: repeat(5, 1fr);
    }

    .grid[data-grid-size="comfortable"] {
        grid-template-columns: repeat(4, 1fr);
    }
}

/* Years/Months group headers, built by photos.js's applyGridLayout --
   grid-column:1/-1 is load-bearing, not decorative: it's what forces
   this item onto a row of its own (a full-width item can't share a row
   with anything else, so CSS grid's own auto-placement breaks to a
   fresh row for it), which is the entire mechanism behind a group's
   tiles visually ending at its own boundary instead of flowing into
   the next group's row. font-size/line-height/font-weight match
   .content-header-title h1 (the "Library" heading) exactly (same
   --title-size/--title-line knobs, so they move together), so a group
   header reads as the same size of heading, just muted -- color is the
   one deliberate difference, #737373 (this file's usual
   text-muted-foreground gray) rather than the full-contrast heading
   color, so it still reads as a secondary label under the real page
   title, not a second page title. No margin-top by default: the very
   FIRST header sits right under the page's own title/date-range, where
   the grid's own 0.5rem gap is already enough -- stacking extra margin
   there on top of that made switching into Years/Months mode read as a
   big jump before the grid had even started. margin-bottom stays
   always, for the (smaller, deliberately different) header-to-its-own-
   photos spacing below. */
.grid-group-header {
    grid-column: 1 / -1;
    margin: 0 0 0.75rem;
    font-size: var(--title-size);
    line-height: var(--title-line);
    font-weight: 600;
    color: #737373;
}

/* Every OTHER header -- i.e. every group boundary actually inside the
   grid, not the first group meeting the page title -- gets real
   breathing room above it, so one group visually ends before the next
   begins instead of just running into it. :first-of-type is reliable
   here specifically because applyGridLayout (photos.js) always creates
   and appends header elements in the same sequence it assigns their
   visual `order` in -- DOM order and visual order never diverge for
   these, so "first h2 in the DOM" and "first header visually" are
   always the same element. */
.grid-group-header:not(:first-of-type) {
    margin-top: 1rem;
}

/* Now just a square CROP WRAPPER -- selection ring + click handling
   live here, but it holds no content of its own. Its .photo-media
   child (see photos-data.js) is what actually looks like a photo, and
   it's a SHARED element that physically moves out of here into the
   viewer on open (FLIP-animated, see photos.js) and back on close --
   never cloned, never a separate snapshot. overflow:hidden is what
   crops .photo-media to this square while it's here. */
.tile {
    aspect-ratio: 1 / 1; /* uniform square tiles, same as the real grid -- not masonry */
    overflow: hidden;
    cursor: pointer;
    border-radius: 4px; /* real value: their rounded-sm = their --radius (0.5rem) minus 4px */
    position: relative; /* containing block for .tile-timecode below (video tiles only) */
}

/* Video-only badge, lower-right corner of the grid tile -- see
   VIDEO_TILES in photos-data.js. A sibling of .photo-media, not a
   child of it, so it stays behind in the grid instead of traveling to
   the viewer along with .photo-media when a video is opened. */
.tile-timecode {
    position: absolute;
    right: 6px;
    bottom: 6px;
    color: #ffffff;
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums; /* digits stay a fixed width so the badge doesn't jiggle between e.g. "0:07" and "0:08" */
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); /* not explicitly asked for, but every real video-duration badge (Photos, YouTube, etc.) does this -- plain white text alone isn't reliably legible against an arbitrary thumbnail */
    pointer-events: none; /* purely decorative -- clicks should pass through to the tile beneath it */
}

/* Favorite badge, lower-left corner of the grid tile -- only present in
   the DOM at all when the row's `favorite` column is true (see
   buildTileFromRow in photos-data.js). Mirrors .tile-timecode's corner
   treatment (same offsets, same drop-shadow-for-legibility reasoning),
   mirrored to the opposite side so a favorited video can show both
   badges at once without overlap. */
.tile-favorite {
    position: absolute;
    left: 6px;
    bottom: 6px;
    width: 17.85px; /* 85% of the 21px (14px * 1.5) this used to be */
    height: 17.85px;
    color: #ffffff;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
    pointer-events: none;
}

.tile-favorite svg {
    width: 100%;
    height: 100%;
    display: block;
}

/* macOS selection state: first click on a tile selects it (this blue
   ring), it does not open. Matches their real ring exactly: their
   "ring-[3px] ring-[#0A84FF]" compiles to a non-inset box-shadow with
   a 0px ring-offset -- i.e. the ring extends OUTWARD into the gap
   around the tile rather than inward over the photo's own edge, so
   none of the image gets covered. box-shadow (not a real border) so
   it doesn't add to the tile's box size, and it naturally follows the
   tile's own border-radius without needing one of its own. */
.tile.selected {
    box-shadow: 0 0 0 3px #0a84ff;
}

/* Touch has no select step -- one tap opens (see the grid click handler
   in photos.js) -- so the ring never shows for it. selectedTile itself
   is still tracked underneath; only its ring is suppressed. data-input
   follows the LAST pointer pressed, so a touchscreen laptop gets the ring
   back the moment the mouse is used again. */
html[data-input="touch"] .tile.selected {
    box-shadow: none;
}

/* placeholder tiles only -- cycles through a handful of hues so it's
   visually easy to tell tiles apart before any real photos exist */
.tile img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

/* Viewer: click a tile, see it bigger. Two real findings from their
   actual photo-viewer.tsx, checked directly rather than assumed:
   1. Its root is "h-full ... bg-background" -- it fills whatever
      container it's rendered into (the same content pane the grid
      occupies), NOT position:fixed over the whole page. That's why
      this is `position: absolute` scoped to `.main` (see `.main`'s
      `position: relative` above), not `fixed; inset: 0`. The sidebar
      stays visible and interactive the whole time, exactly like theirs.
   2. bg-background = white in light mode, not black -- their photo
      canvas is a light surface, not a dark immersive one.
   There's also no fade/scale transition on open/close in their real
   code at all -- it's a plain instant conditional render with zero
   animation. Their only real motion is a 180ms cubic-bezier(0.22, 1,
   0.36, 1) slide when navigating BETWEEN photos once already open
   (photo-transition.ts) -- not implemented here yet, but that's the
   real value to reach for whenever prev/next sliding is built.
   Real finding: their viewer keeps its own header -- it renders a
   SECOND PhotosHeader (same --header-height/bg-muted-50/border-b shell
   as the grid's), with a back chevron on the left, the photo's date + an
   "X of Y" counter centered, and the same four action icons on the
   right -- always enabled here, since a photo is inherently "current"
   whenever the viewer is open (no disabled-until-selected state
   the way the grid header's icons had). */
.viewer {
    position: absolute;
    inset: 0;
    background: #ffffff; /* bg-background, light mode -- not black */
    display: flex;
    flex-direction: column;
    /* Higher than .content-header's z-index:20 -- that header is
       position:sticky within the same stacking context as .viewer
       (both children of .main), and without this, its explicit z-index
       put it ABOVE .viewer once that was added, meaning the grid's
       translucent "Library" header rendered on top of an open photo
       instead of being fully covered/hidden by it. */
    z-index: 30;
}

.viewer[hidden] {
    display: none;
}

.viewer-back {
    display: flex;
    align-items: center;
    margin-left: -0.5rem; /* -ml-2, same negative-margin alignment trick as their real button */
    padding: 0.25rem;
    border: none;
    background: none;
    color: #333333; /* text-foreground */
    cursor: pointer;
}

.viewer-back svg {
    width: var(--header-icon-size); /* h-5 w-5 originally; same knob as .action-btn svg so the header's icons match */
    height: var(--header-icon-size);
}

/* absolutely centered on the header regardless of the back button's or
   action icons' widths -- their real mechanism (absolute + translate),
   not a flex-centered group like the grid header used */
.viewer-header-center {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    max-width: calc(100% - 9rem);
    text-align: center;
    pointer-events: none;
}

.viewer-header-center .viewer-date {
    font-size: var(--viewer-date-size); /* their text-sm; see the knobs at the top */
    font-weight: 500; /* font-medium */
    color: #333333;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.viewer-header-center .viewer-counter {
    font-size: var(--viewer-counter-size); /* their text-xs; see the knobs at the top */
    color: #737373; /* text-muted-foreground */
    font-variant-numeric: tabular-nums;
}

/* Now the vertical SCROLL container -- scrolling down past the photo
   (see .photo-pane/.photo-info below) is the "scroll down reveals
   metadata" interaction, borrowed from their real mobile layout
   (components/apps/photos/photo-viewer.tsx mounts a near-identical
   "mobile-photo-info" section below the photo inside a scrollable
   wrapper, reset to scrollTop 0 on every photo change) -- we're just
   using that same pattern on desktop, per feedback, instead of their
   desktop's actual click-to-open info dropdown. overflow-x stays
   hidden since horizontal movement here is only ever the swipe/drag
   transform, never a real scroll. */
.viewer-body {
    flex: 1;
    min-height: 0;
    position: relative;
    cursor: default; /* plain arrow over the backdrop -- clicking it still closes the viewer (JS below), but that's not a "link/button" area, so no pointer hand */
    overflow-y: auto;
    overflow-x: hidden;
    touch-action: pan-y; /* real value: their swipeable wrapper is "touch-pan-y" -- horizontal drags are claimed for photo navigation below, vertical ones scroll natively to reveal .photo-info */
}

/* Exactly fills .viewer-body's own visible box (not its scrollable
   content height) every time -- clientHeight always reports the
   viewport size of a scroll container regardless of scroll position,
   so this stays pinned to "one screen" even once .photo-info below
   adds extra scrollable height. This is what used to just be
   .viewer-body itself before the info panel existed. */
.photo-pane {
    position: relative;
    height: 100%;
    overflow: hidden; /* belt-and-suspenders alongside .photo-stage's own overflow below */
}

/* --- swipe/slide track --------------------------------------------
   Native CSS scroll-snap, replacing every hand-rolled drag/momentum/
   commit-threshold heuristic tried before (all of it lived in
   photos-gesture.js and is now deleted) -- dragging (touch or
   trackpad) natively moves scrollLeft in real time, so "the photo
   moves along with a two-finger drag" is just what overflow-x:scroll
   already does, nothing custom needed for that part at all.
   scroll-snap-type:x mandatory is what gives the release-glide-then-
   snap-to-nearest feel, using the browser's own physics instead of
   ours -- no more guessing at momentum, thresholds, or when a gesture
   "truly ended". */
.photo-stage {
    height: 100%;
    width: 100%;
    display: flex;
    overflow-x: scroll;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    /* Allows BOTH pan axes on purpose. This used to be pan-x alone, on
       the theory that vertical drags would "pass up" to .viewer-body's
       pan-y -- but touch-action doesn't pass anything up: it only ever
       restricts, and on a real iPhone (iOS 18.7) a vertical swipe that
       started on the photo was refused outright, so the info panel
       couldn't be revealed from the photo at all (and .photo-pane fills
       the whole screen, so that's nearly every swipe). With both axes
       allowed, the browser's own direction-locking decides per gesture:
       horizontal scrolls this stage (photo-swiping, unchanged), and
       vertical finds nothing to scroll here (overflow-y: hidden) and
       chains to .viewer-body, revealing the info panel natively with
       zero JS coordination between the two. No effect on mouse/trackpad
       input -- touch-action only governs touch. */
    touch-action: pan-x pan-y;
    /* Stops horizontal overscroll at the first/last photo from chaining
       up to any ancestor scroller (or triggering the OS back/forward
       swipe html/body's own overscroll-behavior-x rule above already
       guards against) -- scoped to this container specifically, rather
       than relying on the page-level rule alone. */
    overscroll-behavior-x: contain;
    scrollbar-width: none; /* Firefox -- hide the native scrollbar so this reads as a native Photos swipe, not a scrollable list */
}

.photo-stage::-webkit-scrollbar {
    display: none; /* Chrome/Safari */
}

/* One full-width child per photo -- scroll-snap-align:center is what
   brings each one to rest exactly centered after a release, instead of
   wherever momentum happened to leave it. */
.photo-slide {
    flex: 0 0 100%;
    height: 100%;
    scroll-snap-align: center;
    /* Forces the browser to actually stop on THIS slide before moving
       on, even mid-fling -- without it, a fast/strong swipe is allowed
       to sail past intermediate snap points and land two or three
       photos away instead of the very next one. */
    scroll-snap-stop: always;
    display: flex;
    align-items: center;
    justify-content: center;
}

/* The ONE shared element per photo (see photos-data.js) -- lives inside
   its .tile in the grid (100%-filling the square crop, the default
   below) OR inside its .photo-slide in the viewer (JS-sized to its
   native aspect ratio via sizeSlideMedia in photos.js, which overrides
   this width/height with an inline style), physically reparented
   between the two on open/close via a FLIP animation (photos.js) --
   never cloned, never a snapshot. Same look (background/text set
   inline per-photo, color here) regardless of which container it's
   currently in -- only font-size and cursor differ by context, via the
   two rules below, since those depend on which crop it's playing. */
.photo-media {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: rgba(255, 255, 255, 0.55);
    /* No-op for the placeholder divs (they have no intrinsic content to
       fit), but needed now that .photo-media is sometimes a real <video>
       (see the real-video test hookup in photos-data.js): in the grid,
       .photo-media is a square 100%/100% box while the video's own
       aspect ratio usually isn't square, and a video/img with no
       object-fit set just stretches to fill its box -- cover crops
       instead, matching how a real photo thumbnail should look. In the
       viewer, .photo-media's box is already sized to the exact native
       aspect ratio (see sizeSlideMedia in photos.js), so cover has
       nothing to crop there -- except a video's poster, which can be a
       different shape and is letterboxed instead (see below). */
    object-fit: cover;
}

/* True (OS-level) fullscreen, entered via the playbar's fullscreen
   button on .photo-frame (see wireRealVideoControls in photos.js) --
   the browser's own UA stylesheet forces ANY :fullscreen element to
   width:100%/height:100% of the actual screen, overriding our own
   JS-computed contain-fit sizing on the frame entirely (there's no way
   to opt a fullscreen element out of that). Since the frame is now
   very likely a different aspect ratio than the screen, .photo-media's
   usual object-fit:cover would crop/zoom into the video to fill that
   mismatched box -- exactly the "zooms in" problem this overrides.
   contain here is what actually respects the video's own native aspect
   ratio, letterboxed within the fullscreen frame instead, the same way
   a real video player's native fullscreen does. */
.photo-frame:fullscreen {
    background: #000; /* the letterbox bars -- explicit, rather than leaning on the browser's own ::backdrop default, which isn't guaranteed the same color everywhere */
}

.photo-frame:fullscreen .photo-media {
    object-fit: contain;
}

/* A video's poster in the viewer, letterboxed rather than cropped, in
   the one case its frame isn't the poster's own shape: the poster not
   measured yet, so the frame fell back to the video's shape (see
   tileAspect and sizeSlideMedia in photos.js, which sets
   .poster-letterbox). There, cover would show only the poster's middle
   band; contain shows all of it, on black bars painted by the frame.
   Deliberately NOT applied in general: every frame is rounded to whole
   pixels, so even media that fits its frame exactly comes up a fraction
   of a pixel short, and contain plus the black turned that into a thin
   dark line down the edge of every playing video.
   Not while .flipping (see flip() in photos.js): mid-animation the box
   is morphing from the grid's square, where cover is what the tile
   showed, so the switch waits for the poster to land rather than
   jumping at the start of the open. The black is on the frame, not the
   video, so the phone's poster backing (in photos-mobile.css) still
   shows through the video during its load gap. */
.photo-frame.poster-letterbox .photo-media:not(.flipping) {
    object-fit: contain;
}

.photo-frame.poster-letterbox:has(> .photo-media:not(.flipping)) {
    background-color: #000;
}

.tile .photo-media {
    font-size: 0.85rem; /* matches .tile's old own font-size, for the grid-scaled number */
}

.photo-slide .photo-media {
    font-size: 3rem; /* fullscreen-scaled number */
    flex-shrink: 0; /* don't let the flex parent shrink our explicit JS-computed size */
    cursor: default; /* clicking the photo itself shouldn't close the viewer -- only the surrounding backdrop does */
}

/* --- video playback controls overlay -------------------------------
   .photo-frame is a video-tile-only wrapper, permanently present inside
   that tile's .photo-slide from page load (see createPhotoSlide in
   photos.js) -- unlike .photo-media, it never moves. sizeSlideMedia
   sizes THIS (not .photo-media directly) for a video tile, so
   .video-playbar below -- a plain sibling of .photo-media inside this
   frame, not a child of it -- always matches the video's own
   contain-fit box exactly, with no separate positioning math of its
   own; .photo-media then just fills the frame at 100%/100% (its
   ordinary default) once it's moved in. Because .photo-frame only ever
   exists inside a .photo-slide (never in a grid .tile), the playbar has
   no grid-context visibility rule to worry about the way
   .tile-timecode does -- it simply isn't in the grid's DOM at all. */
.photo-frame {
    position: relative;
    flex-shrink: 0; /* don't let .photo-slide's flex layout shrink our explicit JS-computed size, same reasoning as .photo-slide .photo-media below */
}

/* Static/non-interactive per feedback -- there's no real underlying
   video to actually play, mute, or scrub yet, so pointer-events is
   switched off entirely rather than leaving dead-looking controls that
   invite a click.
   Hidden (opacity:0) by default -- .photo-frame is sized/positioned to
   its final fullscreen box the instant openViewer starts (see
   sizeSlideMedia), well before the photo itself has actually finished
   FLIP-animating into that box, so revealing this immediately made it
   render at its resting spot while the still-growing photo visually
   slid underneath/through it. JS (setVideoPlaybarVisible in photos.js)
   toggles .playbar-visible on .photo-frame only once that animation has
   genuinely finished -- see its own comment for exactly when. Actually
   revealed only once THAT'S true and the mouse is hovering the video --
   see the .photo-frame.playbar-visible:hover rule below. */
.video-playbar {
    position: absolute;
    left: 50%;
    bottom: 6rem; /* doubled again from 3rem (itself already doubled from a first pass at 1.5rem) -- still read as too low/cramped against the bottom edge */
    transform: translateX(-50%);
    /* 90% of .photo-frame -- a true percentage now, not a fixed rem --
       so the reference (portrait) case always leaves exactly 5% clear
       on each side, at any window size. max-width is the backstop for
       a LANDSCAPE frame, which is nearly the full stage width -- 90% of
       that would balloon the bar far past what looks right, so it caps
       out at roughly the same absolute size the percentage produces on
       a typical portrait frame instead, keeping the two visually
       matched rather than wildly different sizes. */
    width: 90%;
    max-width: 26.5rem;
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    /* Horizontal inset now lives on .video-playbar-controls and
       .video-playbar-scrub individually (below) instead of here, since
       the two rows need DIFFERENT amounts of it -- the icon row sits
       close to the bar's edges, the timecode row noticeably further in. */
    padding: 0.625rem 0 0.75rem;
    opacity: 0;
    transition: opacity 0.15s ease;
    /* Lower alpha (was 0.55) plus the existing blur -- reads as true
       frosted glass, a smudge of whatever's behind it, rather than a
       near-solid panel. */
    background: rgba(30, 30, 30, 0.4);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border-radius: 0.875rem;
    color: #ffffff;
    pointer-events: none;
}

/* TEMPORARY, alongside the real-video test hookup (photos-data.js,
   wireRealVideoControls in photos.js): re-enables interaction for
   whichever specific playbar actually has a real <video> behind it --
   every other video tile is still the placeholder kind with nothing
   real to control, so its playbar stays exactly as inert as the base
   rule above already makes it. Re-enabling on .video-playbar itself
   (not deeper) is enough -- pointer-events:auto here lets hit-testing
   reach the buttons/track underneath again, and :hover still correctly
   propagates up through them to .photo-frame either way (see the
   comment on that rule below), so the hover-reveal behavior is
   unaffected by this. */
.video-playbar.interactive {
    pointer-events: auto;
}

.video-playbar.interactive .video-playbar-play,
.video-playbar.interactive .video-playbar-mute,
.video-playbar.interactive .video-playbar-track {
    cursor: pointer;
}

/* Two independent conditions have to both be true: .playbar-visible
   (toggled by photos.js -- the photo has actually settled into fullscreen,
   not still mid open/close animation) AND :hover (the mouse is currently
   over the video). :hover here targets .photo-frame, not .video-playbar
   itself -- the bar has pointer-events:none (see above), so the pointer
   hit-tests straight through it to .photo-media/.photo-frame underneath,
   meaning hovering the bar's own on-screen space still counts as
   hovering the frame and doesn't cause a flicker-off. .photo-frame is
   exactly the video's own rendered box (see its own comment), not the
   whole slide, so moving off the video (even while still elsewhere in
   the fullscreen viewer) is what triggers the fade-out. */
@media (hover: hover) { /* see .traffic-lights-group:hover, and the touch reveal after .controls-idle below */
    .photo-frame.playbar-visible:hover .video-playbar {
        opacity: 1;
    }
}

/* Reveals the bar WITHOUT hover, as a cue that the photo you've just
   navigated to is actually a video and needs a play press. The hover
   rule above says nothing at all to someone arrowing/swiping through
   with the mouse parked somewhere else -- a video at rest on its poster
   frame is indistinguishable from a still photo. .playbar-cue is added
   by showPlaybarCueAfterDelay (photos.js) half a second after landing,
   and removed once the message has served its purpose (playback starts,
   or you move on).

   Specificity note: this is 3 classes, the :hover rule above is 2
   classes + a pseudo-class -- an exact tie, so source order decides and
   this one wins by sitting later. Both set opacity:1, so that tie
   doesn't actually matter; what does is staying BELOW this and ABOVE
   the .controls-idle rule following it, which needs to keep out-ranking
   both of us to do its fullscreen auto-hide. */
.photo-frame.playbar-visible.playbar-cue .video-playbar {
    opacity: 1;
}

/* Fullscreen-only auto-hide, on top of the :hover rule above -- see the
   idle timer in wireRealVideoControls (photos.js) for exactly when
   .controls-idle gets added/removed. Needs one more class than the
   reveal rule above (5 selectors vs. 4) to reliably win against it on
   specificity alone, without needing !important -- :hover is still
   true here regardless (the pointer can't leave a fullscreen frame,
   since it fills the whole screen), so this has to actively out-rank
   it, not just avoid conflicting with it. */
@media (hover: hover) {
    .photo-frame.playbar-visible.controls-idle:hover .video-playbar {
        opacity: 0;
    }
}

/* The touch replacement for the :hover reveal: a tap on the video
   toggles .controls-shown (photoStage's click handler in photos.js).
   Never set by a mouse, so desktop is unaffected. Same specificity as
   the .playbar-cue rule. */
.photo-frame.playbar-visible.controls-shown .video-playbar {
    opacity: 1;
}

/* On touch, the bar takes taps only while it's actually showing.
   .interactive otherwise leaves pointer-events on at opacity 0, which
   is harmless with a mouse (hover reveals the bar before a click can
   land) but on a phone means a first tap where the invisible play
   button sits starts playback. */
@media (hover: none) {
    .video-playbar.interactive {
        pointer-events: none;
    }

    .photo-frame.playbar-visible.playbar-cue .video-playbar.interactive,
    .photo-frame.playbar-visible.controls-shown .video-playbar.interactive {
        pointer-events: auto;
    }
}

/* space-between (not a centered group with a fixed gap) so mute/cast
   land close to the bar's own left/right edges -- matched spacing on
   both sides since they're the same size -- while play, the only
   other item, ends up exactly centered as a natural side effect of
   having just one thing between two equal-width edge items. Its own
   small padding is what actually sets that "close to the edge" distance
   (up from flush against the bar's own edge). */
.video-playbar-controls {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 0.75rem;
}

/* Dimmer than the timecodes below (pure white) -- reads as a secondary/
   supporting element the way a real player's icons sit a step back from
   its actual numbers. */
.video-playbar-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 1.25rem;
    height: 1.25rem;
    /* Opaque color + opacity on the ELEMENT (not a translucent color)
       -- the play icon draws its triangle as both a fill AND a stroke
       of the same currentColor (see photos-data.js, the round-corner
       trick), and a translucent color there made the two overlap along
       the inner edge and double up, reading as a brighter triangle
       nested inside the dimmer one. Dimming post-render like this keeps
       the fill+stroke overlap fully opaque (identical color on top of
       itself is a no-op) and only fades the final flattened icon. */
    color: #ffffff;
    opacity: 0.72;
}

.video-playbar-btn svg {
    width: 100%;
    height: 100%;
}

.video-playbar-play {
    width: 1.75rem;
    height: 1.75rem;
}

/* Noticeably more inset than .video-playbar-controls above -- the
   timecodes read as "bumped in" from the bar's edges, not flush with
   the icon row sitting right above them. */
.video-playbar-scrub {
    display: flex;
    align-items: center;
    gap: 0.625rem;
    padding: 0 1.375rem;
}

.video-playbar-time {
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums; /* same "digits don't jiggle" reasoning as .tile-timecode */
    flex-shrink: 0;
}

/* touch-action: none is load-bearing for scrubbing by finger, not a
   tidying-up: this track sits inside .photo-stage, whose native
   scroll-snap is what pages the viewer from photo to photo. Without it
   the browser claims any sideways drag that starts here as a stage
   scroll before a single pointermove reaches the drag handler in
   wireRealVideoControls (photos.js) -- so dragging the bar would swipe
   to the next photo rather than seek. It also stops a vertical drag
   here bouncing anything. Harmless to the mouse, which never had either
   behavior to lose. */
.video-playbar-track {
    position: relative;
    flex: 1;
    height: 3px;
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.35);
    touch-action: none;
}

/* Watched-portion indicator -- width driven live off a real video's
   currentTime/duration by wireRealVideoControls (photos.js); stays at
   its default 0% for every placeholder video tile, which just reads as
   "not started yet", consistent with everything else static in there. */
.video-playbar-fill {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: 0%;
    border-radius: inherit;
    background: rgba(255, 255, 255, 0.8);
}

/* Parked at the very start of the track -- 0:00 elapsed, matching the
   static (never-played) state everything else in this overlay shows. */
.video-playbar-thumb {
    position: absolute;
    top: 50%;
    left: 0;
    width: 0.1875rem;
    height: 0.875rem; /* taller than the track itself -- reads as a small vertical capsule sitting on top of it, not a dot centered on it */
    border-radius: 999px;
    background: #ffffff;
    transform: translate(-50%, -50%);
}

/* --- scroll-reveal info panel --------------------------------------
   A custom design, not pulled from alanagoyal's source -- built from
   the user's own iOS Photos screenshot reference, adapted from its
   phone-width stack into a centered desktop column, then reworked
   several times past that original spec through direct feedback: the
   caption now lives INSIDE the "secondary" card rather than as its own
   big header above it, Date/Filename pair right-aligned against
   Platform/Tags in two rows rather than stacking as their own lines,
   and a pair of full-width divider lines tried between those rows was
   built and then removed again. What's unchanged from the original
   spec is the card-within-a-card structure (one "secondary" card,
   holding a nested "windowed" format/tools box one shade darker, with
   its own two text tiers -- main tool darker, details lighter) and the
   color palette. Colors are Apple's real, documented iOS system-color
   constants (light mode) -- not sampled off the screenshot's pixels,
   which isn't something this environment can do for an inline image
   (no file on disk to run a color-extraction script against) -- but
   exact, citable values, confirmed with the user over guessing:
     systemGray6  #F2F2F7  -- the secondary caption/tools card
     systemGray5  #E5E5EA  -- the nested windowed tools box (one step
                               darker than its systemGray6 parent)
     label        #1C1C1E -- primary text (caption, date, main tool)
     secondaryLabel rgba(60, 60, 67, 0.6) -- secondary text (filename,
                               tool details)
   Text sizes and weight are NOT from iOS, though -- per feedback,
   these should read as part of THIS interface: sized down to match
   the sidebar's own scale (.sidebar-item's size as the largest text
   here, .sidebar-heading's as the smallest -- the --info-size-* knobs
   at the top of the file keep that same ladder) and never bold, same
   as the sidebar's own unbolded text. Font itself is
   inherited straight from .photos-screen (see its own comment above), so this
   already matches the sidebar's real SF Pro stack with no override
   needed here. */
/* Horizontal 16%/vertical 4rem-bottom padding corrected against the
   reference screenshot's actual proportions (measuring the map's inset
   against its content pane's width) -- it is NOT flush with this
   file's own 1rem grid/header gutter like an earlier pass assumed; it
   carries a real, sizeable gutter of its own, around 16% of the
   content pane's width on each side. Percentage rather than a fixed
   rem so that relationship holds as the window is resized, instead of
   a fixed px gutter that would read as huge in a narrow window and
   trivial in a wide one. Like the spacing note above, macOS Photos is
   closed-source, so this is a visual-proportion calibration measured
   off the screenshot, not a citable spec value the way the Apple
   UIColor constants are.
   The 0.75rem TOP padding is separate and more recent: without it, the
   card's content (Caption, now the first thing inside it -- see
   .photo-info-caption's own comment) starts the instant you scroll
   past the photo/video, flush against its bottom edge with no breathing
   room at all. 0.75rem (12px) gives that edge a small gap of its own,
   deliberately smaller than the 1rem (16px) .photo-info-secondary
   already puts above Caption internally -- that 1rem is "space inside
   the card, before its text"; this is "space outside the card, before
   the card itself." */
.photo-info {
    padding: 0.75rem 16% 4rem;
}

/* Tier 2: the "secondary element" card. Radius and padding are sized
   up a step from before to match the roomier feel of macOS Photos' own
   map/info card (see the reference screenshot) -- that's a real app,
   but a closed-source native one, so unlike the exact color values
   below, there's no actual spec to fetch for ITS spacing; that part is
   a deliberate visual calibration off the screenshot, not a verified
   value. */
.photo-info-secondary {
    background: #f7f7f9; /* user-sampled via Photoshop's color picker directly off the reference screenshot -- supersedes the earlier Apple systemGray6 (#F2F2F7) approximation */
    border-radius: 0.75rem;
    padding: 1rem 1.125rem;
}

/* Caption -- prefilled metadata (see captionFor in the script below),
   NOT an editable field. There's no real caption data source yet, so
   this is placeholder text standing in for what will eventually be
   pre-written per photo, not typed by a viewer.
   This card's own top/only line now -- Date/Filename used to hold that
   spot and the caption sat outside the card entirely; the two swapped
   (an explicit restyle, not a bugfix), so Caption now relies on the
   card's own padding for its box inset exactly like Date did before
   it, rather than carrying a matching inset of its own to line up with
   a card it used to sit beside instead of inside. */
.photo-info-caption {
    font-size: var(--info-size-caption); /* matches .sidebar-item's text size (both 0.875rem) -- the largest text in this panel; untouched by the Date/Platform size swap below, which is specifically about those two fields */
    font-weight: 400; /* not bold, matching the sidebar's own unbolded item text */
    color: #1c1c1e; /* real value: Apple's UIColor.label */
    margin: 0;
}

.photo-info-tools {
    margin-top: 1rem;
}

/* Tier 3: the nested "windowed" box -- just a rounded clipping
   container now (no background/padding of its own); its two rows
   each carry their own background, so the color change BETWEEN them
   is what separates the two sections, the same way the iOS
   reference's camera card reads as "one bolder header row, one
   lighter body row" rather than a single flat block of text. */
.photo-info-tools-window {
    border-radius: 0.625rem;
    overflow: hidden;
}

/* Darker tier: the main tool's own section -- a distinct darker-grey
   strip, one step darker than the row below it, separating it from
   the supporting details underneath by color AND weight -- bold here,
   since this is where [Project Title] shows up for a tile that has one.
   Bold used to be unique to this strip; [Credit] in the tier below is
   now the one other place the panel uses it, so color (not weight
   alone) is what marks this row as the header of the two. */
.photo-info-tool-main {
    background: #dedee2; /* user-sampled via Photoshop's color picker -- supersedes the earlier Apple systemGray4 (#D1D1D6) approximation */
    padding: 0.5rem 0.875rem;
    font-size: var(--info-size-project);
    font-weight: 600;
    color: #1c1c1e; /* real value: Apple's UIColor.label -- same near-black used in the reference */
}

/* Lighter tier: supporting details, in their own lighter section
   below the darker header strip above. A wrapper (was the text element
   itself) since it holds [Credit] as its own full-width line, then two
   label/value ROWS (.photo-info-tool-row below) rather than five
   stacked lines: [Platform] left + Date right, then [Tags] left +
   [Filename] right. A pair of full-width divider lines between those
   was tried here and removed again before landing on this two-column
   layout. [Credit]/[Platform]/[Filename] hide themselves entirely when
   their row has no value rather than rendering empty (see
   renderPhotoInfo); Date/[Tags] follow the same left-column position
   regardless. */
.photo-info-tool-details {
    background: #ececf0; /* user-sampled via Photoshop's color picker -- supersedes the earlier Apple systemGray5 (#E5E5EA) approximation */
    padding: 0.5rem 0.875rem;
}

/* Each row pairs one left-aligned field with one right-aligned field --
   [Platform]+Date in the first, [Tags]+[Filename] in the second (see
   the markup in index.html). flex-wrap/column-gap are a safety net,
   not the normal case: real values here (platform names, dates, tag
   words, filenames) are short enough to sit comfortably on one line at
   this box's usual width, but nothing stops a long filename from
   needing to wrap rather than overflow the box or overlap the text on
   the left.
   The right-hand field in each row (Date, [Filename] -- see their own
   rules) uses margin-left: auto, not justify-content: space-between on
   this wrapper, specifically so it stays pinned to the right edge even
   when it's the ONLY visible child -- [Platform] and [Tags] are both
   optional and Date/[Filename] don't reposition themselves based on
   whether their row-mate happens to be hidden. */
.photo-info-tool-row {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    column-gap: 0.75rem;
}

/* Gap between the two rows -- same 0.125rem minor-gap value used
   elsewhere in this tier (see .photo-info-tool-platform's old comment
   history). Lives on the SECOND row via the adjacent-sibling combinator
   rather than as a margin-bottom on the first, so it's anchored to the
   fixed DOM relationship between the two row wrappers -- unaffected by
   which of [Platform]/Date/[Tags]/[Filename] inside either row happen
   to be hidden, unlike the per-element collapsing-margin tricks used
   elsewhere in this file for the five-line layout this replaced. One
   accepted edge case: if [Tags] AND [Filename] are both hidden, the
   second row collapses to zero height but still carries this margin,
   leaving a trailing 0.125rem of empty space below the first row --
   minor enough not to be worth extra markup to special-case. */
.photo-info-tool-row + .photo-info-tool-row {
    margin-top: 0.125rem;
}

/* [Credit] -- the emphasized line of the details tier, and the only
   bold thing below the header strip. It carries the role actually
   credited on the project, so it's given weight of its own instead of
   blending into the platform line immediately beneath it, which is set
   in the same size and color.
   Its 0.5rem bottom margin deliberately mirrors the 0.5rem of padding
   .photo-info-tool-details already puts ABOVE it, so the credit sits in
   an evenly spaced band of its own rather than sitting tight against
   [Platform]/[Tags] the way those two sit against each other.
   This and .photo-info-tool-platform were briefly a single shared rule,
   on the theory that they were peer facts deserving identical
   treatment. They aren't: the credit is both heavier and spaced apart,
   so they're two rules again rather than one rule plus a pile of
   overrides.
   The gap lives on THIS element as a margin-bottom rather than becoming
   a margin-top on the line below -- either black line can be hidden,
   and a gap attached to the following element would be left stranded as
   unwanted space whenever the line above it isn't rendered. Attached
   this way the spacing appears only when the line itself does, which
   stays correct for all four combinations of the two being present or
   absent. */
.photo-info-tool-credit {
    font-size: var(--info-size-detail);
    font-weight: 600; /* same weight as .photo-info-tool-main's [Project Title], the panel's other bold element */
    color: #1c1c1e; /* real value: Apple's UIColor.label -- black, same as .photo-info-tool-main above */
    margin-bottom: 0.5rem; /* matches .photo-info-tool-details' own padding above this line */
}

/* [Platform] -- same tier, size and color as [Credit] above, but left
   at normal weight: it's supporting context for the credit, not a peer
   of it. Briefly sized up to the larger project-title tier as part of a
   swap with [Date], then reverted back to its original
   --info-size-detail (matching [Tags]) --
   [Date] kept its half of that swap (see its own rule), so the two no
   longer match sizes the way they did before either change.
   Sits in the LEFT column of its row -- no positioning rule needed of
   its own, that's just normal flex flow; see .photo-info-tool-row for
   the row mechanics and .photo-info-date for its right-column partner. */
.photo-info-tool-platform {
    font-size: var(--info-size-detail);
    font-weight: 400;
    color: #1c1c1e; /* real value: Apple's UIColor.label -- black, same as .photo-info-tool-main above */
}

/* [Date] -- was this card's own large "primary" line when it lived up
   in .photo-info-secondary (Caption's position now); relocated here as
   part of an earlier restyle, with its font-size swapped against
   [Platform]'s former one (--info-size-detail, the smallest size in this
   panel, same as [Tags]) rather than keeping the larger project-title
   tier it had before the move. [Platform]'s half of that swap was
   since reverted; [Date]'s was
   kept, so [Date] now reads at the same quiet size as [Filename]/[Tags]
   rather than standing out.
   Pinned to the RIGHT edge of its row via margin-left: auto, paired
   with [Platform] on the left -- see .photo-info-tool-row's comment for
   why auto-margin rather than justify-content. Always present, unlike
   [Filename]/[Platform]/[Credit]/[Tags] -- infoDateFor never returns
   null (an unknown date falls back to created_at), so this row is
   never left fully empty the way the second one can be. */
.photo-info-date {
    font-size: var(--info-size-detail);
    font-weight: 400; /* not bold */
    color: #1c1c1e; /* real value: Apple's UIColor.label */
    margin-left: auto;
}

/* [Filename] -- moved down from .photo-info-secondary alongside [Date],
   same earlier restyle; now paired with [Tags] on the second row
   instead of stacked under [Date] on the first. Keeps the font-size it
   always had (--info-size-detail) -- it was already the smallest text in this
   panel, same tier [Platform] briefly occupied and has since reverted
   to; optional per tile, hidden entirely when a tile has none of its
   own (see filenameFor).
   Pinned to the RIGHT edge of its row via margin-left: auto, same
   mechanics as [Date] above -- see .photo-info-tool-row's comment. */
.photo-info-filename {
    font-size: var(--info-size-detail); /* matches .sidebar-heading's text size (both 0.75rem) -- the smallest in this panel */
    font-weight: 400;
    color: rgba(60, 60, 67, 0.6); /* real value: Apple's UIColor.secondaryLabel */
    margin-left: auto;
}

/* [Tags] -- the LEFT column of the second row, paired with [Filename]
   on the right; no positioning rule of its own needed, same as
   [Platform] above it. */
.photo-info-tool-tags {
    font-size: var(--info-size-detail);
    font-weight: 400;
    color: rgba(60, 60, 67, 0.6); /* real value: Apple's UIColor.secondaryLabel */
}

/* --- safe-area insets ----------------------------------------------
   index.html's viewport meta sets viewport-fit=cover, which lets the
   page draw under the iPhone's notch/Dynamic Island and home indicator
   rather than being letterboxed inside them. That's only correct if
   everything that TOUCHES a screen edge pads itself back out by the
   matching env(safe-area-inset-*) -- which is all this section does.
   Every inset is 0 on desktop (and on any device without a notch), so
   each calc() below collapses to exactly the value its original rule
   already sets: the desktop layout doesn't move by a pixel.

   Collected here as overrides, rather than edited into each original
   rule, so the whole edge-to-edge story reads in one place -- the
   mobile redesign will rework most of these elements, and this is the
   list of what it has to keep honoring. Each override names the rule
   it extends.

   Which edges each element takes follows the CURRENT layout: the
   sidebar owns the left edge (landscape notch side), .main owns the
   right, both own the top, and only the scrolling content (grid, info
   panel, sidebar list) needs the bottom -- nothing in this layout is
   fixed to the bottom edge. Photos themselves are deliberately NOT
   inset: a full-bleed photo running under the notch in landscape is
   what iOS Photos does too, and the video playbar already sits 6rem
   above its frame's bottom edge (see .video-playbar), well clear of
   the home indicator. */
:root {
    --safe-top: env(safe-area-inset-top, 0px);
    --safe-right: env(safe-area-inset-right, 0px);
    --safe-bottom: env(safe-area-inset-bottom, 0px);
    --safe-left: env(safe-area-inset-left, 0px);
}

/* .sidebar -- the width grows by the left inset rather than the padding
   eating into --sidebar-width, so in landscape the items keep their
   real width and the notch just gets its own strip beside them. */
.sidebar {
    width: calc(var(--sidebar-width) + var(--safe-left));
    padding-top: calc(0.5rem + var(--safe-top));
    padding-left: calc(0.5rem + var(--safe-left));
    padding-bottom: calc(0.5rem + var(--safe-bottom));
}

/* .content-header -- BOTH headers share this class (the grid's, and the
   viewer's own), so this covers the viewer's back button/date/actions
   too. The --header-height grows by the top inset and padding-top
   absorbs it, so the actual content row below the status bar stays
   exactly the original height-minus-padding tall. */
.content-header {
    height: calc(var(--header-height) + var(--safe-top));
    min-height: calc(var(--header-height) + var(--safe-top));
    padding-top: calc(0.5rem + var(--safe-top));
    padding-right: calc(1rem + var(--safe-right));
}

/* .content-header-center / .viewer-header-center -- both center
   themselves on the WHOLE header box, which now includes the inset
   strip; shifted so they center on the safe content row instead of
   riding up into the status bar. */
.content-header-center {
    top: var(--safe-top);
    right: var(--safe-right);
}

.viewer-header-center {
    top: calc(50% + var(--safe-top) / 2);
    left: calc(50% - var(--safe-right) / 2);
}

/* .grid -- the last row can scroll clear of the home indicator, and the
   right column clear of the landscape notch. */
.grid {
    padding-right: calc(1rem + var(--safe-right));
    padding-bottom: calc(1rem + var(--safe-bottom));
}

/* .photo-info -- same idea for the bottom of the info panel. Its own
   16% side gutters are already wider than any landscape inset. */
.photo-info {
    padding-bottom: calc(4rem + var(--safe-bottom));
}
