/* ─────────────────────────────────────────────────────────────
   Field Frames — a quiet gallery that lets the photographs carry
   the color. Cormorant Garamond display + IBM Plex Mono labels,
   one accent (lichen). Sitewide type runs small; content column
   caps at 1200px while the hero backdrop runs full-bleed.
   ───────────────────────────────────────────────────────────── */

/* ── The palette is a theme ──────────────────────────────────
   Every token is written ONCE, as light-dark(light, dark): the dark
   value is the identity, the light value the same palette turned over.
   Which one applies is decided by `color-scheme` alone — set to light
   by html[data-theme="light"] (written before paint by the inline
   script in index.html, toggled by the switch). The OS preference is
   deliberately NOT consulted: the site is dark by default. Nothing else
   in this file or videos.css knows which theme is on: every colour goes
   through a token, and the translucent ones are relative colours of
   the two grounds (rgb(from var(--ink) r g b / a)), so a veil over a
   photograph is always the current ground at some opacity. Don't write
   a hex or an rgba() outside this block; it will not flip.

   Light is not a pure inversion of the values. Lichen on paper is
   2.1:1, illegible for the small mono it is used on, so the light
   accent is the same lichen darker (#54644a, 5:1 — about what the dark
   accent has on ink); --paper-dim / --paper-mid are likewise set for
   the contrast their dark counterparts have, not mirrored by number.

   Floor: light-dark() and relative colours need Safari 17.5 / Chrome
   123 / Firefox 120. */
:root {
  color-scheme: dark;
  --ink:        light-dark(#e9e5d9, #15140f);
  --ink-soft:   light-dark(#ded9cc, #1d1b15);
  --paper:      light-dark(#15140f, #e9e5d9);
  --paper-soft: light-dark(#2a2820, #d6d1c4);   /* hero subtitle / captions */
  --paper-mid:  light-dark(#4a473e, #c9c4b6);   /* coordinate lines / lightbox readouts */
  --paper-dim:  light-dark(#5c584d, #9c988b);   /* secondary / mono labels */
  --lichen:     light-dark(#54644a, #93a382);   /* lone accent: coastal scrub */
  --hair:       rgb(from var(--paper) r g b / 0.12);
  --backdrop:   light-dark(rgba(240, 237, 229, 0.96), rgba(12, 11, 8, 0.96));   /* the lightboxes */
  /* Type that sits ON A PHOTOGRAPH — the folder-card label, the NEW
     badge, everything on the hero. Their ground is the picture, which
     does not change with the theme, so neither do they: the dark
     palette's values in both themes. */
  --on-photo:      #e9e5d9;
  --on-photo-soft: #d6d1c4;
  --on-photo-mid:  #c9c4b6;
  --on-photo-dim:  #9c988b;
  --on-photo-accent: #93a382;
  --on-photo-veil: rgba(12, 11, 8, 0.58);   /* the changelog's ground on the picture */

  --mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
  --display: "Cormorant Garamond", Georgia, serif;

  /* ── The character cell ──────────────────────────────────────
     The site is laid out on a terminal grid: one cell is one character
     space, every gutter below is a whole number of cells, and the cursor
     moves cell to cell across the same grid. Change these two numbers
     and the whole page re-registers.

     --vu is the viewport unit every layout length is built on: one
     percent of the width OR of 1.6x the height, whichever is smaller,
     so a window taller than 16:10 scales by width and a shorter one —
     a laptop with devtools open, a phone on its side — by height.
     --svh is the small viewport (the same one .strip is sized to).
     motion.js reads the cell off the cursor's own box and strip.js
     measures the content column off a real .panel-inner, so none of
     these need to be bare numbers. */
  --svh: 1vh;
  --vu: min(1vw, calc(var(--svh) * 1.6));
  --cell-w: clamp(6px, calc(var(--vu) * 0.625), 9px);   /* 9px at 1440 wide */
  --cell-h: calc(var(--cell-w) * 2);

  --gap: var(--cell-w);                                   /* one cell between cards */
  --edge: clamp(calc(var(--cell-w) * 2), calc(var(--vu) * 4), calc(var(--cell-w) * 5));
  --max: 1200px;
  /* The content column: the max width, the screen, or — on a short
     viewport — however wide --cols columns of --col-cap come to, so a
     header line and two rows of cards always fit a section. Every
     centred column reads this; keep it the one definition. */
  --col-cap: calc(var(--svh) * 38);
  --content: min(var(--max), 100vw,
                 calc(var(--col-cap) * var(--cols) + var(--gap) * (var(--cols) - 1) + var(--edge) * 2));
  /* The column count. strip.js reads it back out of the computed style,
     so THIS is the single source of truth — never restate it in JS. */
  --cols: 4;
  /* Height of the fixed masthead when there is one; app.js measures the
     real element into it. 0 on the index. */
  --chrome: 0px;
  /* Panel gutter above and below the content — one cell row at a phone,
     two at a desk. */
  --panel-pad: clamp(var(--cell-h), calc(var(--vu) * 4), calc(var(--cell-h) * 2));

  /* The theme switch's glyph, and the room the readouts leave it. */
  --toggle-glyph: 14px;
  --toggle-w: calc(var(--toggle-glyph) + var(--cell-w) * 2);

  /* ── What still differs by theme and is not a colour ──────────
     Restated by the two light selectors below — keep those in step. */
  --grain-page: 0.16;                        /* grain over the whole page */
  --grain: var(--grain-light);               /* white specks, for ink */
  --asset-flip: none;                        /* the SVG assets carry dark fills */
  --block-bg: var(--paper);                  /* the cursor cell */
  --block-blend: normal;
}
/* The small viewport unit where it exists; plain vh (which on a phone
   includes the browser chrome) everywhere else. */
@supports (height: 1svh) { :root { --svh: 1svh; } }

/* ── Light ───────────────────────────────────────────────────
   Two selectors, one set of values: the stored / toggled choice, and
   the OS preference when scripting is off and nobody writes the
   attribute. Grain flips to black specks and drops (black on paper
   reads stronger than white on ink); the wordmark and the sprocket
   strip are images with dark fills, so they are inverted — with a
   hue-rotate, because invert alone turns paper a cold navy; the cursor
   cell is white in `difference` so it reads over the hero and the
   paper alike (the accent over a live control keeps a normal blend —
   lichen in difference is mud). */
html[data-theme="light"] {
  color-scheme: light;
  --grain-page: 0.10;
  --grain: var(--grain-dark);
  --asset-flip: invert(1) hue-rotate(180deg);
  --block-bg: #fff;
  --block-blend: difference;
}

@media (max-width: 1000px) { :root { --cols: 3; } }
@media (max-width: 680px)  { :root { --cols: 2; } }

* { box-sizing: border-box; }
/* Type has its own, gentler scale: every font size is in rem, and the
   root em runs 16px → 14.4px and no further while the layout shrinks to
   under half. The labels are small by design; scaled as hard as the
   boxes they would vanish. */
html {
  -webkit-text-size-adjust: 100%;
  font-size: clamp(0.9rem, calc(0.55rem + var(--vu) * 0.5), 1rem);
}

/* The document does not scroll. #strip is the only scroller on the page. */
html, body { height: 100%; overflow: hidden; }

body {
  margin: 0;
  background: var(--ink);
  color: var(--paper);
  font-family: var(--mono);
  font-size: 13px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* The house label: one line of small uppercase mono. Every readout,
   header line and control on the site is set in it. */
.hero-coord,
.topbar-coord,
.back,
.lb-close,
.lb-share,
.colophon,
.folders-meta,
.folder-meta,
.about-eyebrow,
.videos-meta,
.ff-keys {
  font-size: 0.462rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
/* The section-header lines — FOLDERS / 008 FOLDERS, MOTION PICTURES /
   006 FILMS, ABOUT / FIELD FRAMES, the frame count under a folder's
   title. Same line of type everywhere; the margins differ. */
.folders-meta,
.folder-meta,
.about-eyebrow,
.videos-meta {
  color: var(--paper-dim);
  display: flex;
  flex-wrap: wrap;
  gap: 0.5em 0.9em;
}
.folders-meta,
.videos-meta { margin: 0 0 clamp(10px, calc(var(--vu) * 3), 26px); }
.folder-meta { margin: clamp(12px, calc(var(--vu) * 3), 28px) 0 0; }
.about-eyebrow { margin: 0 0 clamp(12px, calc(var(--svh) * 4), 44px); }
.folders-meta .accent,
.folder-meta .accent,
.about-eyebrow .accent,
.videos-meta .accent { color: var(--lichen); }
.folders-meta .sep,
.folder-meta .sep,
.about-eyebrow .sep,
.videos-meta .sep { color: var(--hair); }

/* ── The theme switch ─────────────────────────────────────────
   Top right, fixed, on both views, and on the SECTION-HEADER LINE:
   --panel-pad down, where "FOLDERS / 008 FOLDERS" prints on every
   index panel (the hero's coordinate readout is nudged onto the same
   line, see .hero-coord). Fixed rather than in either row because the
   hero's row is behind the panels that travel over it. No bar, no box:
   one glyph in the readout's colour, drawn after SF Symbols' sun.max
   and moon (index.html), offering the theme you would switch TO, and
   re-printing like every other readout when its value changes. Its box
   is exactly one line of the header type, so the glyph centres on the
   header's x-height. */
.theme-toggle {
  position: fixed;
  right: max(var(--edge), calc((100vw - var(--max)) / 2 + var(--edge)));
  top: var(--panel-pad);
  z-index: 20;                 /* with .topbar: over the strip, under the lightboxes */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--toggle-glyph);
  height: calc(0.462rem * 1.5);
  margin: 0;
  padding: 0;
  background: none;
  border: 0;
  color: var(--paper-dim);
  cursor: pointer;
  transition: color 0.2s ease;
}
/* A folder has no header on that line (its panels clear the topbar via
   --chrome), so there the switch sits on the topbar's coordinate
   readout: the row's padding plus half of (row height − this line
   box), where the row is the mark at a desk and the back button at a
   phone. */
body:not(.index-mode) .theme-toggle {
  top: calc(clamp(20px, 4vw, 40px)
       + (max(clamp(22px, 2.8vw, 28px), 0.462rem * 1.5 + 16px) - 0.462rem * 1.5) / 2);
}
html.ff-booting .theme-toggle { display: none; }
.theme-toggle svg { display: block; width: var(--toggle-glyph); height: var(--toggle-glyph); flex: none; }
/* Sun on the dark page, moon on the light one; aria-checked is "light
   is on", kept current by app.js. */
.theme-toggle .tt-moon { display: none; }
.theme-toggle[aria-checked="true"] .tt-sun { display: none; }
.theme-toggle[aria-checked="true"] .tt-moon { display: block; }
.theme-toggle:is(:hover, .ff-hover) { color: var(--paper); }
/* Over the hero photograph (app.js holds ff-on-hero while the index sits
   on it) it is on-photo type like the readout beside it — in light the
   page's dim is near-black and vanishes on the scrim. */
html.ff-on-hero .theme-toggle { color: var(--on-photo-mid); }
html.ff-on-hero .theme-toggle:is(:hover, .ff-hover) { color: var(--on-photo); }
.theme-toggle:focus-visible { outline: 2px solid var(--lichen); outline-offset: 4px; }
.hero-coord,
.topbar-coord { margin-right: var(--toggle-w); }
/* Re-print on change (app.js adds .is-printing after a remove-and-reflow
   so a second press restarts it, and removes it at animationend). */
@media (prefers-reduced-motion: no-preference) {
  .theme-toggle.is-printing svg {
    --fb: 3px;
    animation: ff-print 0.9s cubic-bezier(0.5, 0, 0.1, 1) both;
  }
}

/* The flip itself: a short cross-fade on the surfaces that carry the
   ground, for the moment app.js holds the class. Deliberately not `*` —
   that would put a transition on every tile in an 84-frame folder. */
@media (prefers-reduced-motion: no-preference) {
  html.ff-theming body,
  html.ff-theming .panel,
  html.ff-theming .topbar,
  html.ff-theming .hero-scrim,
  html.ff-theming .lightbox,
  html.ff-theming .folder-card,
  html.ff-theming .frame,
  html.ff-theming .ff-block {
    transition: background-color 0.35s ease, color 0.35s ease;
  }
}

.sprockets { filter: var(--asset-flip); }

@keyframes ff-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }

/* ── Image protection (best effort) ─────────────────────────── */
.frame img,
.folder-card img,
.lb-img {
  -webkit-user-select: none;
  user-select: none;
  -webkit-user-drag: none;
  -webkit-touch-callout: none;
  pointer-events: none;
}
.frame,
.folder-card {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}

/* ── Landing hero (full-bleed backdrop, capped inner content) ── */
.panel--hero { flex-direction: column; }
.hero-bg {
  position: absolute;
  inset: -48px;
  background-color: var(--ink-soft);
  /* The soft focus is in the PIXELS where the image pipeline can bake
     it (app.js `setHeroBackdrop` adds .is-baked and the filter goes),
     and a live blur only as the fallback. A resting `filter: blur()` on
     a full-bleed box is the one thing WebKit was re-running in software
     on every frame the hero moved — every pan off it, every frame of
     the bounce, the theme flip. --hero-blur is also the END of
     @keyframes ff-develop, so the develop lands exactly on the resting
     state in both cases. */
  --hero-blur: 8px;
  filter: blur(var(--hero-blur));
  /* The 3D (splat.js writes these; see CLAUDE.md "The hero splat"):
     --hx/--hy are the pointer's offset in px-per-unit (the photo layers
     multiply them below), --tx/--ty the plate's tilt. Everything is
     transform-only — a 3D transform is its own compositing layer — and
     at rest all four are 0, so this IS `scale(1.06)`. The from/to of
     @keyframes ff-develop use the same function list so the develop
     lands exactly here. The type (.hero-content) is a sibling and stays
     flat. */
  --hx: 0px;
  --hy: 0px;
  --tx: 0deg;
  --ty: 0deg;
  transform: perspective(1200px) rotateX(var(--ty)) rotateY(var(--tx)) scale(1.06);
}
.hero-bg.is-baked { --hero-blur: 0px; filter: none; }
/* The radial blur, centre in focus: ::before carries the SHARP rung (in
   every mode — the plain fallback simply has no ::after); ::after is the
   baked-blur rung masked so it is absent at the centre and full toward
   the edges. --hero-focus is the radius of the sharp zone,
   --hero-soft-edge where the blur is complete (both as a share of the
   box). Static: two images and one mask, composited once — nothing runs
   per frame at rest. The splat canvas (z-index 1) paints over both.
   Both layers drift a little with the pointer, the blurred one (the
   "far" plane) twice as far — translate3d only, no repaint. */
.hero-bg::before,
.hero-next::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--hero-sharp, none) center / cover no-repeat;
  transform: translate3d(calc(var(--hx) * 2), calc(var(--hy) * 2), 0);
}
.hero-bg.is-baked::after,
.hero-next.is-baked::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--hero-soft) center / cover no-repeat;
  --hero-focus: 18%;
  --hero-soft-edge: 58%;
  -webkit-mask-image: radial-gradient(ellipse at center, transparent var(--hero-focus), #000 var(--hero-soft-edge));
  mask-image: radial-gradient(ellipse at center, transparent var(--hero-focus), #000 var(--hero-soft-edge));
  transform: translate3d(calc(var(--hx) * 4), calc(var(--hy) * 4), 0);
}
/* The splat leads the arrival (splat.js decides synchronously, feature
   checks only): while .is-waiting the photo layers sit at opacity 0 —
   only the ground shows — and the CSS develop is off for .hero-bg
   (.is-led below; the splat's own develop replaces it). The first drawn
   frame lifts .is-waiting and the photo fades in UNDER the canvas;
   any failure lifts .is-led too and the develop runs as it always did. */
.hero-bg.is-led::before,
.hero-bg.is-led::after { transition: opacity 1.6s ease 0.4s; }
.hero-bg.is-waiting::before,
.hero-bg.is-waiting::after { opacity: 0; transition: none; }
html.ff-motion .hero-bg.is-led { animation: none; }
/* The live change (app.js crossfadeHero — the admin's preview, a write,
   the service worker's refresh): background-image cannot transition, so
   the NEXT photograph's pair — the same sharp ::before and masked-soft
   ::after, the same drift (--hx/--hy inherit from .hero-bg) — is painted
   on this child at opacity 0, faded to 1 (opacity only), and only then
   written into .hero-bg's own properties and the child removed: the two
   are pixel-identical by then, so the hand-over is invisible. z-index 1
   like the canvas and before it in the tree: over .hero-bg::after,
   under the splat. .is-waiting never applies to it — a swap never
   re-hides the photo. Exists only while a swap is in flight. */
.hero-next { position: absolute; inset: 0; z-index: 1; opacity: 0; pointer-events: none; }
.hero-next.is-in { opacity: 1; transition: opacity 1.2s ease; }
/* The fallback's live blur gets its own compositing layer: filtered once
   and moved as a texture, rather than re-filtered per frame. The ONE
   deliberate will-change on the page besides the cursor cell. */
.hero-bg:not(.is-baked) { will-change: transform; }
/* The splat (splat.js): a WebGL canvas over the baked blur, faded in by
   opacity only once it has been drawn — never before first paint, never
   at all on the fallback paths, where this element does not exist. */
.hero-splat {
  position: absolute;
  inset: 0;
  z-index: 1;   /* over ::after, inside .hero-bg's own stacking context */
  width: 100%;
  height: 100%;
  opacity: 0;
  pointer-events: none;
}
.hero-splat.is-on { opacity: 1; }
/* The dissolve: a press on the hero lifts the splat to show the
   photograph under it (which rests at opacity 1 — see the arrival),
   easing a touch larger as it goes, the way a splat cloud would let go.
   Opacity and transform only, so the parked loop stays parked and the
   compositor does all of it. splat.js owns the class and the timing:
   back after 2s from a click's release, held for as long as the press. */
.hero-splat.is-on.is-lifted { opacity: 0; transform: scale(1.025); }
.hero-splat { will-change: opacity; transition: opacity 0.6s ease, transform 0.6s ease; }
.hero-splat.is-lifted { transition: opacity 0.55s ease, transform 0.55s ease; }
@media (prefers-reduced-motion: reduce) { .hero-splat { transition: none; } }
.hero-scrim {
  position: absolute;
  inset: 0;
  /* The DARK ground's literal values on purpose, not the theme's: the
     hero is a photograph, and a photograph is its own ground in both
     themes. A paper gradient over the picture read as a fogged print. */
  background: linear-gradient(180deg,
    rgba(21, 20, 15, 0.42) 0%,
    rgba(21, 20, 15, 0.30) 38%,
    rgba(21, 20, 15, 0.82) 100%);
}
.hero-top {
  position: relative;
  width: var(--content);
  margin: 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1em;
  padding: clamp(12px, calc(var(--vu) * 4), 40px) var(--edge);
  color: var(--on-photo);   /* on the photograph, both themes */
}
/* The wordmark on the hero keeps its paper fill in both themes. */
.hero-wordmark,
.topbar-wordmark {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  line-height: 0;
}
/* The logo is a MASK, not an image: the SVG shape is painted with --paper, so
   it recolours with the theme (light/dark) and with a catalog's own palette —
   default and uploaded logos alike (app.js applyIdentity sets --logo to the
   catalog's SVG, or leaves the built-in). --logo-ar is the uploaded logo's
   aspect ratio (app.js measures it); the default is the wordmark's 400×250. */
.wordmark-logo,
.footer-logo {
  display: block;
  width: auto;
  background-color: var(--paper);
  aspect-ratio: var(--logo-ar, 400 / 250);
  -webkit-mask: var(--logo, url("/assets/Logo.svg")) no-repeat left center / contain;
          mask: var(--logo, url("/assets/Logo.svg")) no-repeat left center / contain;
}
.wordmark-logo {
  /* The native cap that kept the 400×250 SVG from being 250px tall. */
  height: clamp(22px, calc(var(--vu) * 3.4), 38px);
}
.topbar-wordmark .wordmark-logo { height: clamp(18px, calc(var(--vu) * 2.8), 28px); }
.hero-wordmark:focus-visible,
.topbar-wordmark:focus-visible { outline: 2px solid var(--lichen); outline-offset: 3px; }
.hero-coord {
  color: var(--on-photo-mid);
  /* Onto the section-header line, where the theme switch sits: at most
     a 4px nudge off the mark's centre line. */
  align-self: flex-start;
  margin-top: calc(var(--panel-pad) - clamp(12px, calc(var(--vu) * 4), 40px));
}
.hero-content {
  position: relative;
  color: var(--on-photo);
  flex: 1;
  width: var(--content);
  margin: 0 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding: 0 var(--edge) clamp(24px, calc(var(--svh) * 10), 110px);
}
/* The text block and the console share this row; the console is
   bottom-aligned so its one line sits on the subtitle's line. Under
   680px the row wraps and the console drops beneath the text. */
.hero-row {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: clamp(16px, calc(var(--vu) * 5), 80px);
  /* When the row can't hold the title and the console side by side, the
     console drops to its own line beneath the subtitle — never clipped,
     and the title is never wrapped to make room for it. */
  flex-wrap: wrap;
}
.hero-text { flex: 0 1 auto; min-width: 0; }
.hero-text { display: flex; flex-direction: column; gap: clamp(8px, calc(var(--vu) * 2), 22px); }
/* Display type is allowed more scale than the labels, with a rem floor. */
.hero-title {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.9rem, calc(var(--vu) * 6.3), 4.9rem);
  line-height: 1;
  letter-spacing: -0.01em;
  margin: 0;
  max-width: 15ch;
  text-wrap: balance;
}
.hero-sub {
  margin: 0;
  font-size: clamp(0.546rem, calc(var(--vu) * 0.98), 0.644rem);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--on-photo-soft);
  max-width: 44ch;
}

/* ── The changelog ─────────────────────────────────────────────
   A console readout in the hero's bottom-right corner, under the
   arrow, in the flow of the hero column. Collapsed it is one line with
   a blinking block cursor; open, the list OVERLAYS the photograph —
   absolutely positioned in the hero column, right-aligned with the
   head, growing upward — so the title and the subtitle never
   move. app.js places it on open (see placeLog): above the head where
   there is room beside the title, above the title where the list would
   cover it, and never over the wordmark row; past that it scrolls
   inside itself. Set larger than the site's labels on purpose — this is
   the one readout meant to be read, not glanced at — on the same --vu
   scale, floored where it stays legible on a phone on its side. On the
   photograph, so the on-photo tokens in both themes. */
.hero-log {
  position: relative;
  align-self: flex-end;
  flex: 0 0 auto;
  width: max-content;    /* its full line, always... */
  max-width: 100%;       /* ...unless it alone is wider than the row */
  /* The veil's inset lives here, not on the head: a negative margin on a
     flex item is subtracted from the container's max-content width, and
     a max-width on the same item then subtracts it again — 12px of the
     label vanished behind an ellipsis at every viewport. */
  padding: 0 var(--cell-w);
  margin-right: calc(var(--cell-w) * -1);
  margin-left: auto;
  /* The head's own padding sits below the text baseline; pull it back
     so the console's line and the subtitle's line coincide. */
  margin-bottom: -0.35em;
  font-size: clamp(10px, calc(var(--vu) * 0.8), 11.5px);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  line-height: 1.5;
  color: var(--on-photo-mid);
  display: flex;
  justify-content: flex-end;
}
.hero-log-head {
  display: flex;
  align-items: center;
  gap: calc(var(--cell-w) * 0.8);
  margin: 0 calc(var(--cell-w) * -1);   /* fills the veil's inset above */
  padding: 0.35em var(--cell-w);
  background: transparent;
  border: 0;
  border-left: 1px solid transparent;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: inherit;
  cursor: pointer;
  transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
}
/* The hover says "this opens": the head takes the drawer's own ground
   and accent edge, so it reads as the drawer's handle. */
.hero-log-head:is(:hover, .ff-hover),
.hero-log-head[aria-expanded="true"] {
  color: var(--on-photo);
  background: var(--on-photo-veil);
  border-left-color: var(--on-photo-accent);
}
.hero-log-head:focus-visible { outline: 2px solid var(--lichen); outline-offset: 3px; }
.hero-log-prompt { color: var(--on-photo-accent); flex: none; }
.hero-log-latest {
  min-width: 0;
  white-space: pre;   /* the two-space columns are the layout */
  overflow: hidden;
  text-overflow: ellipsis;
}
/* A terminal's block cursor, blinking on the same clock as the NEW
   badge. Steady when the drawer is open or the head is hovered. */
.hero-log-cursor {
  flex: none;
  width: 0.62em;
  height: 1.05em;
  background: currentColor;
  animation: ff-blink 1.1s steps(1, end) infinite;
}
.hero-log-head:is(:hover, .ff-hover) .hero-log-cursor,
.hero-log-head[aria-expanded="true"] .hero-log-cursor { animation: none; }
.hero-log-list {
  position: absolute;
  right: calc(var(--cell-w) * -1);     /* flush with the head's veil */
  bottom: 100%;                         /* app.js measures the exact offset */
  width: max-content;
  max-width: calc(100vw - var(--edge) * 2);
  list-style: none;
  margin: 0;
  padding: calc(var(--cell-w) * 1.2) calc(var(--cell-w) * 1.6);
  max-height: calc(var(--svh) * 42);
  overflow-y: auto;
  scrollbar-width: none;
  background: var(--on-photo-veil);
  border-left: 1px solid var(--on-photo-accent);
  z-index: 2;                           /* over the title's row */
}
.hero-log-list::-webkit-scrollbar { display: none; }
.hero-log-item {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr);
  column-gap: calc(var(--cell-w) * 2);
  padding: 0.32em 0;
  color: var(--on-photo-mid);
  text-decoration: none;
  transition: color 0.2s ease;
}
.hero-log-item:is(:hover, .ff-hover),
.hero-log-item:focus-visible { color: var(--on-photo); outline: none; }
.hero-log-item:focus-visible .hero-log-date { color: var(--on-photo); }
.hero-log-date { color: var(--on-photo-dim); white-space: nowrap; }
.hero-log-what { color: var(--on-photo-accent); white-space: nowrap; }
.hero-log-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
@media (prefers-reduced-motion: no-preference) {
  /* Each line prints on as the drawer opens, in order — the animation
     starts when [hidden] comes off. */
  .hero-log-item { --fb: 3px; animation: ff-print 0.5s cubic-bezier(0.5, 0, 0.1, 1) calc(var(--i, 0) * 45ms) both; }
}

/* ── Compact masthead (folder view) ─────────────────────────── */
/* Fixed, not in the flow: the strip scrolls sideways underneath it, so
   `back` stays reachable from any panel of a long folder. app.js
   measures its height into --chrome and panels pad past it. */
.topbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  width: var(--content);
  margin: 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1em;
  padding: clamp(12px, calc(var(--vu) * 4), 40px) var(--edge);
}
.topbar[hidden] { display: none; }
.topbar-left { display: flex; align-items: center; gap: clamp(10px, calc(var(--vu) * 2), 22px); }
.topbar-right, .hero-right { display: flex; align-items: center; gap: clamp(10px, calc(var(--vu) * 2.4), 26px); min-width: 0; }
.topbar-coord { color: var(--paper-dim); }

/* ── The account keys (app.js, Account) ───────────────────────
   [ LOGIN ] beside the coordinates, in the topbar and on the hero;
   once in, the name is the key ([ <username> ] opens nothing — it
   says who) and [ LOGOUT ] stands beside it, the tier's name dim
   before them. Keys in the lightbox's mono; hidden entirely on a
   site without accounts. */
.ff-account { display: inline-flex; align-items: center; gap: 0.9em; }
.ff-account[hidden] { display: none; }
/* .ff-key sets display, which would win over the UA's [hidden]. */
.ff-account .ff-key[hidden], .ff-tier[hidden] { display: none; }
.ff-account .ff-key { font-family: var(--mono); letter-spacing: 0.12em; }
.ff-account .ff-login.is-user { text-transform: none; letter-spacing: 0.04em; cursor: default; }
.ff-account .ff-login.is-user .ff-key-label { color: var(--paper); }
.ff-tier { font-family: var(--mono); font-size: 0.8em; letter-spacing: 0.12em; text-transform: uppercase; color: var(--paper-dim); }
.hero-right .ff-tier { color: var(--on-photo-mid); }
.hero-right .ff-key { color: var(--on-photo-mid); }
.hero-right .ff-key:is(:hover, .ff-hover) { color: var(--on-photo); }
/* In the colophon: a small dim key under the two lines, the tier name
   beside it. The site is public; this is the one door to the folders
   kept for friends, and it should read like a footnote. */
.colophon .ff-account { margin-top: 6px; gap: 0.7em; font-size: 0.85em; }
.colophon .ff-account .ff-key-label { color: var(--paper-dim); }
.colophon .ff-account .ff-key:is(:hover, .ff-hover) .ff-key-label { color: var(--paper); }
.colophon .ff-tier { color: var(--paper-dim); }

/* The login prompt: the admin gate's two lines, over everything but
   the cursor. A field is an underlined run; the caret is lichen. */
.login {
  position: fixed; inset: 0; z-index: 72;
  display: grid; place-items: center;
  background: rgb(from var(--ink) r g b / 0.86);
  font-family: var(--mono); letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--paper-dim);
}
.login[hidden] { display: none; }
.login-card {
  display: grid; row-gap: 0.5em; min-width: min(34ch, 90vw);
  padding: calc(var(--cell-w) * 1.6) calc(var(--cell-w) * 2.4);
  background: rgb(from var(--ink) r g b / 0.92);
  border-left: 1px solid var(--lichen);
}
.login-line { display: flex; align-items: baseline; gap: 1ch; margin: 0; }
.login-label { color: var(--lichen); white-space: nowrap; }
.login-line input {
  flex: 1; min-width: 8ch; background: none; border: 0; border-bottom: 1px solid var(--paper-dim);
  padding: 0.1em 0; color: var(--paper); font: inherit; letter-spacing: 0.06em; text-transform: none;
  caret-color: var(--lichen); outline: none; border-radius: 0;
}
.login-line input:focus { border-bottom-color: var(--lichen); }
.login-status { margin: 0; min-height: 1.4em; color: var(--paper-dim); }
.login-status.is-error { color: var(--paper); }
.login-status.is-busy { color: var(--lichen); }
.login-keys { display: flex; gap: 1.4em; margin: 0.2em 0 0; }
html.ff-motion .login:not([hidden]) .login-line { --fb: 3px; animation: ff-print 0.5s cubic-bezier(0.5, 0, 0.1, 1) both; }
html.ff-motion .login:not([hidden]) .login-line + .login-line { animation-delay: 0.08s; }
.back { font-family: var(--mono); }   /* the rest is .ff-key */
.back:focus-visible { outline: 2px solid var(--lichen); outline-offset: 2px; }

/* ═══════════════════════════════════════════════════════════════
   FILMSTRIP — the page is one horizontal track of viewport panels.
   #strip is the ONLY scroller on the page; html and body are
   overflow:hidden above.
   ═══════════════════════════════════════════════════════════════ */
.strip {
  position: relative;
  height: 100vh;
  height: 100svh;        /* exclude mobile browser chrome */
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  /* Snap is a touch-and-index-only instrument: a fine pointer turns it
     off below (motion.js owns the position there and snap only fights
     the glide), and so does a folder (one stop at its head, and
     proximity there drags a small swipe back to the title). */
  scroll-snap-type: x proximity;
  /* Don't hand a horizontal overscroll to the browser's back gesture. */
  overscroll-behavior-x: contain;
  /* Horizontal panning stays native. A vertical drag on a section is
     the pane's own scroll — touch-action only blocks between the touch
     and the nearest element that would scroll, and the pane is nearer. */
  touch-action: pan-x;
  scroll-behavior: auto;
  scrollbar-width: none;
}
@media (hover: hover) and (pointer: fine) {
  .strip { scroll-snap-type: none; }
}
body:not(.index-mode) .strip { scroll-snap-type: none; }
.strip::-webkit-scrollbar { width: 0; height: 0; }
/* motion.js sets this while it drives the scroll: snap overrides the
   position once a gesture ends, which is exactly what the glide is
   still doing, and the two take turns undoing each other. */
.strip.is-driving { scroll-snap-type: none; }

/* app.js and videos.js each own a stretch of the strip. `display:
   contents` makes the panels they build real children of the flex
   container while each file keeps one element it can empty and refill. */
.run { display: contents; }
.run[hidden] { display: none; }

.panel {
  position: relative;
  flex: 0 0 100vw;
  width: 100vw;
  height: 100%;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  overflow: hidden;
  display: flex;
}
.panel[hidden] { display: none; }

/* The content block: the site's own max width and gutter, centred. */
.panel-inner {
  width: var(--content);
  margin: 0 auto;
  padding: calc(var(--panel-pad) + var(--chrome)) var(--edge) var(--panel-pad);
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.panel--status .panel-inner { justify-content: center; }

/* Hidden for the duration of the load, and only for that: this card is
   static markup, so the browser paints it as soon as the stylesheets
   land — alone, full width, while the hero is still `hidden` and both
   runs are empty. `display: none`, not `visibility: hidden`: strip.js's
   measure() skips panels with `offsetParent === null`, and a merely
   invisible card would still push a real stop. The class is added by
   the inline script in index.html's <head> and removed by app.js as a
   view renders; with scripting off nothing matches. Don't move it under
   `html.ff-motion`: it has to hold whether motion.js loads or not. */
html.ff-booting .panel--about { display: none; }

/* ── The end card ─────────────────────────────────────────────
   Sized to what it holds: as wide as its content plus a gutter each
   side, so it begins one --edge after the films end and finishes at its
   own edge. (16:9 capped at 100vw resolved to a full screen on nearly
   every viewport, and its padding-left rebuilt the centred column's
   margin on top of the gutter the films panel had already left — 890px
   of nothing on a 2000px display.) */
.panel--about {
  flex: 0 0 auto;      /* .panel says 100vw; both of these must override it */
  width: auto;
  max-width: 100vw;
  height: 100%;
  display: flex;
}
.about-inner {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  width: max-content;
  max-width: 100vw;
  padding: calc(var(--panel-pad) + var(--chrome)) var(--edge) var(--panel-pad);
  min-height: 0;
}
/* The reading matter centres in the space above the colophon, which
   keeps the floor. */
.about-main {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
}
.about-title {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.4rem, calc(var(--svh) * 4.2), 2.6rem);
  line-height: 1.05;
  letter-spacing: -0.01em;
  margin: 0 0 clamp(10px, calc(var(--svh) * 2.4), 24px);
  max-width: 18ch;
  text-wrap: balance;
}
/* The one place the site has running text. Uppercase mono is the house
   voice for labels and a paragraph set in it is unreadable, so the body
   borrows the display face at a size meant for reading. */
.about-body {
  display: flex;
  flex-direction: column;
  gap: 0.85em;
  margin: 0 0 clamp(14px, calc(var(--svh) * 4), 44px);
  max-width: 46ch;
}
.about-body p {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(0.85rem, calc(var(--svh) * 2.1), 1.18rem);
  line-height: 1.5;
  color: var(--paper-soft);
}

.about-links { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.about-link {
  display: inline-flex;
  align-items: baseline;
  gap: 1.1em;
  text-decoration: none;
  font-size: 0.52rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--paper-dim);
  border-bottom: 1px solid var(--hair);
  padding-bottom: 7px;
  transition: color 0.2s ease, border-color 0.2s ease;
}
.about-link-value { color: var(--paper); }
.about-link:is(:hover, .ff-hover) { color: var(--lichen); border-color: var(--lichen); }
.about-link:is(:hover, .ff-hover) .about-link-value { color: var(--lichen); }
.about-link:focus-visible { outline: 2px solid var(--lichen); outline-offset: 3px; }

/* ── The flowing section ──────────────────────────────────────
   A folder's photographs are ONE grid, as wide as it needs to be,
   running off to the right — which is what makes natural aspect ratios
   possible: /api/photos returns no dimensions, and a flowing grid never
   has to know them. */
.panel--flow {
  position: relative;
  /* Both, and both matter: .panel sets `width: 100vw`, and overriding
     only `flex` left the section one screen wide, clipping every column
     past the first four. */
  flex: 0 0 auto;
  width: auto;
  height: 100%;
  display: flex;
}
/* A starting value only — strip.js measures the columns and sets an
   explicit width, because a column-wrap container's max-content width
   is under-specified and one engine answers "one column". */
.panel--flow .gallery--frames { width: max-content; }
.flow-inner {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  gap: var(--gap);
  padding: calc(var(--panel-pad) + var(--chrome)) var(--edge) var(--panel-pad);
  /* A section of unbounded width cannot be centred, but its head can
     start where a centred one would, so moving from the index into a
     folder doesn't shift the margin. */
  padding-left: max(var(--edge), calc((100vw - var(--content)) / 2 + var(--edge)));
}
/* The title stands on the floor at the head of the strip, and the
   photographs begin in the very next column. */
.flow-inner .folder-head {
  align-self: flex-end;
  flex: none;
  width: clamp(140px, calc(var(--vu) * 20), 280px);
  max-width: none;
  padding-bottom: 2px;
}

.folder-head { margin: 0; max-width: 60ch; }
.folder-title {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.274rem, calc(var(--vu) * 3.094), 2.002rem);
  line-height: 1.05;
  letter-spacing: -0.01em;
  margin: 0;
}
.folder-caption {
  margin: 0.7em 0 0;
  color: var(--paper-soft);
  font-size: 0.783rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  line-height: 1.55;
}
@media (max-width: 640px) {
  .folder-title { font-size: clamp(1.6rem, calc(var(--vu) * 8), 2.4rem); line-height: 1.02; }
  .folder-caption { font-size: 0.64rem; letter-spacing: 0.1em; }
}

/* ── Index sections ───────────────────────────────────────────
   Folders and the films are each ONE panel holding the whole list
   (FFStrip.section), and the panel's content block is the scroller:
   the header line is the first thing inside it and reads away with the
   grid. The scroller is the WHOLE panel height — deliberately not a
   pane clipped below a sticky head, which read as a window inside the
   page. No native scrollbar: a bar down the side of a filmstrip would
   be the one thing that made it a box. */
.panel--section { background: var(--ink); }
.panel--section .panel-inner {
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  /* Its end must not chain: a phone would rubber-band the page, and the
     strip must never move on a vertical gesture. */
  overscroll-behavior: contain;
  scrollbar-width: none;
}
.panel--section .panel-inner::-webkit-scrollbar { width: 0; height: 0; display: none; }
/* Children of a scrolling flex column must keep their natural height, or
   the grid shrinks to fit the panel and nothing overflows to read. */
.panel--section .panel-inner > * { flex: none; }

/* ── Folder index (square cover cards) ──────────────────────── */
.gallery--folders {
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
  gap: var(--gap);
  align-content: start;
}
.folder-card {
  position: relative;
  display: block;
  aspect-ratio: 1 / 1;
  background: var(--ink-soft);
  overflow: hidden;
  text-decoration: none;
  line-height: 0;
  /* Skip layout/paint for cards below the fold. Safe here specifically
     because `aspect-ratio` fixes the box from the grid column alone;
     never put this on a box sized by its contents. */
  content-visibility: auto;
  contain-intrinsic-size: auto 300px;
}
.folder-card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
}
.folder-card img.loaded { opacity: 1; }
.folder-new {
  position: absolute;
  top: calc(var(--cell-w) * 1.33);
  right: calc(var(--cell-w) * 1.55);
  font-size: 0.6rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--on-photo-accent);
  line-height: 1;
  animation: ff-blink 1.1s steps(1, end) infinite;
}
.folder-label {
  position: absolute;
  inset: auto 0 0 0;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.8em;
  padding: calc(var(--cell-w) * 1.55) calc(var(--cell-w) * 1.55) calc(var(--cell-w) * 1.33);
  line-height: 1.3;
}
.folder-name {
  font-size: 0.6rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--on-photo-dim);
  /* One line, trimmed: a card on a short screen is narrow enough for a
     long name to wrap. */
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.folder-count {
  font-size: 0.6rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--on-photo-dim);
  flex: none;
}
@media (prefers-reduced-motion: no-preference) {
  .folder-card img { transition: opacity 0.6s ease, transform 0.5s ease; }
  .folder-card:is(:hover, .ff-hover) img { transform: scale(1.03); }
  .folder-name { transition: color 0.2s ease; }
  .folder-card:is(:hover, .ff-hover) .folder-name { color: var(--on-photo); }
}
.folder-card:focus-visible { outline: 2px solid var(--lichen); outline-offset: 2px; }

/* ── Folder view: the flowing grid's tiles ───────────────────
   Flex column-wrap rather than grid on purpose: in a grid every item in
   a column shares that column's width, so one wide frame opens a gap
   beside every narrower one below it. Here each column is exactly
   --col-w — the same width a card has on the index — and nothing is
   ragged. Frames fill a column top to bottom, then wrap right. */
.gallery--frames {
  display: flex;
  flex-flow: column wrap;
  height: 100%;
  gap: var(--gap);
  align-content: flex-start;   /* columns pack to the left */
}
/* One column wide, and as tall as the photograph is. The 3:2 is only a
   placeholder for the moment before the image arrives — app.js replaces
   it with the real naturalWidth/naturalHeight on load, which is the only
   place the true ratio exists. */
.frame {
  position: relative;
  display: block;
  flex: none;
  width: var(--col-w, 270px);
  aspect-ratio: 3 / 2;
  /* A frame taller than the strip would push out of the column and be
     clipped; object-fit keeps such a frame uncropped. */
  max-height: 100%;
  background: var(--ink-soft);
  transition: background-color 0.5s ease;
  cursor: zoom-in;
  overflow: hidden;
  line-height: 0;
}
.frame img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  opacity: 0;
}
.frame img.loaded { opacity: 1; }
/* Keyed off a class on the TILE, not `:has(img.loaded)`, which fired an
   ancestor-direction style invalidation once per photograph. */
.frame.is-loaded { background: transparent; }
.frame .idx {
  position: absolute;
  top: 8px;
  left: 9px;
  font-size: 0.434rem;
  letter-spacing: 0.12em;
  color: var(--paper);
  background: rgb(from var(--ink) r g b / 0.55);
  padding: 2px 6px;
  border-radius: 2px;
  opacity: 0;
  pointer-events: none;
  line-height: 1.4;
}
@media (prefers-reduced-motion: no-preference) {
  .frame img { transition: opacity 0.6s ease, transform 0.5s ease; }
  .frame .idx { transition: opacity 0.25s ease; }
  .frame:is(:hover, .ff-hover) img { transform: scale(1.03); }
}
.frame:is(:hover, .ff-hover) .idx,
.frame:focus-visible .idx { opacity: 1; }
.frame:focus-visible { outline: 2px solid var(--lichen); outline-offset: 2px; }

/* ── Status ─────────────────────────────────────────────────── */
.status {
  text-align: center;
  color: var(--paper-dim);
  font-size: 0.546rem;
  letter-spacing: 0.1em;
}
.status-line { margin: 0.4em 0; }
.status .hint { color: var(--lichen); }

/* ── Colophon ───────────────────────────────────────────────── */
/* The foot of the end card, stacked; margin-top:auto puts it on the floor. */
.colophon {
  margin: auto 0 0;
  padding: 20px 0 0;
  border-top: 1px solid var(--hair);
  color: var(--paper-dim);
  line-height: 1.9;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  min-width: 22ch;
}
.footer-logo {
  height: 24px;
  opacity: 0.8;
  margin-bottom: 10px;
}

/* ── Lightbox (full screen, contained image, caption row) ───── */
.lightbox {
  position: fixed;
  inset: 0;
  background: var(--backdrop);
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 50;
  padding: clamp(10px, 2vw, 28px);
}
.lightbox.open { display: flex; }
.lb-stage {
  margin: 0;
  max-width: 100%;
  max-height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(12px, 1.6vw, 18px);
}
/* The zoom viewport (app.js, Zoom): shrink-wraps the photo's fit box and
   clips the scaled photo to it, so zoomed pixels never cover the caption
   row or the close button. touch-action: none — the pinch and the pan are
   ours, and the browser must not answer them with its own page zoom. */
.lb-view {
  display: block;
  flex: none;
  max-width: 100%;
  overflow: hidden;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}
/* The background is the OPENING PLACEHOLDER (app.js, The opening
   placeholder): the 640 tile the visitor just clicked, painted at the
   size the full rung is about to land in, so the lightbox opens on the
   photograph instead of on a flat rectangle. app.js writes only
   `background-image` and clears it once the full rung has decoded; the
   sizing stays here, where every other layout number is.
   `contain`, not `cover`: the ratio the placeholder is sized by comes
   from the 640 rung and the photograph's from the 2560, and the same
   picture rounded at two sizes can disagree by a tenth of a percent.
   Letterboxing that against --ink-soft is invisible; cropping it would
   nibble an edge. Nothing here fades — background-image cannot
   transition (which is why the hero's live change needs a whole second
   layer; see .hero-next), and a fade would be wrong anyway: soft to
   sharp at one size reads as the picture resolving, two pictures
   crossing over reads as an effect. */
.lb-img {
  display: block;
  max-width: 94vw;
  max-height: 80vh;
  width: auto;
  height: auto;          /* element shrink-wraps the photo — no crop */
  object-fit: contain;
  background-color: var(--ink-soft);
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
  transform-origin: 0 0; /* the zoom's translate + scale, from the corner */
}
/* Zoomed, the photo moves as a texture on its own layer; at 1× the
   promotion goes so a resting lightbox costs nothing extra. */
.lb-img.is-zoomed { will-change: transform; }
/* A click, a key or a double tap ease to the new scale; a gesture
   (drag, pinch, wheel) writes the transform straight. */
@media (prefers-reduced-motion: no-preference) {
  .lb-img.is-eased { transition: transform 0.32s cubic-bezier(0.5, 0, 0.1, 1); }
}
/* Caption row — width is synced to the photo's rendered width in JS. */
.lb-bar {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 1.5em;
  font-size: 0.476rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  line-height: 1.4;
}
.lb-settings,
.lb-camera {
  color: var(--paper-mid);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.lb-settings { justify-self: start; }
.lb-camera { justify-self: end; text-align: right; }
.lb-nav-group { justify-self: center; display: flex; align-items: center; gap: 1.4em; }
.lb-meta { color: var(--paper-dim); white-space: nowrap; }
.lb-meta .accent { color: var(--lichen); }
.lb-meta .place { color: var(--paper); }
/* The zoom readout (×4), in the meta line's own voice; empty at 1×. */
.lb-zoom { color: var(--paper-mid); white-space: nowrap; }
.lb-zoom:empty { display: none; }
.lb-arrow { font-family: var(--mono); font-size: 0.85rem; line-height: 1; }   /* the rest is .ff-key */
.lb-arrow:focus-visible,
.lb-close:focus-visible,
.lb-share:focus-visible { outline: 2px solid var(--lichen); outline-offset: 2px; }
/* The dialog's keys, top-right: [ SHARE ] [ ESC ], esc keeping the
   corner it has always had. The container holds the position; the keys
   are plain .ff-key and share one label rule. The row sits above the
   photograph's fit box on every viewport (80vh / 56vh of the height,
   centred), so it never meets the caption bar or the arrows below. */
.lb-keys {
  position: absolute;
  top: clamp(12px, 3vw, 28px);
  right: clamp(12px, 3vw, 28px);
  display: flex;
  align-items: center;
  gap: 1.4em;
}
.lb-close,
.lb-share {
  font-family: var(--mono);
  letter-spacing: 0.12em;
}
/* Prints on each time the lightbox opens; the keys are display:none
   with the dialog, so the animation restarts on every open. Esc first,
   share a beat behind it, so the row prints in reading order. */
html.ff-motion .lightbox.open .lb-close { --fb: 3px; animation: ff-print 0.55s cubic-bezier(0.5, 0, 0.1, 1) 0.1s both; }
html.ff-motion .lightbox.open .lb-share { --fb: 3px; animation: ff-print 0.55s cubic-bezier(0.5, 0, 0.1, 1) 0.18s both; }
/* "copied": the label itself says so for a moment (app.js, share());
   no new colour — the key keeps its rest and hover states, and the text
   is the only thing that changes. */
.lb-share.is-done .ff-key-label { color: var(--paper); }
html.ff-motion .topbar:not([hidden]) .back { --fb: 3px; animation: ff-print 0.55s cubic-bezier(0.5, 0, 0.1, 1) 0.15s both; }

/* Mobile: stack settings + camera under the photo, then arrows + breadcrumb. */
@media (max-width: 640px) {
  .lb-img { max-height: 56vh; }
  .lb-bar {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    width: auto !important;   /* override the JS width sync */
    font-size: 0.62rem;
    text-align: center;
  }
  .lb-settings, .lb-camera {
    justify-self: auto;
    text-align: center;
    white-space: normal;
    overflow: visible;
  }
  .lb-settings { order: 1; }
  .lb-camera { order: 2; }
  .lb-nav-group { order: 3; margin-top: 6px; gap: 1.6em; }
  .lb-arrow { font-size: 1rem; padding: 4px 8px; }
}

/* ═══════════════════════════════════════════════════════════════
   MOTION — grain, arrival, reveal, cursor.

   · Everything staged invisible is scoped to `html.ff-motion`, a class
     only motion.js sets, so nothing can be left hidden with nobody to
     reveal it.
   · Everything that arrives is a one-shot ANIMATION, not a transition
     from a toggled class: every element here is `[hidden]` until
     /api/photos answers, and an animation begins when its element
     becomes displayed, so it is keyed to the moment the content
     arrives, with no JS involved.
   · Nothing animates a property that costs layout — transform, opacity
     and filter only.
   ═══════════════════════════════════════════════════════════════ */

/* ── Console keys ─────────────────────────────────────────────
   The site's buttons — [ ESC ], [ ← ALL ], the lightbox arrows — are
   keys on a terminal, not boxes: a dim bracketed label at rest. On
   hover the brackets tighten in by a hair and the label goes
   reverse-video (ground on paper), which is how a 70s screen says
   "selected"; the press-invert in motion.js then says "pressed".
   Transform and colour only; the brackets are pseudo-elements so the
   markup stays a plain <button> with a label span. `--bare` keeps the
   brackets for the hover only (the lightbox arrows). */
.ff-key {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  background: none;
  border: 0;
  padding: 0.35em 0.2em;
  color: var(--paper-dim);
  cursor: pointer;
  text-transform: uppercase;
  transition: color 0.18s linear;
}
.ff-key::before,
.ff-key::after {
  content: "[";
  color: var(--paper-dim);
  opacity: 0.55;
  transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.18s linear, color 0.18s linear;
}
.ff-key::after { content: "]"; }
.ff-key--bare::before,
.ff-key--bare::after { opacity: 0; transform: translateX(0.3em); }
.ff-key--bare::after { transform: translateX(-0.3em); }
.ff-key-label {
  padding: 0 0.15em;
  transition: background-color 0.12s linear, color 0.12s linear;
}
.ff-key:is(:hover, .ff-hover) { color: var(--paper); }
.ff-key:is(:hover, .ff-hover)::before { transform: translateX(0.18em); opacity: 1; color: var(--lichen); }
.ff-key:is(:hover, .ff-hover)::after  { transform: translateX(-0.18em); opacity: 1; color: var(--lichen); }
.ff-key:is(:hover, .ff-hover) .ff-key-label { background: var(--paper); color: var(--ink); }
.ff-key:active .ff-key-label { background: var(--lichen); color: var(--ink); }
@media (prefers-reduced-motion: reduce) {
  .ff-key, .ff-key::before, .ff-key::after, .ff-key-label { transition: none; }
}

/* ── Grain ────────────────────────────────────────────────────
   One inline SVG tile, no image request: fractalNoise, then a colour
   matrix that throws away the RGB (forcing white, or black for the
   light tile) and moves the red channel into ALPHA, so the tile is
   transparent with speckles and composites normally — no
   mix-blend-mode, which would pull the whole viewport into a blend
   group on every paint. The gamma on alpha is load-bearing: raw noise
   sits near 0.5 with a narrow spread and is a flat haze at any opacity;
   gamma 2.4 pulls the distribution apart into sparse specks.
   stitchTiles='stitch' is what makes it seamless. The opacities are
   the tuning knobs; the tile itself should not need to change. */
:root {
  --grain-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0 0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='gamma' exponent='2.4' amplitude='1'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23g)'/%3E%3C/svg%3E");
  --grain-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='gamma' exponent='2.4' amplitude='1'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23g)'/%3E%3C/svg%3E");
}

/* Above the content but below both lightboxes (z-index 50/60): grain
   must never fall on the full-size photograph. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background-image: var(--grain);
  background-size: 220px 220px;
  opacity: var(--grain-page);
}

/* A second pass over the hero only, riding the scrim: the white tile at
   the dark opacity in BOTH themes, like the scrim — the hero is a
   photograph, not the page. */
.hero-scrim::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: var(--grain-light);
  background-size: 220px 220px;
  opacity: 0.13;
}

/* ── The develop ──────────────────────────────────────────────
   The backdrop resolves OUT of the dark rather than flashing bright.
   The end state is .hero-bg's own resting state — blur(var(--hero-blur))
   and scale(1.06) — and the animation fills BACKWARDS only, so once it
   ends the element's own style applies and a baked backdrop is left
   with no filter at all (a `both` fill would hold blur(0px) on it for
   ever). Change the resting values and this `to` together, or the
   backdrop jumps at the end. */
@keyframes ff-develop {
  /* The transform lists match .hero-bg's resting transform function for
     function, so from/to interpolate cheaply and the end IS the resting
     state (the tilt vars are 0 unless a pointer is mid-drift). */
  from {
    opacity: 0;
    filter: blur(34px) brightness(0.28) saturate(0.25);
    transform: perspective(1200px) rotateX(var(--ty, 0deg)) rotateY(var(--tx, 0deg)) scale(1.22);
  }
  40% { opacity: 1; }
  to {
    opacity: 1;
    filter: blur(var(--hero-blur, 8px)) brightness(1) saturate(1);
    transform: perspective(1200px) rotateX(var(--ty, 0deg)) rotateY(var(--tx, 0deg)) scale(1.06);
  }
}

/* Everything else pulls into focus the same way. --fb is each element's
   own blur depth: big type needs a deeper blur than small type. */
@keyframes ff-focus {
  from {
    opacity: 0;
    filter: blur(var(--fb, 10px));
    transform: translate3d(0, 14px, 0);
  }
  to {
    opacity: 1;
    filter: blur(0);
    transform: none;
  }
}

/* The mono readout lines also print in left-to-right. The -2% end inset
   keeps the clip clear of letter-spacing overhang. */
@keyframes ff-print {
  from {
    opacity: 0;
    filter: blur(var(--fb, 5px));
    clip-path: inset(0 100% 0 0);
  }
  to {
    opacity: 1;
    filter: blur(0);
    clip-path: inset(0 -2% 0 0);
  }
}

@media (prefers-reduced-motion: no-preference) {
  html.ff-motion .hero-bg {
    animation: ff-develop 2.2s cubic-bezier(0.16, 0.8, 0.24, 1) backwards;
  }

  /* Staggered so the hero assembles; the delays trail the develop. */
  html.ff-motion .hero-wordmark { --fb: 9px;  animation: ff-focus 1.1s cubic-bezier(0.16, 0.8, 0.24, 1) 0.35s both; }
  html.ff-motion .hero-coord    { --fb: 4px;  animation: ff-print 1.2s cubic-bezier(0.5, 0, 0.1, 1) 0.55s both; }
  html.ff-motion .hero-title    { --fb: 20px; animation: ff-focus 1.5s cubic-bezier(0.16, 0.8, 0.24, 1) 0.62s both; }
  html.ff-motion .hero-sub      { --fb: 6px;  animation: ff-focus 1.3s cubic-bezier(0.16, 0.8, 0.24, 1) 0.82s both; }
  /* The changelog prints on last, like the readout lines. */
  html.ff-motion .hero-log-head { --fb: 4px;  animation: ff-print 1.2s cubic-bezier(0.5, 0, 0.1, 1) 1.05s both; }

  /* Section headers and the folder header, when their view arrives. */
  html.ff-motion .folders-meta,
  html.ff-motion .topbar-coord   { --fb: 4px;  animation: ff-print 1.1s cubic-bezier(0.5, 0, 0.1, 1) 0.15s both; }
  html.ff-motion .topbar-wordmark { --fb: 7px; animation: ff-focus 0.9s cubic-bezier(0.16, 0.8, 0.24, 1) both; }
  html.ff-motion .folder-title   { --fb: 14px; animation: ff-focus 1.1s cubic-bezier(0.16, 0.8, 0.24, 1) 0.08s both; }
  html.ff-motion .folder-caption { --fb: 5px;  animation: ff-focus 1.1s cubic-bezier(0.16, 0.8, 0.24, 1) 0.2s both; }
  html.ff-motion .videos-meta    { --fb: 4px;  animation: ff-print 1.1s cubic-bezier(0.5, 0, 0.1, 1) both; }
}

/* ── Tiles surfacing on scroll ────────────────────────────────
   Staged by this rule, released by motion.js adding .is-in as each tile
   reaches the viewport; --d is that tile's stagger within its batch.

   Opacity and transform ONLY. A blur is not free the way those are —
   every frame re-runs a gaussian over the element's texture, and WebKit
   composites animated blurs far less cheaply than Blink; a dozen
   concurrent ones over full-size photographs was the single thing
   Safari punished hardest. And deliberately NO `will-change`: a folder
   stages its whole strip at once, and 72 pending tiles asking to be
   promoted simultaneously is worse than none. Don't put either back. */
@media (prefers-reduced-motion: no-preference) {
  html.ff-motion .ff-reveal {
    opacity: 0;
    transform: translate3d(0, 20px, 0) scale(0.985);
    transition:
      opacity 0.8s cubic-bezier(0.16, 0.8, 0.24, 1) var(--d, 0ms),
      transform 0.95s cubic-bezier(0.16, 0.8, 0.24, 1) var(--d, 0ms);
  }
  html.ff-motion .ff-reveal.is-in {
    opacity: 1;
    transform: none;
  }
}

/* ── The cursor ───────────────────────────────────────────────
   `cursor: none` is scoped to body.ff-cursor, which motion.js adds only
   after the replacement is in the DOM and only on a fine pointer. Never
   move it to a bare `body` rule — that is the one change that could
   leave a page with no pointer at all. */
body.ff-cursor,
body.ff-cursor * { cursor: none !important; }
/* Text entry is the one place a real caret beats a block. Nothing on the
   site takes typed input today; this keeps adding one from shipping an
   unusable field. */
body.ff-cursor input,
body.ff-cursor textarea,
body.ff-cursor [contenteditable] { cursor: text !important; }

.ff-cur {
  position: fixed;
  inset: 0;
  z-index: 9999;
  pointer-events: none;   /* never intercepts a click or a hover */
  opacity: 0;
  transition: opacity 0.25s ease;
}
.ff-cur.is-on { opacity: 1; }

/* Exactly one character cell of the grid, which motion.js snaps to cell
   boundaries: it occupies whichever character space the pointer is in
   and jumps to the next. That quantised step IS the effect — a
   transition or an eased follow would smear it back into an ordinary
   custom cursor, so there is deliberately neither, and no blink (a
   block that both blinks and jumps reads as broken). Origin at the
   viewport's top-left: the block is placed BY ITS CELL, not centred on
   the pointer. */
.ff-block {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--cell-w);
  height: var(--cell-h);
  background: var(--block-bg);
  mix-blend-mode: var(--block-blend);
  will-change: transform;
  transition: background-color 0.18s linear;   /* colour only — never transform */
}
/* Over something interactive the cell takes the accent. */
.ff-cur.is-snapped .ff-block { background: var(--lichen); mix-blend-mode: normal; }
.ff-cur.is-down .ff-block { background: var(--block-bg); mix-blend-mode: var(--block-blend); }

/* Pressing inverts the thing being pressed. No transition: a press is
   instantaneous, and easing it turns a shutter into a fade. */
.ff-invert { filter: invert(1); }

/* Every :hover rule above is written `:is(:hover, .ff-hover)`: motion.js
   puts .ff-hover on the block's target, so a control the keyboard has
   moved the block onto shows its hover state exactly as under the mouse
   (:hover itself follows only the real pointer). Keep new hover rules
   in that form.

   Small controls lean toward the pointer — motion.js writes --mx/--my
   and applies this class only to targets under its size threshold. */
.ff-magnet {
  transform: translate3d(var(--mx, 0), var(--my, 0), 0);
  transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}

@media (prefers-reduced-motion: reduce) {
  /* The cursor stays — it is the pointer, not decoration — and it
     already jumps cell to cell, so there is nothing left in it to still. */
  .ff-block, .ff-magnet { transition: none; }
  .ff-magnet { transform: none; }
}

/* ── The keys (?) ─────────────────────────────────────────────
   keys.js prints the keymap on `?` and takes it down on the next key
   or a click. The card is the console drawer's own idiom — the ground's
   veil with the accent edge — set in the house label, and each line
   prints on in turn (ff-print). Centred, because it is a legend of the
   whole page and not a readout of any one corner. Never intercepts a
   pointer. */
.ff-keys {
  position: fixed;
  inset: 0;
  z-index: 70;                 /* over both lightboxes, under the cursor */
  display: grid;
  place-items: center;
  pointer-events: none;
}
.ff-keys-card {
  display: grid;
  row-gap: 0.32em;
  padding: calc(var(--cell-w) * 1.6) calc(var(--cell-w) * 2.4);
  background: rgb(from var(--ink) r g b / 0.86);
  border-left: 1px solid var(--lichen);
  color: var(--paper-dim);
}
.ff-keys-line {
  display: grid;
  grid-template-columns: 6ch auto;
  column-gap: calc(var(--cell-w) * 2);
  margin: 0;
  white-space: nowrap;
}
.ff-keys-k { color: var(--lichen); }
@media (prefers-reduced-motion: no-preference) {
  .ff-keys-line { --fb: 3px; animation: ff-print 0.5s cubic-bezier(0.5, 0, 0.1, 1) calc(var(--i, 0) * 45ms) both; }
}

/* ── Cameras ──────────────────────────────────────────────────
   The Cameras section on the home page reuses the square folder module
   (.folder-card, .camera-card), so it needs no card rules of its own.
   Below is the single camera page: the CRT product-image visual, the
   specs, the read-only blurb, the links, and the seven latest frames.
   Every colour is a palette token (or a relative colour of one); the CRT
   animates transform/opacity only and settles static under reduced
   motion. */

/* The page is one scrolling panel (it borrows .panel--section's scroller
   and no-scrollbar). Its single child keeps its natural height. */
.panel--camera .panel-inner > * { flex: none; }
.camera {
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.camera-title {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.4rem, calc(var(--svh) * 4.2), 2.6rem);
  line-height: 1.05;
  letter-spacing: -0.01em;
  margin: 0;
  color: var(--paper);
}
.camera-head { margin: 0 0 clamp(16px, calc(var(--svh) * 3), 36px); }

/* CRT beside the written matter; they stack on a narrow column. */
.camera-body {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: clamp(20px, calc(var(--vu) * 4), 52px);
  margin: 0 0 clamp(18px, calc(var(--svh) * 4), 44px);
}
.camera-info {
  flex: 1 1 260px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: clamp(16px, calc(var(--vu) * 3), 30px);
}

/* ── The CRT plinth ───────────────────────────────────────────
   A 2D image can't spin a full turn (edge-on it vanishes), so the plinth
   gives a gentle oscillating tilt of about ±16°. Perspective on the
   container; the tilt, scanlines, fringe, flicker and reflection are all
   overlays that ride the transform. */
.crt {
  flex: 1 1 300px;
  min-width: 0;
  max-width: 460px;
  perspective: 1100px;
  padding: clamp(6px, calc(var(--vu) * 1.5), 18px) 0;
}
.crt-frame {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 3;
  transform-style: preserve-3d;
}
.crt-tilt {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: 6px;
  background: var(--ink-soft);
  box-shadow:
    0 18px 44px rgb(from var(--ink) r g b / 0.55),
    inset 0 0 0 1px var(--hair);
  /* Static resting tilt — this is what reduced motion keeps. */
  transform: rotateY(-8deg) rotateX(3deg);
  transform-origin: 50% 50%;
  backface-visibility: hidden;
}
.crt-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  -webkit-user-select: none;
  user-select: none;
  -webkit-user-drag: none;
}
.crt-img.loaded { opacity: 1; }
.crt-scan,
.crt-fringe,
.crt-glow {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
/* Fine dark lines — the current ground at low alpha, so they read in
   both themes. */
.crt-scan {
  background: repeating-linear-gradient(
    to bottom,
    rgb(from var(--ink) r g b / 0.42) 0 1px,
    transparent 1px 3px);
  opacity: 0.55;
}
/* A two-colour edge split from the accent and the paper token — a
   chromatic-aberration hint kept within the palette (a true red/blue
   split would need raw colour). */
.crt-fringe {
  box-shadow:
    inset 2px 0 7px -3px rgb(from var(--lichen) r g b / 0.55),
    inset -2px 0 7px -3px rgb(from var(--paper) r g b / 0.4);
  mix-blend-mode: screen;
  opacity: 0.6;
}
/* A soft screen vignette. */
.crt-glow {
  background: radial-gradient(
    120% 120% at 50% 32%,
    transparent 55%,
    rgb(from var(--ink) r g b / 0.5) 100%);
}
/* The reflection below the plinth: the same image flipped, blurred, faded
   out downward by a mask (the mask reads alpha only, so the token's hue
   is irrelevant). */
.crt-reflect {
  width: 100%;
  height: clamp(28px, calc(var(--svh) * 12), 84px);
  margin-top: 6px;
  background-size: cover;
  background-position: center top;
  transform: scaleY(-1) rotateX(3deg);
  transform-origin: top center;
  opacity: 0.16;
  filter: blur(3px);
  -webkit-mask-image: linear-gradient(to bottom, var(--ink), transparent);
  mask-image: linear-gradient(to bottom, var(--ink), transparent);
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  .crt-tilt {
    animation: crt-tilt 9s ease-in-out infinite;
    will-change: transform;
  }
  .crt-scan { animation: crt-flicker 4s steps(1, end) infinite; }
}
@keyframes crt-tilt {
  0%   { transform: rotateY(-16deg) rotateX(4deg); }
  50%  { transform: rotateY(16deg) rotateX(-3deg); }
  100% { transform: rotateY(-16deg) rotateX(4deg); }
}
@keyframes crt-flicker {
  0%, 100% { opacity: 0.55; }
  47% { opacity: 0.5; }
  50% { opacity: 0.63; }
  53% { opacity: 0.5; }
}

/* ── Specs, blurb, links ──────────────────────────────────────── */
.camera-specs {
  margin: 0;
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.35em 1.2em;
  font-family: var(--mono);
  font-size: 0.72rem;
  line-height: 1.5;
}
.camera-specs dt {
  margin: 0;
  color: var(--paper-dim);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  font-size: 0.9em;
}
.camera-specs dd {
  margin: 0;
  color: var(--paper);
  letter-spacing: 0.04em;
}
.camera-blurb {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(0.9rem, calc(var(--svh) * 2.1), 1.1rem);
  line-height: 1.5;
  color: var(--paper-soft);
  white-space: pre-wrap;
  max-width: 44ch;
}
.camera-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6em 1.4em;
}
.camera-link {
  font-family: var(--mono);
  font-size: 0.6rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--lichen);
  text-decoration: none;
  border-bottom: 1px solid var(--hair);
  padding-bottom: 2px;
}
.camera-link:is(:hover, .ff-hover) {
  color: var(--paper);
  border-bottom-color: var(--lichen);
}
.camera-link:focus-visible { outline: 2px solid var(--lichen); outline-offset: 3px; }

/* ── The seven latest frames ──────────────────────────────────── */
.camera-frames-head { margin: 0 0 clamp(10px, calc(var(--vu) * 3), 24px); }
.camera-frames {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(clamp(64px, 12vw, 96px), 1fr));
  gap: var(--gap);
  align-content: start;
}
.camera-frame {
  position: relative;
  display: block;
  aspect-ratio: 1 / 1;
  background: var(--ink-soft);
  overflow: hidden;
  line-height: 0;
  text-decoration: none;
}
.camera-frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
}
.camera-frame img.loaded { opacity: 1; }
.camera-frame:focus-visible { outline: 2px solid var(--lichen); outline-offset: 2px; }
@media (prefers-reduced-motion: no-preference) {
  .camera-frame img { transition: opacity 0.6s ease, transform 0.5s ease; }
  .camera-frame:is(:hover, .ff-hover) img { transform: scale(1.04); }
}

/* ── Catalog morph ──────────────────────────────────────────────────
   The logo cycles catalogs in place (app.js cycleSite): the colour tokens
   (--ink/--paper/--lichen) are swapped on :root, so easing background and
   colour makes the palette change glide rather than snap, and the page dims
   briefly while the next catalog's gallery loads. Motion-safe only. */
@media (prefers-reduced-motion: no-preference) {
  body { transition: background-color 0.45s ease, color 0.45s ease, opacity 0.25s ease; }
  body.is-morphing { opacity: 0.45; }
}
