* { box-sizing: border-box; }
html, body {
  margin: 0; padding: 0; width: 100%; height: 100%;
  background: #000; color: #eee; font-family: system-ui, sans-serif;
  user-select: none;
}
/* Locked/fixed layout is specific to the swipe feed (index.html sets <html class="feed">) —
   grid.html loads this same stylesheet but needs the page to actually scroll, so this must not
   be a blanket html,body rule. Scoping it here, at the source, means grid.css never has to fight
   it: per the CSS overflow-propagation spec, body's own overflow only becomes the page's actual
   scrolling behavior when html's computed overflow is the default `visible` — leaving html
   untouched on grid.html (rather than overriding it back) is what makes that propagation work. */
html.feed, html.feed body {
  overflow: hidden; touch-action: none;
}
#stage {
  position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
  background: #000;
}
/* A native scroll-snap feed, not a fixed 3-slide track — the browser's own scroll physics
   drives vertical navigation (momentum, snapping, all of it), and only a small bounded window
   of slides (current ± a couple) ever exists in the DOM at once regardless of catalogue size.
   touch-action:pan-y hands vertical gestures to the browser entirely; JS only ever intercepts
   horizontal ones (fav/hide), which act on the current slide alone. */
#feed {
  position: absolute; inset: 0;
  overflow-y: auto; overflow-x: hidden;
  /* One-to-one is the "exactly one media on screen" destination, so the browser's own snapping
     is what enforces that — mandatory, not proximity, and not a JS correction after the fact.
     A JS settle (ease onto the nearest slide once scrolling stops) was the previous approach and
     is what produced the "double scroll": the fling came to rest straddling two slides, then a
     second, separate animation dragged the view forward or back onto one. Native mandatory snap
     folds that into the fling itself — one gesture, one motion, always ending on a whole slide.
     scroll-snap-stop:always additionally forbids flying past a slide inside one fling, which is
     what made a fast swipe silently skip an item. */
  scroll-snap-type: y mandatory;
  /* Scroll anchoring compensates for content inserted/removed above the view on its own, which
     double-counts against the explicit compensation materializeBehind/pruneBehindFar do (see
     keepingScrollAt in app.js) — the result was a full-slide jump. This window is virtualized,
     so the compensation has to be ours alone. */
  overflow-anchor: none;
  touch-action: pan-y;
  scrollbar-width: none;
}
#feed::-webkit-scrollbar { display: none; }
.slide {
  position: relative; width: 100%; height: 100%;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: flex; align-items: center; justify-content: center;
  background: #000; overflow: hidden;
}
/* Wraps one slide's media so a horizontal swipe can drag it without moving the vertical scroll
   position or the fixed overlays. Transform and opacity only, so dragging stays on the
   compositor rather than forcing layout each frame. */
.slideMedia {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  will-change: transform, opacity;
}
.slideMedia img, .slideMedia video {
  width: 100%; height: 100%; object-fit: contain;
  /* A horizontal drag on an <img> is a native image-drag gesture on desktop, which swallows the
     pointer stream mid-swipe (the fav/hide drag just stops) and auto-scrolls the page near its
     edges. Nothing here is meant to be dragged out of the page. */
  -webkit-user-drag: none; user-drag: none;
  /* Suppresses iOS's own long-press menu (save image / copy) on the media, the same reason
     grid.css does it for cells — a finger lingering mid-tap should never hand the gesture to
     the OS instead of this page's own tap handling. */
  -webkit-touch-callout: none;
}
.slideMedia img.fit-cover, .slideMedia video.fit-cover { object-fit: cover; }
/* Set per item by autoFill() in app.js when the media is close enough to the screen's shape
   that filling it crops almost nothing. Same declaration as an explicit fill; the difference is
   entirely in who decides and on what evidence. */
.slideMedia img.fit-auto, .slideMedia video.fit-auto { object-fit: cover; }

/* Live zoom factor while pinching, so the gesture has a readout and "am I back to normal?" is
   answerable at a glance. Shares the video badge's corner and weight; only one of the two is
   ever on screen at a time, since a zoomed video is not a thing this does. */
#zoomBadge {
  position: absolute; z-index: 12;
  top: calc(8px + env(safe-area-inset-top)); left: 8px;
  padding: 3px 8px; border-radius: 11px;
  background: rgba(0,0,0,0.6); color: #fff;
  font-size: 11px; line-height: 1.4; font-variant-numeric: tabular-nums;
  pointer-events: none;
}
#zoomBadge[hidden] { display: none; }
/* Top-CENTRE rather than either corner — both are already spoken for (zoomBadge/.vidBadge
   share top-left, #soundBtn owns top-right), and this is a different kind of thing anyway: not
   a per-item mark, a standing fact about what the whole wall is currently scoped to. */
#scopeBadge {
  position: absolute; z-index: 12; left: 50%; transform: translateX(-50%);
  top: calc(8px + env(safe-area-inset-top));
  padding: 5px 12px; border-radius: 12px;
  background: rgba(0,0,0,0.65); color: #fff; text-decoration: none;
  font-size: 11px; line-height: 1.4; white-space: nowrap;
  border: 1px solid rgba(255,255,255,0.2);
}
#scopeBadge[hidden] { display: none; }
#scopeBadge:active { background: rgba(255,255,255,0.15); }

/* Tiny corner marker saying "this one is a video". Placed top-left, clear of the pause badge
   on the right and of the media itself. */
.vidBadge {
  position: absolute; z-index: 2;
  top: calc(8px + env(safe-area-inset-top)); left: 8px;
  min-width: 22px; height: 22px; padding: 0 6px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 11px; background: rgba(0,0,0,0.5);
  color: #fff; font-size: 11px; line-height: 1;
  pointer-events: none; opacity: 0.45;
  transition: opacity 200ms ease-out;
}
/* Still buffering: brighter and breathing, so a blank screen reads as "loading, wait" rather
   than "broken". Goes quiet the moment frames actually start. */
.vidBadge.waiting { opacity: 0.95; animation: vidWait 1.1s ease-in-out infinite; }
/* The browser is refusing to start without a gesture, which is a different thing from still
   loading — pulsing at someone who is waiting for a download that isn't happening is worse
   than useless, so this state says what it actually wants. */
.vidBadge.blocked { opacity: 1; animation: none; background: rgba(255,255,255,0.9); color: #000; font-weight: 700; }
@keyframes vidWait { 0%, 100% { opacity: 0.95; } 50% { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) { .vidBadge.waiting { animation: none; } }

#hint {
  position: absolute; color: #ccc; font-size: 15px; line-height: 1.9;
  text-align: center; pointer-events: none;
  padding: 14px 18px; border-radius: 10px; background: rgba(0,0,0,0.55);
}
#hint b { font-weight: 700; color: #fff; }

/* swipe feedback: the side zones flash ★ / ✕ when a horizontal swipe acts on an item */
.tapzone {
  position: absolute; top: 0; bottom: 0; width: 30%;
  display: flex; align-items: center; pointer-events: none;
  background: rgba(255,255,255,0); transition: background 150ms ease-out;
}
.tapzone span {
  color: #fff; font-size: 40px; opacity: 0; transform: scale(0.7);
  transition: opacity 150ms ease-out, transform 150ms ease-out;
}
#zoneLeft { left: 0; justify-content: flex-start; padding-left: 14px; }
#zoneRight { right: 0; justify-content: flex-end; padding-right: 14px; }
.tapzone.flash { background: rgba(255,255,255,0.08); }
.tapzone.flash span { opacity: 0.85; transform: scale(1); }

/* one-time discoverability hint for the invisible swipe gestures */
.tapzone.hintpulse span { opacity: 0.35; transform: scale(1); transition: opacity 900ms ease-in-out; }

#favBurst {
  position: absolute; top: 50%; left: 50%; color: #fff; font-size: 72px; pointer-events: none;
  opacity: 0; transform: translate(-50%, -50%) scale(0.6);
}
#favBurst.burst {
  animation: favburst 500ms ease-out;
}
@keyframes favburst {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }
  35% { opacity: 1; transform: translate(-50%, -50%) scale(1.15); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(1.3); }
}

/* small corner status badge, not a centered modal-like overlay — paused just means
   "autoplay is off, navigate at your own pace," it shouldn't block the view */
#pauseOverlay {
  /* Sits left of the sound control, which owns the corner itself. */
  position: absolute; top: calc(13px + env(safe-area-inset-top)); right: 54px;
  color: #fff; font-size: 14px; pointer-events: none;
  opacity: 0; transform: scale(0.8);
  transition: opacity 150ms ease-out, transform 150ms ease-out;
  background: rgba(0,0,0,0.5); width: 28px; height: 28px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
}
#pauseOverlay.show { opacity: 0.9; transform: scale(1); }

#authOverlay {
  position: fixed; inset: 0; z-index: 30;
  background: rgba(0,0,0,0.85);
  display: flex; align-items: center; justify-content: center;
  touch-action: auto;
}
/* an ID selector beats the UA stylesheet's [hidden] rule on specificity alone, so
   display:flex above would win even while hidden without this override */
#authOverlay[hidden] { display: none; }
#authBox {
  width: 280px; max-width: 85vw; padding: 20px;
  border: 1px solid #444; border-radius: 10px; background: #111;
  text-align: center; font-size: 13px;
}
#authBox p { margin: 0 0 12px; }
#authBox input {
  width: 100%; padding: 8px; margin-bottom: 8px; box-sizing: border-box;
  background: #000; color: #eee; border: 1px solid #444; border-radius: 6px; font-size: 14px;
}
#authBox button {
  width: 100%; padding: 8px; background: #eee; color: #000; border: none;
  border-radius: 6px; font-weight: 600; cursor: pointer;
}
#authBox button:active { background: #ccc; }
#authError { color: #f66; min-height: 14px; font-size: 12px; }

/* ---- bottom bar: one row, fixed height, never wraps. The old bar wrapped to two and
   sometimes three rows on a phone and ate a real slice of the media; this is a fixed 54px
   whatever the width, and everything that used to overflow lives in the sheet below. */
#ctrl {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 10;
  padding-bottom: env(safe-area-inset-bottom);
  background: linear-gradient(to top, rgba(0,0,0,0.82), rgba(0,0,0,0.45));
  color: #ccc;
  touch-action: auto; overflow: hidden;
}
/* The row scrolls sideways: the first screen is six slots (eight with a mouse, which also gets
   fav/hide here), and more quick controls wait past the right edge — swipe the bar left. */
#ctrlBar {
  --slot: calc(100% / 6);
  display: flex; align-items: stretch;
  height: 54px;
  /* Not a native scroller: app.js pages it with a transform (see initBarExtras for why), so
     the browser must never claim a touch here as a pan. #ctrl clips what's past the edge. */
  touch-action: none; will-change: transform;
}
@media (hover: hover) and (pointer: fine) { #ctrlBar { --slot: calc(100% / 8); } }
/* The edge fade is drawn over the bar by #ctrl, never as a mask on the scroller itself: a masked
   scroll container is exactly what iOS WebKit stops delivering taps to (every bar button went
   dead on an iPhone). pointer-events:none keeps the fade itself from ever eating a tap. */
#ctrl::before, #ctrl::after {
  content: ''; position: absolute; top: 0; height: 54px; width: 24px; z-index: 1;
  pointer-events: none; opacity: 0; transition: opacity 0.2s;
}
#ctrl::before { left: 0; background: linear-gradient(to right, rgba(0,0,0,0.85), transparent); }
#ctrl::after { right: 0; background: linear-gradient(to left, rgba(0,0,0,0.85), transparent); }
#ctrl.moreLeft::before, #ctrl.moreRight::after { opacity: 1; }
/* Fixed shares of the bar's width rather than intrinsic widths, so the first screen is balanced
   at any size and every target is at least a finger wide on a narrow phone. */
.ctrlBtn {
  flex: 0 0 var(--slot); min-width: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
  background: none; border: none; color: #eee; text-decoration: none;
  font: inherit; cursor: pointer; padding: 0;
  -webkit-tap-highlight-color: transparent;
}
.ctrlBtn[hidden] { display: none; }
.ctrlBtn:active { background: rgba(255,255,255,0.14); }
.ctrlBtn .ic { font-size: 19px; line-height: 1; }
.ctrlBtn .lb {
  font-size: 10px; letter-spacing: 0.02em; color: #bbb;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.ctrlBtn.active .ic, .ctrlBtn.active .lb { color: #fff; }
.ctrlBtn.active { background: rgba(255,255,255,0.16); }
/* Views sit apart from the ordering controls to their left. */
#gridBtn { box-shadow: inset 1px 0 0 rgba(255,255,255,0.12); }
/* Touch layouts have fav/hide as floating buttons on the left (.sideFab), so the bar drops them
   — the exact inverse of the rule that hides .sideFab for a mouse. */
@media not all and (hover: hover) and (pointer: fine) {
  #ctrlBar #favBtn, #ctrlBar #hideBtn { display: none; }
}

/* ---- the sheet: everything you set once and stop thinking about. Off-screen entirely when
   closed, so it costs nothing while you're just looking at media. */
/* Floating sound control. Lives on the media, top-right, and only exists while a video does —
   it is about the clip in front of you, not a permanent app setting, and the bar below has no
   room to spare for a button that is meaningless on most slides. */
/* Drawn rather than set as an emoji: an emoji speaker renders as a full-colour glyph at a size
   the font decides, which is neither small nor quiet. This is a 14px line drawing that reads as
   part of the interface. The tap target stays finger-sized around it via padding, so making the
   mark smaller doesn't make it harder to hit. */
#soundBtn {
  position: absolute; z-index: 12;
  top: calc(4px + env(safe-area-inset-top)); right: 4px;
  width: 34px; height: 34px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  border: none; background: none; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  opacity: 0.55; transition: opacity 150ms ease-out;
}
#soundBtn[hidden] { display: none; }
#soundBtn svg {
  width: 15px; height: 15px;
  fill: none; stroke: #fff; stroke-width: 1.4;
  stroke-linecap: round; stroke-linejoin: round;
  /* Legible over a bright frame without needing a plate behind it. */
  filter: drop-shadow(0 1px 2px rgba(0,0,0,0.9));
}
#soundBtn svg path:first-child { fill: #fff; stroke-width: 1; }
#soundBtn:active { opacity: 1; }
/* Muted is the resting state, so the slash is what's shown by default and the waves replace it
   once sound is actually on. */
#soundBtn #soundWaves { display: none; }
#soundBtn.active { opacity: 0.95; }
#soundBtn.active #soundWaves { display: inline; }
#soundBtn.active #soundSlash { display: none; }

/* A dedicated touch target, not a share of #ctrlBar's fixed six-way row (see that rule's own
   comment) — this is a frequent action, not an occasional one like sound, so it gets a bigger
   target and more presence than #soundBtn's quiet corner mark. WCAG 2.5.5's 44px minimum,
   positioned above the control bar where a thumb already rests, clear of the scrubber (which
   sits in the same corner only for videos — see #vidScrub's own --ctrl-h offset). */
#similarFab {
  position: absolute; z-index: 12;
  right: 10px; bottom: calc(var(--ctrl-h, 58px) + 58px);
  width: 46px; height: 46px; padding: 0; border-radius: 23px;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid rgba(255,255,255,0.25); background: rgba(20,20,24,0.72);
  color: #fff; font-size: 19px; line-height: 1; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  box-shadow: 0 2px 10px rgba(0,0,0,0.4);
}
#similarFab[hidden] { display: none; }
#similarFab:active { background: rgba(255,255,255,0.22); }

/* Search's own floating target, stacked above similarFab — always visible (name search needs
   no map), so unlike similarFab it carries no [hidden] default. */
#searchFab {
  position: absolute; z-index: 12;
  right: 10px; bottom: calc(var(--ctrl-h, 58px) + 112px);
  width: 46px; height: 46px; padding: 0; border-radius: 23px;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid rgba(255,255,255,0.25); background: rgba(20,20,24,0.72);
  color: #fff; font-size: 19px; line-height: 1; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  box-shadow: 0 2px 10px rgba(0,0,0,0.4);
}
#searchFab:active { background: rgba(255,255,255,0.22); }

/* Stacked one more slot below searchFab (+68 there, +122 here — same 54px spacing). */
#favsFab {
  position: absolute; z-index: 12;
  right: 10px; bottom: calc(var(--ctrl-h, 58px) + 166px);
  width: 46px; height: 46px; padding: 0; border-radius: 23px;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid rgba(255,255,255,0.25); background: rgba(20,20,24,0.72);
  color: #fff; font-size: 19px; line-height: 1; text-decoration: none;
  -webkit-tap-highlight-color: transparent;
  box-shadow: 0 2px 10px rgba(0,0,0,0.4);
}
#favsFab[hidden] { display: none; }
#favsFab:active { background: rgba(255,255,255,0.22); }

/* Drift (d): touch only — a mouse has the key and the bar. It takes the bottom slot of the
   right-hand stack, so on touch everything above it moves up one 54px step. */
#driftFab {
  position: absolute; z-index: 12;
  right: 10px; bottom: calc(var(--ctrl-h, 58px) + 58px);
  width: 46px; height: 46px; padding: 0; border-radius: 23px;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid rgba(255,255,255,0.25); background: rgba(20,20,24,0.72);
  color: #fff; font-size: 19px; line-height: 1; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  box-shadow: 0 2px 10px rgba(0,0,0,0.4);
}
#driftFab[hidden] { display: none; }
#driftFab:active { background: rgba(255,255,255,0.22); }
#driftFab.active { color: #000; background: rgba(238,238,238,0.9); }
@media (hover: hover) and (pointer: fine) { #driftFab { display: none; } }
@media not all and (hover: hover) and (pointer: fine) {
  body[data-map="1"] #similarFab { bottom: calc(var(--ctrl-h, 58px) + 112px); }
  body[data-map="1"] #searchFab { bottom: calc(var(--ctrl-h, 58px) + 166px); }
  body[data-map="1"] #favsFab { bottom: calc(var(--ctrl-h, 58px) + 220px); }
}
/* No search button (a public gallery without a search daemon strips it): favs drops into its slot. */
body.no-search-fab #favsFab { bottom: calc(var(--ctrl-h, 58px) + 112px); }
@media not all and (hover: hover) and (pointer: fine) {
  body.no-search-fab[data-map="1"] #favsFab { bottom: calc(var(--ctrl-h, 58px) + 166px); }
}

/* Left-hand counterparts of the right-hand stack above: same size and treatment, mirrored, so the
   two thumbs each have their own quick actions while swiping. */
.sideFab {
  position: absolute; z-index: 12; left: 10px;
  width: 46px; height: 46px; padding: 0; border-radius: 23px;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid rgba(255,255,255,0.25); background: rgba(20,20,24,0.72);
  color: #fff; font-size: 19px; line-height: 1; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  box-shadow: 0 2px 10px rgba(0,0,0,0.4);
}
/* Every floating button starts above the video scrubber (a ~40px strip just over the bar,
   see #vidScrub) rather than stepping up only while one shows — that needed :has(), which
   not every phone browser has, and a button that moves under the thumb is worse anyway. */
/* Discard at the bottom, favourite above it — with a wider gap than the right-hand stack uses,
   so a reach for one doesn't land on the other. */
#hideFab { bottom: calc(var(--ctrl-h, 58px) + 58px); }
#favFab { bottom: calc(var(--ctrl-h, 58px) + 128px); }
.sideFab:active { background: rgba(255,255,255,0.22); }
#favFab.active { color: #ffd54a; border-color: rgba(255,213,74,0.6); }
/* A mouse has the keyboard and the bar; these are for fingers. */
@media (hover: hover) and (pointer: fine) { .sideFab { display: none; } }

/* The overlay itself — same centred-dialog-over-a-backdrop pattern as #authOverlay, so it reads
   as the same kind of thing: a focused question, answer it or back out. */
#searchOverlay {
  position: fixed; inset: 0; z-index: 30;
  background: rgba(0,0,0,0.75);
  display: flex; align-items: flex-start; justify-content: center;
  padding-top: calc(env(safe-area-inset-top, 0px) + 60px);
  touch-action: auto;
}
#searchOverlay[hidden] { display: none; }
#searchBox {
  position: relative;
  width: 320px; max-width: 88vw; padding: 16px;
  border: 1px solid #444; border-radius: 12px; background: #111;
}
#searchBoxRow { display: flex; gap: 8px; }
#searchBox input {
  flex: 1; min-width: 0; padding: 10px 12px; border-radius: 8px;
  border: 1px solid #444; background: #1a1a1a; color: #eee; font-size: 16px;
}
#searchBox input:focus { outline: none; border-color: #888; }
#searchBox button#searchOverlayGo {
  flex: none; padding: 0 16px; border-radius: 8px; border: none;
  background: #eee; color: #000; font-weight: 600; cursor: pointer;
}
#searchBox .seg { margin-top: 10px; }
#searchOverlayClose {
  position: absolute; top: -14px; right: -14px;
  width: 30px; height: 30px; border-radius: 15px;
  border: 1px solid #444; background: #222; color: #eee;
  font-size: 14px; cursor: pointer;
}

#sheet {
  position: fixed; inset: 0; z-index: 20;
  background: rgba(0,0,0,0.55);
  display: flex; align-items: flex-end;
  touch-action: auto;
}
#sheet[hidden] { display: none; }
#sheetPanel {
  position: relative;
  width: 100%; max-height: 82vh; overflow-y: auto;
  background: #111; border-top: 1px solid #333;
  border-radius: 14px 14px 0 0;
  /* Top padding clears the close button, which is absolutely positioned and would otherwise
     sit on top of the first row's right-hand controls. */
  padding: 48px 14px calc(16px + env(safe-area-inset-bottom));
  animation: sheetUp 180ms ease-out;
}
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
#sheetClose {
  position: absolute; top: 9px; right: 10px;
  width: 34px; height: 34px; border-radius: 50%;
  background: rgba(255,255,255,0.08); color: #eee; border: none;
  font-size: 14px; cursor: pointer;
}
.sheetRow {
  display: flex; align-items: flex-start; gap: 10px;
  padding: 9px 0; border-bottom: 1px solid #222;
}
.sheetRow:last-child { border-bottom: none; }
.sheetLabel {
  flex: 0 0 42px; font-size: 11px; color: #888; text-transform: uppercase;
  letter-spacing: 0.06em; padding-top: 11px;
}
.sheetFields {
  flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
  font-size: 12px;
}
/* Segmented control: one visible group of mutually exclusive choices, so the current filter is
   readable at a glance instead of being a state you have to remember having set. */
.seg {
  flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: 4px;
}
.seg > * {
  flex: 1 1 auto; min-width: 54px; min-height: 40px;
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,0.06); color: #ddd; border: 1px solid #333;
  border-radius: 8px; font-size: 13px; text-decoration: none; cursor: pointer;
  padding: 0 8px; white-space: nowrap;
}
.seg > *:active { background: rgba(255,255,255,0.16); }
.seg > .on { background: #eee; color: #000; border-color: #eee; font-weight: 700; }

/* shared pill-button look — reused by grid.css for the grid page's nav/pager buttons */
.btn {
  font-size: 13px; padding: 6px 10px; min-height: 40px;
  border: 1px solid #444; border-radius: 8px;
  display: inline-flex; align-items: center; justify-content: center;
  text-decoration: none; white-space: nowrap; flex: none;
  background: none; color: #eee; cursor: pointer;
}
/* Same fix as .ctrlBtn[hidden] just above, for the exact same reason: this class's own
   `display: inline-flex` shares the browser's `[hidden]{display:none}` rule's specificity (one
   class vs. one attribute selector), and author CSS beats the UA stylesheet on a tie — so every
   hidden `.btn` (mapFit, saveCollectionBtn, selSimilar, emptyTrashBtn...) was rendering anyway.
   This is what made the grid bar look "squeezed": buttons that should never have been on
   screen outside a map/selection/search context were sitting in the row permanently. */
.btn[hidden] { display: none; }
.btn:active { background: rgba(255,255,255,0.12); }
/* A pill that is a toggle, not an action — random and fit both live in the sheet as modes. */
.btn.active { background: #eee; color: #000; border-color: #eee; font-weight: 700; }
/* The shortcuts are only worth having if they can be found, and a sheet the viewer already
   opens for everything else is a cheaper place to say so than a help overlay of its own. */
#keyHelp { gap: 6px 10px; color: #999; font-size: 11px; }
#keyHelp span { white-space: nowrap; }
#keyHelp b {
  display: inline-block; min-width: 15px; text-align: center;
  padding: 1px 4px; margin-right: 3px;
  border: 1px solid #444; border-radius: 4px;
  background: #000; color: #eee; font-weight: 600; font-size: 11px;
}
#sheetPanel label { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
#sheetPanel input[type="checkbox"] { width: 15px; height: 15px; }
#sheetPanel input[type="number"] {
  width: 48px; min-height: 34px; background: #000; color: #eee;
  border: 1px solid #444; border-radius: 6px; font-size: 13px; padding: 2px 4px;
}
#rescanBtn {
  background: none; color: #eee; border: none; padding: 2px 0;
  font-size: 12px; text-decoration: underline; cursor: pointer;
}
.muted { color: #888; }

/* Long-video position scrubber. Sits above #ctrl rather than inside it: it belongs to the media
   on screen, appears and disappears with it, and must stay reachable with a thumb. touch-action
   is none on purpose — html.feed sets touch-action:none globally, and the range still needs the
   browser to route the whole drag to the control rather than treating it as a page gesture. */
#vidScrub {
  position: absolute; left: 0; right: 0;
  /* #ctrl wraps to two rows on a narrow phone, so its height is not a constant to hard-code
     against — app.js measures it into --ctrl-h and keeps that current. The fallback only ever
     applies for the instant before the first measurement. #ctrl carries its own safe-area
     padding, so that's already inside the measured height. */
  bottom: calc(var(--ctrl-h, 58px) + 6px);
  z-index: 12;
  display: flex; align-items: center; gap: 10px;
  padding: 8px 14px;
  background: linear-gradient(to top, rgba(0,0,0,0.65), rgba(0,0,0,0));
  touch-action: none;
}
#vidScrub[hidden] { display: none; }
#vidScrubTime {
  flex: none;
  font-size: 12px; color: #ddd; font-variant-numeric: tabular-nums;
  white-space: nowrap; text-shadow: 0 1px 2px rgba(0,0,0,0.9);
}
#vidScrubRange {
  flex: 1 1 auto; min-width: 0; margin: 0;
  -webkit-appearance: none; appearance: none; background: none;
  /* A wider hit area than the 3px track it draws — the bar is thin by design, the target isn't. */
  height: 28px; touch-action: none; cursor: pointer;
}
#vidScrubRange::-webkit-slider-runnable-track {
  height: 3px; border-radius: 2px; background: rgba(255,255,255,0.35);
}
#vidScrubRange::-moz-range-track {
  height: 3px; border-radius: 2px; background: rgba(255,255,255,0.35);
}
#vidScrubRange::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 14px; height: 14px; margin-top: -5.5px;
  border-radius: 50%; background: #fff; border: none;
  box-shadow: 0 1px 3px rgba(0,0,0,0.8);
}
#vidScrubRange::-moz-range-thumb {
  width: 14px; height: 14px;
  border-radius: 50%; background: #fff; border: none;
  box-shadow: 0 1px 3px rgba(0,0,0,0.8);
}

/* ---- keyboard help overlay (built by keyhelp.js, shared by both pages) */
#keyOverlay {
  position: fixed; inset: 0; z-index: 40;
  background: rgba(0,0,0,0.8);
  display: flex; align-items: center; justify-content: center;
  padding: 16px; touch-action: auto;
}
#keyOverlay[hidden] { display: none; }
#keyPanel {
  width: 100%; max-width: 340px; max-height: 84vh; overflow-y: auto;
  background: #111; border: 1px solid #333; border-radius: 12px;
  padding: 16px 18px; color: #ddd; font-size: 13px;
}
#keyPanel h3 {
  margin: 14px 0 8px; font-size: 11px; text-transform: uppercase;
  letter-spacing: 0.07em; color: #888; font-weight: 600;
}
#keyPanel h3:first-child { margin-top: 0; }
/* Two columns that stay aligned down the list: the key on the left at a fixed width, what it
   does on the right. A definition list is what this actually is. */
#keyPanel dl {
  margin: 0; display: grid; grid-template-columns: 74px 1fr; gap: 6px 10px; align-items: center;
}
#keyPanel dt {
  justify-self: start;
  padding: 2px 6px; border: 1px solid #444; border-radius: 5px;
  background: #000; color: #eee; font-size: 12px; font-weight: 600;
  white-space: nowrap;
}
#keyPanel dd { margin: 0; color: #bbb; }
#keyPanel p { margin: 14px 0 0; font-size: 11px; text-align: center; }

/* A video's still, laid over it until the first real frame is on screen (see makeSlide). Same box
   and fit as the video underneath, so the hand-over doesn't shift anything. */
.slideMedia .vidPoster {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: contain; pointer-events: none;
  -webkit-user-drag: none; user-drag: none; -webkit-touch-callout: none;
}
.slideMedia video.fit-cover + .vidPoster, .slideMedia video.fit-auto + .vidPoster { object-fit: cover; }

/* Privacy blur after the page was hidden, until the next swipe (see app.js's visibilitychange).
   Only the media — the controls stay sharp, so it's obvious how to get going again. */
body.veiled .slideMedia { filter: blur(32px) brightness(0.7); }
