/* =====================================================================
   PHONE LAYOUT -- loaded by index.html with
   media="not all and (min-width: 700px)", the exact complement of the
   (min-width: 700px) rule in photos-style.css that switches the grid
   from 3 columns to the zoom pill's 6/5/4. So "phone chrome" and "3
   columns" can never disagree about which side of the line a viewport
   is on, and a desktop-width window never even applies this file:
   photos-style.css stays the desktop source of truth, untouched.

   Same DOM as desktop, restyled. Nothing here (or anywhere) removes an
   element from the document: display:none takes it out of layout,
   hit-testing, the accessibility tree and tab order, which is "gone" for
   anyone using the page, while every lookup in photos.js still finds it.
   That matters because these aren't dead weight on a phone: the sidebar
   items ARE the filter state (.active + their click handlers -- the
   planned top-of-screen icon rows will drive them rather than duplicate
   them), and a phone rotating or a window resizing across 700px has to
   get its desktop chrome back live, with the viewer possibly open,
   without anything rebuilding DOM. photos.js's null guards (see the
   MISSING-ELEMENT RULE at its top) are insurance, not load-bearing.

   Rules here only ever win by loading later at equal-or-higher
   specificity -- no !important. The grid's own header is always
   addressed as `.main > .content-header`: the viewer has a second
   .content-header of its own (back button, date, actions), which this
   pass deliberately leaves exactly as it is.
   ===================================================================== */

:root {
    /* Shared by the sticky title and its scrim below. */
    --mobile-title-top: 0.375rem; /* gap between the status bar/notch and "Library" */
    --mobile-title-block: 3rem; /* h1 line-height (1.875rem) + date-range line-height (1.125rem) */
    --mobile-heading-size: 1.5625rem; /* 25px -- "Library" and "Pinned Collections" share it */
    --mobile-heading-line: 1.875rem;

    /* The viewer's header, the back chevron, its date/counter and the
       Years/Months group headers share rules with the desktop chrome,
       whose sizes are tuned by the knobs at the top of photos-style.css.
       The phone keeps the values it always had there, so retuning desktop
       can't move it. (--subtitle-size isn't pinned: the only thing using
       it on a phone is .content-header-title p, which the sticky-title
       section below overrides outright.) */
    --header-height: 69px;
    --header-icon-size: 1.25rem;
    --title-size: 1.125rem;
    --title-line: 1.75rem;
    --viewer-date-size: 0.875rem;
    --viewer-counter-size: 0.75rem;
}

/* --- window chrome: gone ---------------------------------------------
   A phone has no window: no frame, radius, hairline or shadow. The
   traffic lights live inside .sidebar, so they go with it. */
.window {
    border: 0;
    border-radius: 0;
    box-shadow: none;
}

.sidebar {
    display: none;
}

/* --- sticky title ------------------------------------------------------
   On a phone, the grid's title block (.content-header-title: the
   collection back button, "Library"/the collection's name, the date
   range) is moved INSIDE .grid-scroll by photos.js (placeGridTitle),
   between the Pinned Collections row and the grid, and made sticky.
   It used to float over the grid from outside the scroller and be
   repositioned from JS on every scroll event to sit under the Pinned
   Collections row -- but iOS delivers scroll events late and in bursts
   while the content itself scrolls on a separate, frame-perfect path, so
   the title visibly lagged and snapped back: the jiggle. As a sticky
   element inside the scroller, the browser moves it on that same path:
   it scrolls up with the row, then pins at the top once the row is gone
   (or straight away inside a collection, where the row is hidden), with
   no JS involved in where it is.

   The grid's own header bar, now empty on a phone (its title has moved,
   and the zoom pill / time filter / action buttons are phone-hidden),
   goes entirely. Desktop never sees any of this: placeGridTitle leaves
   the title in the header there.

   pointer-events:none, because once pinned it covers the top of the
   grid: the top row's tiles stay tappable through it (the back button
   opts back in, below). z-index 10: above the tiles, below .viewer's 30
   and the 40 flip() gives a photo in flight.

   The bottom padding is the scrim's tail (below); the matching negative
   margin lets the grid run up underneath that tail, so in flow there's
   just the intended 0.625rem between the date range and the first row. */
.main > .content-header {
    display: none;
}

.grid-scroll > .content-header-title {
    position: sticky;
    top: 0;
    z-index: 10;
    padding: calc(var(--safe-top) + var(--mobile-title-top)) calc(1rem + var(--safe-right)) 1.75rem calc(1rem + var(--safe-left));
    margin-bottom: -1.125rem;
    pointer-events: none;
}

/* The legibility scrim: a top-down dark gradient, chosen over a blur.
   A blur doesn't fix the actual problem -- a blurred light photo is
   still light, so white text over it stays unreadable unless the blur
   is also tinted dark, at which point it's a scrim with a blur on top.
   backdrop-filter over a scrolling grid of full-res images also re-blurs
   on every frame of momentum scrolling (expensive on an iPhone), and
   gives a hard bottom edge unless it's masked. The gradient costs
   nothing to composite, has no edge, and is what iOS Photos itself puts
   behind its Library title. Several stops rather than two, so the fade
   eases out instead of showing a visible band where it ends.

   The stops are placed against the TEXT, not as percentages of the box:
   the box grows by the safe-area inset (~59px on a current iPhone), and
   percentage stops would stretch with it, sliding the thin end of the
   fade under the title on exactly the phones with a notch. Anchored
   like this, the h1 always sits on ~0.45 black and the date range on
   ~0.32 whatever the inset, and only the tail below the text fades
   out. (Inside a collection the back button's row sits above the h1, so
   the text sits a little lower against these stops -- still on the
   dark part of the fade.)

   Its own layer (not the block's background) so it can fade in and out
   on .is-scrolled below. z-index -1 inside the block's own stacking
   context (z-index 10 above) keeps it behind the text. */
.grid-scroll > .content-header-title::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    background: linear-gradient(
        to bottom,
        rgba(0, 0, 0, 0.55) 0,
        rgba(0, 0, 0, 0.45) calc(var(--safe-top) + var(--mobile-title-top) + var(--mobile-heading-line)),
        rgba(0, 0, 0, 0.3) calc(var(--safe-top) + var(--mobile-title-top) + var(--mobile-title-block)),
        rgba(0, 0, 0, 0) 100%
    );
    opacity: 0;
    transition: opacity 0.2s ease;
}

/* .is-scrolled is toggled by photos.js (syncGridHeaderOverlap) once
   photos are actually passing under the title's text -- measured, the
   grid's top edge above the bottom of the date range. Before that only
   white page is behind it, where white text would simply vanish, so the
   title starts out dark with no scrim and switches to white-on-scrim
   only once there's a photo behind it -- iOS's own navigation-bar
   behaviour (a separate appearance for "scrolled to the edge"). */
.grid-scroll > .content-header-title.is-scrolled::before {
    opacity: 1;
}

.grid-scroll > .content-header-title h1 {
    font-size: var(--mobile-heading-size);
    line-height: var(--mobile-heading-line);
    font-weight: 700;
    letter-spacing: -0.01em;
    color: #1c1c1e; /* Apple's UIColor.label, same as the info panel's primary text */
    transition: color 0.2s ease;
}

.grid-scroll > .content-header-title p {
    font-size: 0.8125rem;
    line-height: 1.125rem;
    font-weight: 600;
    color: rgba(60, 60, 67, 0.6); /* UIColor.secondaryLabel */
    transition: color 0.2s ease;
}

/* The white-on-scrim state. The faint text-shadow is a second line of
   defence for the one case the gradient handles worst -- a near-white
   photo right under the text, which is also where the scrim is
   thinnest. */
.grid-scroll > .content-header-title.is-scrolled h1,
.grid-scroll > .content-header-title.is-scrolled p {
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}

.grid-scroll > .content-header-title.is-scrolled h1 {
    color: #ffffff;
}

.grid-scroll > .content-header-title.is-scrolled p {
    color: rgba(255, 255, 255, 0.85);
}

/* --- grid: full-bleed ----------------------------------------------------
   Edge to edge like iOS Photos: no outer gutter, hairline 2px gaps,
   square corners. Still the same 3 columns photos-style.css already
   pins below 700px. No top padding of its own: the sticky title above
   sits in flow and already leaves the gap (see its negative margin).
   The sides and bottom pad by the safe-area insets only, since with no
   sidebar .main now owns the left edge as well as the right. */
.grid {
    gap: 2px;
    padding: 0 var(--safe-right) var(--safe-bottom) var(--safe-left);
    /* At least a screen tall, so the grid can always be scrolled until its
       first row is right up under the pinned title. Without it a short set
       (today's whole library, or any small collection) runs out of scroll
       first, and the page bottoms out with a strip of white page sitting
       under the title's dark scrim -- a grey band. align-content: start
       keeps the extra height as empty space below the last row instead of
       stretching the rows into it. */
    min-height: 100%;
    align-content: start;
}

.tile {
    border-radius: 0;
}

/* Years/Months headers only show up here if a grouping was picked at
   desktop width before resizing down (the time filter is hidden on a
   phone), but with no outer gutter any more they'd sit flush against
   the screen edge, so give them the title's own 1rem inset. */
.grid-group-header {
    padding: 0 calc(1rem + var(--safe-right)) 0 calc(1rem + var(--safe-left));
}

/* --- viewer: the media IS the screen -------------------------------------
   With .sidebar gone, .viewer (absolute/inset:0 in .main) already covers
   the whole screen; what kept the media from using all of it was the
   viewer's own 69px header taking a row off the top. Taking that header
   out of flow lets .viewer-body -- and with it .photo-pane/.photo-stage --
   fill the full height, and sizeSlideMedia (which measures .photo-stage)
   then contain-fits each photo/video as large as its native aspect ratio
   allows. No JS change: it just measures a bigger box.

   The header floats over the top of the media instead, as a translucent
   bar, iOS Photos' own viewer treatment. Unlike the grid's title above,
   a blur is the right call here: the text stays dark on a light frosted
   bar, so the legibility problem a blur can't solve for white text never
   comes up, and what's underneath is one photo, not a momentum-scrolling
   grid of them. It overlays the info panel too, once that's scrolled up.

   z-index 50 inside .viewer's own stacking context (z-index 30): above
   the 40 flip() gives a photo in flight, so an opening photo grows in
   UNDER the bar rather than over it and then snapping beneath it when
   the animation ends. */
.viewer > .content-header {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    z-index: 50;
    background: rgba(246, 246, 246, 0.78);
    -webkit-backdrop-filter: saturate(180%) blur(20px);
    backdrop-filter: saturate(180%) blur(20px);
    border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}

/* Info / share / rotate / favorite: not on a phone. The grid header's
   copy is already hidden above; this is the viewer's. Hidden, not
   removed -- setFavoriteButtonState still writes to the favorite button,
   harmlessly. */
.viewer .content-header-actions {
    display: none;
}

/* --- video playbar: iOS Photos' own controls -----------------------------
   Restyles the same playbar markup (createVideoPlaybar, photos-data.js)
   to match iOS Photos' video controls: no panel at all -- no background,
   no blur, no rounded box -- just play/pause on the left and mute on the
   right, in one row above a single thick progress bar that fills with
   white as the video plays. No timecodes, no scrubber thumb, no
   fullscreen button. Only appearance changes here: when the bar shows
   (.playbar-visible + the touch reveal/cue, photos-style.css) and what
   each control does (wireRealVideoControls, photos.js) are untouched.

   Anchored to .photo-frame -- the video's own contain-fit box -- the same
   way the desktop bar is, just stretched across it with a fixed side
   inset instead of centered at 90%. For a vertical video that box is the
   whole screen, so the controls land along the bottom of the screen as
   in iOS; a landscape video gets them along its own bottom edge. The
   safe-area inset keeps them clear of the home indicator in landscape.

   With no panel behind them, white controls over a bright frame would
   wash out, so every element carries a soft drop shadow -- the same
   job the panel's dark glass used to do, without the box. */
.photo-frame .video-playbar {
    left: 1.25rem;
    right: 1.25rem;
    bottom: calc(var(--filmstrip-height) + 0.75rem + var(--safe-bottom)); /* just above the filmstrip (see its section below) */
    width: auto;
    max-width: none;
    transform: none;
    gap: 1rem;
    padding: 0;
    background: none;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    border-radius: 0;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
}

/* Two items, pushed to opposite edges by the row's existing
   space-between: play first (order -1 moves it ahead of mute, which
   comes first in the markup), mute last. Fullscreen is out of the row
   entirely -- display:none rather than the visibility:hidden photos.js
   uses, which keeps a slot open to hold desktop's centered play button
   in place; here there's nothing to center. */
.photo-frame .video-playbar-controls {
    padding: 0;
}

.photo-frame .video-playbar-play {
    order: -1;
}

.photo-frame .video-playbar-fullscreen {
    display: none;
}

/* A 24px glyph in a 44px touch target (Apple's minimum). The negative
   margin gives back the extra 10px on each side, so the glyphs still sit
   flush with the progress bar's ends below and the row's height doesn't
   grow. Full white, not desktop's 0.72 dim -- iOS's controls are solid. */
.photo-frame .video-playbar-btn {
    width: 2.75rem;
    height: 2.75rem;
    margin: -0.625rem;
    padding: 0.625rem;
    opacity: 1;
}

/* The progress bar: the scrub row's two timecodes and thumb are hidden,
   so the track alone spans the full width. Thicker than desktop's 3px
   hairline, fully rounded; the unplayed part is a faint white (reads as
   dark grey over a dark frame, as in iOS) and .video-playbar-fill --
   already driven by wireRealVideoControls -- is the solid white played
   part. */
.photo-frame .video-playbar-scrub {
    padding: 0;
    gap: 0;
}

.photo-frame .video-playbar-time,
.photo-frame .video-playbar-thumb {
    display: none;
}

.photo-frame .video-playbar-track {
    height: 0.375rem;
    background: rgba(255, 255, 255, 0.28);
}

/* The track scrubs by finger (the pointer-event drag in
   wireRealVideoControls, photos.js, is the same code the mouse uses),
   but 6px is far too thin to actually catch a fingertip. This invisible
   pad grows the hit area to 28px without changing anything visible --
   events on it target the track itself, since it's that element's own
   pseudo-element.
   The two insets are deliberately uneven. Downward is free space (the
   filmstrip below is a separate, higher layer and takes its own taps,
   so it just overlaps the last few px harmlessly). Upward is not: the
   play/mute buttons are 44px targets whose boxes already hang 10px
   below their glyphs into the 16px row gap, so this stops at the 6px
   that are genuinely free -- any more and the track would start
   swallowing the bottom edge of those buttons. */
.photo-frame .video-playbar-track::before {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    top: -0.375rem;
    bottom: -1rem;
}

/* Held state, as iOS does it: the bar thickens under the finger while
   you scrub and settles back on release. .scrubbing is added/removed by
   the drag itself (photos.js), so it covers a mouse drag too -- it just
   has no visible effect on desktop, whose own hairline track is
   deliberately unchanged. Only the track's own height animates -- the
   fill is height:100% of it and follows for free. */
.photo-frame .video-playbar-track {
    transition: height 0.15s ease;
}

.photo-frame .video-playbar.scrubbing .video-playbar-track {
    height: 0.625rem;
}

.photo-frame .video-playbar-fill {
    background: #ffffff;
}

/* --- no whole-page bounce ----------------------------------------------
   Nothing on this page scrolls at the document level (the grid, the
   viewer and its info panel each scroll inside their own box), but iOS
   still rubber-bands the entire page when a drag starts on something
   that can't scroll itself -- the floating title, the viewer's header --
   which reads as the page lurching or about to reload. Stops that at the
   root; the inner scrollers keep their own native bounce. */
html,
body {
    overscroll-behavior: none;
}

/* --- poster backing for autoplay ------------------------------------------
   autoplayOnPhone (photos.js) paints a video's poster onto its
   .photo-frame as a background-image just before playing it, so the gap
   between iOS dropping the poster and the first frame arriving shows the
   same image instead of the viewer's white. contain, to match how the
   video draws its poster in the viewer (letterboxed on the frame's black
   -- see .photo-frame.poster-letterbox in photos-style.css): a poster of a
   different shape than the video then looks the same before and during
   the gap. No image is set here -- only autoplay sets one, and
   resetVideoPlayback clears it. */
.photo-frame {
    background-size: contain;
    background-position: center;
    background-repeat: no-repeat;
}

/* --- filmstrip ------------------------------------------------------------
   iOS Photos' strip of small thumbnails along the bottom of the viewer:
   narrow slivers, the current one wider and centered (buildFilmstrip /
   syncFilmstrip in photos.js do the building, centering and tap-to-jump).
   Pinned to the bottom of .viewer, outside .viewer-body, over whatever's
   behind it with no bar of its own, as in iOS. The playbar above (see
   .photo-frame .video-playbar) is lifted to sit just clear of it.

   It scrolls sideways on its own (a long library won't fit), with
   touch-action pan-x so a vertical drag on it does nothing rather than
   bouncing anything. Spacers at both ends let the first and last thumbs
   actually reach the center. z-index 50: level with the viewer's header,
   above the photo and the 40 flip() gives a photo in flight. */
:root {
    --filmstrip-height: 3.25rem; /* 2.25rem thumbs + 0.5rem above and below */
    --filmstrip-thumb-w: 1.375rem; /* every thumb at rest */
    --filmstrip-current-w: 2.75rem; /* the current one, fully widened */
    --filmstrip-current-gap: 0.375rem; /* extra space each side of the current one */
}

.filmstrip {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 50;
    display: flex;
    align-items: center;
    gap: 2px;
    height: calc(var(--filmstrip-height) + var(--safe-bottom));
    padding: 0 var(--safe-right) var(--safe-bottom) var(--safe-left);
    overflow-x: auto;
    overflow-y: hidden;
    overscroll-behavior-x: contain;
    touch-action: pan-x;
    scrollbar-width: none;
    transition: opacity 0.2s ease;
    /* Edge fade: the outermost thumb-and-a-half at each end dissolves
       into whatever's behind, rather than being sliced off by the screen
       edge. A mask, not a gradient overlay, so it fades the thumbs
       themselves and works over any photo, light or dark. */
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 2.25rem, #000 calc(100% - 2.25rem), transparent 100%);
    mask-image: linear-gradient(to right, transparent 0, #000 2.25rem, #000 calc(100% - 2.25rem), transparent 100%);
}

.filmstrip::-webkit-scrollbar {
    display: none;
}

/* Half the strip, minus half the current thumb and its side margin, so
   the first/last thumb can scroll to dead center.
   align-self: stretch is load-bearing, not cosmetic: with the strip's
   align-items: center, an empty pseudo-element is 0px tall, and WebKit
   (iOS Safari) leaves a zero-height box out of the scrollable area
   entirely. The trailing spacer then didn't exist as far as scrolling
   was concerned, the strip ran out of room long before the later thumbs
   could reach the center, and they sat stuck off to the right. Chrome
   counts it either way, which is why this only showed on the phone. */
.filmstrip::before,
.filmstrip::after {
    content: '';
    flex: 0 0 calc(50% - var(--filmstrip-current-w) / 2 - var(--filmstrip-current-gap));
    align-self: stretch;
}

/* Width and side margin both scale with --e, a 0-to-1 "how current is
   this thumb" set inline by photos.js (renderFilmstripAt): 0 is a plain
   sliver, 1 the fully widened current thumb. Mid-swipe the outgoing and
   incoming thumbs sit at in-between values that add up to 1, so the
   widening slides from one to the other in step with the finger instead
   of jumping when the swipe settles. No width transition, on purpose --
   --e already moves smoothly with the swipe, and JS measures the result
   in the same frame. */
.filmstrip-thumb {
    flex: 0 0 auto;
    width: calc(var(--filmstrip-thumb-w) + var(--e, 0) * (var(--filmstrip-current-w) - var(--filmstrip-thumb-w)));
    margin: 0 calc(var(--e, 0) * var(--filmstrip-current-gap));
    height: 2.25rem;
    padding: 0;
    border: 0;
    border-radius: 2px;
    overflow: hidden;
    background: #d1d1d6; /* UIColor.systemGray4 -- what a thumb shows until (or instead of) its image */
    -webkit-tap-highlight-color: transparent;
}

.filmstrip-thumb img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* Out of the way while the info panel is up (set by photos.js on
   .viewer-body scroll), as iOS does. */
.viewer.info-revealed .filmstrip {
    opacity: 0;
    pointer-events: none;
}

/* --- the viewer's chrome, as one layer -------------------------------------
   Header, playbar and filmstrip show and hide TOGETHER: every open starts
   with all three up, and a tap on the photo (or the space around it)
   toggles .chrome-hidden on .viewer -- see viewerBody's click handler in
   photos.js. That replaces the per-video playbar toggle (.controls-shown)
   and the hover/cue reveals in the phone layout: here the playbar is
   simply up whenever the chrome is, once the video has landed
   (.playbar-visible, which flips off during open/close/drag on its own).

   Hidden with visibility as well as opacity, for two reasons: hidden
   controls must not take taps, and the drag-to-dismiss writes inline
   opacity on the header and filmstrip while you drag -- inline opacity
   beats a class, but can't make a visibility:hidden element visible, so
   hidden chrome stays hidden through a drag too. The visibility switch
   is delayed by the fade's length on the way out (so the fade is seen)
   and immediate on the way back in. */
.viewer > .content-header,
.filmstrip {
    transition: opacity 0.2s ease, visibility 0s linear 0s;
}

.viewer:not(.chrome-hidden) .photo-frame.playbar-visible .video-playbar {
    opacity: 1;
}

.viewer:not(.chrome-hidden) .photo-frame.playbar-visible .video-playbar.interactive {
    pointer-events: auto;
}

.viewer.chrome-hidden > .content-header,
.viewer.chrome-hidden .filmstrip,
.viewer.chrome-hidden .photo-frame .video-playbar {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity 0.2s ease, visibility 0s linear 0.2s;
}

/* --- favorite heart: sized between desktop and the timecode ---------------
   On the phone grid the heart badge (bottom-left, .tile-favorite) sits
   halfway between its desktop size and the height of the video timecode
   (bottom-right, .tile-timecode) -- matching the digits exactly read as
   too small. Sizes here are of the DRAWN heart, not its box: the path
   fills y 4-21 of its 24-unit viewBox (71%), so the 21px desktop box this
   was derived against drew a 14.9px heart, and the timecode's digits are
   8.65px tall (measured at its 12px). Halfway is an 11.8px heart, i.e. a
   16.6px box. (Desktop has since been taken down to 17.85px on its own --
   these phone values are the tuned ones, not a live 50% of whatever
   desktop currently is, so they stand.)
   Positioned so the drawn heart still lines up with the timecode: its
   bottom on the digits' baseline (9px above the tile's bottom edge; the
   path's lower 1.5/24 of the box sits below the drawn heart, hence 6.9px),
   and 6px from the tile's left edge (the path starts 1/12 of the box in,
   hence 4.6px), mirroring the timecode's 6px from the right. */
.tile-favorite {
    width: 16.6px;
    height: 16.6px;
    left: 4.6px;
    bottom: 6.9px;
}

/* --- details panel: rising to meet the media ------------------------------
   The media is centered in a full-screen-tall pane, so anything shorter
   than the screen leaves an empty band between its bottom edge and this
   panel. layoutInfoReveal (photos.js) measures that band per photo and
   publishes it as --info-shift; this pulls the panel up by exactly that
   much once the scroll has engaged, putting the card 12px below the
   media. Both then move at the plain scroll rate, so the 12px holds all
   the way up -- see layoutInfoReveal's comment for the whole mechanism,
   and MAX_MEDIA_TRAVEL_PX there for how far the media is allowed to go.

   The translate is driven by .info-revealed -- a class photos.js toggles
   once, at scrollTop > 24 -- and transitioned here, NOT recomputed per
   scroll event. That is the entire point: the previous version set this
   offset from a scroll listener on every event, and on iOS those arrive
   late and in bursts while the content scrolls on its own frame-perfect
   path, so the media lagged the finger and snapped back. Same jiggle the
   grid's sticky title was rebuilt to escape. A class landing a frame late
   just starts this transition a frame late, which is invisible.

   `translate`, not `transform`: drag-to-dismiss and flip() both use
   `transform` on elements in here, and keeping to the separate property
   means nothing has to compose by hand. --info-shift is unset for media
   tall enough to need no gap closed, where calc() falls back to 0px and
   this rule is inert. */
.viewer.info-revealed .photo-info {
    translate: 0 calc(-1 * var(--info-shift, 0px));
}

.photo-info {
    transition: translate 0.26s cubic-bezier(0.22, 1, 0.36, 1); /* their photo-transition.ts easing; 0.26s rather than its 180ms since this travels a few hundred px, not a slide-width */
}

/* --- details panel: minimal side padding ----------------------------------
   Desktop's .photo-info carries a 16% gutter each side (a centered
   column inside a wide content pane) -- on a 430px phone that's ~69px
   of empty space either side of the card. Here the sides match the
   panel's own 0.75rem (12px) top gap below the media, so the card sits
   the same small distance from the screen edges as from the photo, plus
   the notch inset when the phone is on its side. Top and bottom padding
   are unchanged (photos-style.css). */
.photo-info {
    padding-left: calc(0.75rem + var(--safe-left));
    padding-right: calc(0.75rem + var(--safe-right));
    /* Bottom trimmed from desktop's 4rem, and NOT for looks -- it is never
       seen. The card is translated up off this padding (see the reveal
       section above), so all 4rem of it is blank space at the end of the
       scroll. It also sets the floor on MAX_MEDIA_TRAVEL_PX in photos.js:
       scroll distance is this panel's height, so every px of bottom
       padding is a px of media travel that the dial there cannot take
       back. 0.75rem matches the sides and the gap above the card; the
       safe-area inset stays, so nothing lands under the home indicator. */
    padding-bottom: calc(0.75rem + var(--safe-bottom));
}

/* --- details panel: smaller type, same proportions -------------------------
   Every text size in the details panel scaled down by ONE factor, so the
   three tiers keep one set of ratios (caption 1rem : project title
   0.9375rem : everything else 0.8125rem). Those base sizes are literal
   copies, not read from photos-style.css -- desktop's panel text has its
   own --info-size-* knobs there, so retuning desktop never moves the
   phone. --photo-info-type-scale is the single knob -- change it here
   and all seven sizes follow. At 0.875 that's 14px / 13.1px / 11.4px. */
:root {
    --photo-info-type-scale: 0.875;
}

.photo-info-caption {
    font-size: calc(1rem * var(--photo-info-type-scale));
}

.photo-info-tool-main {
    font-size: calc(0.9375rem * var(--photo-info-type-scale));
}

.photo-info-tool-credit,
.photo-info-tool-platform,
.photo-info-date,
.photo-info-filename,
.photo-info-tool-tags {
    font-size: calc(0.8125rem * var(--photo-info-type-scale));
}

/* --- Pinned Collections ------------------------------------------------------
   iOS Photos' row of collection cards, at the top of the home page above
   the "Library" title and grid (renderPinnedCollections in photos.js
   builds it; a card filters exactly like its sidebar button, and the
   sticky "Library" title sits in flow right below it -- see the sticky
   title section).
   Light-theme take on the reference: a bold title with a grey chevron,
   then square rounded thumbnails with the name centered underneath.
   Home page only -- inside a collection (.main.is-filtered) it's hidden
   and the header's back button takes its place.

   Three cards across with a sliver of the fourth peeking in from the
   right edge, so it reads as "swipe for more": each card is a third of
   the row's width after the left inset, the two gaps between the three,
   and ~1.25rem left over for the peek. Scroll-snaps card by card, with
   the inset kept on the left as it snaps (scroll-padding). pan-x pan-y
   so a vertical drag on the row still scrolls the page past it. */
.pinned-collections:not([hidden]) {
    display: block;
    /* Top of the page now, so it clears the status bar/notch itself;
       the small bottom gap is the space before the "Library" title. */
    padding: calc(var(--safe-top) + var(--mobile-title-top) + 0.25rem) 0 0.5rem;
}

.main.is-filtered .pinned-collections {
    display: none;
}

.pinned-collections-title {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    margin: 0 0 0.875rem;
    padding: 0 calc(1rem + var(--safe-right)) 0 calc(1rem + var(--safe-left));
    font-size: var(--mobile-heading-size); /* same as the "Library" title below it */
    line-height: var(--mobile-heading-line);
    font-weight: 700;
    letter-spacing: -0.01em;
    color: #1c1c1e; /* UIColor.label, same as the floating title's resting state */
}

.pinned-collections-title svg {
    width: 1.125rem;
    height: 1.125rem;
    color: rgba(60, 60, 67, 0.3); /* UIColor.tertiaryLabel -- the reference's grey chevron */
}

.pinned-collections-row {
    --pinned-inset: calc(1rem + var(--safe-left));
    --pinned-gap: 0.625rem;
    display: flex;
    gap: var(--pinned-gap);
    overflow-x: auto;
    overflow-y: hidden;
    padding: 0 calc(1rem + var(--safe-right)) 0 var(--pinned-inset);
    scroll-snap-type: x mandatory;
    scroll-padding-left: var(--pinned-inset);
    overscroll-behavior-x: contain;
    touch-action: pan-x pan-y;
    scrollbar-width: none;
}

.pinned-collections-row::-webkit-scrollbar {
    display: none;
}

.pinned-card {
    flex: 0 0 calc((100% - 2 * var(--pinned-gap)) / 3 - 0.25rem); /* three across the row's width, minus a little so the fourth peeks in (~18px on a 430px phone) */
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.5rem;
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    color: #1c1c1e;
    scroll-snap-align: start;
    -webkit-tap-highlight-color: transparent;
}

.pinned-card-thumb {
    position: relative;
    display: block;
    width: 100%;
    aspect-ratio: 1 / 1;
    border-radius: 0.625rem; /* 10px */
    overflow: hidden;
    background: #d1d1d6; /* UIColor.systemGray4, until (or instead of) the image */
}

.pinned-card-thumb img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.pinned-card-label {
    font-size: 0.9375rem;
    line-height: 1.25rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 100%;
}

.pinned-card:active .pinned-card-thumb {
    opacity: 0.7;
}

/* --- back to Library ----------------------------------------------------------
   Inside a collection (.main.is-filtered -- see syncPinnedCollections in
   photos.js) the floating header gets iOS's round back button above the
   collection's title, as in the Favorites reference; it clicks the
   sidebar's Library button. A frosted grey circle with a dark chevron
   over the white top of the page, switching to a darker glass with a
   white chevron once photos are scrolled under it (.is-scrolled), the
   same switch the title's own colours follow.

   The title block is pointer-events:none (once pinned it covers the top
   row of tiles), so the button opts back in. It sits in the sticky
   title's flow, so the grid below simply starts after its row. */
.main.is-filtered .collection-back {
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    width: 1.65rem; /* 26.4px */
    height: 1.65rem;
    margin: 0 0 0.5rem 0;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: rgba(120, 120, 128, 0.16); /* UIColor.tertiarySystemFill */
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
    color: #1c1c1e;
    pointer-events: auto;
    -webkit-tap-highlight-color: transparent;
    transition: background-color 0.2s ease, color 0.2s ease;
}

/* 14.4px in the 26.4px circle: both are the same 20% up from the
   earlier 12px-in-22px, and the difference still splits into a whole 6px
   each side, so the arrow sits on whole pixels -- an 11px arrow in the
   original 22px circle left 5.5px, smeared it across two device pixels,
   and read as soft and slightly off. There's also an optical nudge,
   built into the path itself in index.html (drawn from x=14, not 15, in
   its 24-unit viewBox: ~0.6px left at this size) rather than done with a
   transform here, so the box stays on whole pixels: a "<" carries its
   visual weight on its open right side and reads as sitting right of
   center unless it's eased back a touch -- same kind of per-icon
   correction as the traffic lights' in photos-style.css. */
.main.is-filtered .collection-back svg {
    width: 0.9rem; /* 14.4px */
    height: 0.9rem;
}

/* Drawn at 26.4px, but still a 44px target (Apple's minimum): an
   invisible layer reaching 8.8px past every edge takes the taps. */
.main.is-filtered .collection-back::before {
    content: '';
    position: absolute;
    inset: -0.55rem;
}

.main.is-filtered .collection-back:active {
    opacity: 0.6;
}

.content-header-title.is-scrolled .collection-back {
    background: rgba(0, 0, 0, 0.3);
    color: #ffffff;
}

