/* ═══════════════════════════════════════════════════════════
   davidbrooksdirector.com
   Off-white paper, near-black ink, the work in color.
   ═══════════════════════════════════════════════════════════ */

/* ─── Fonts (self-hosted, variable, cut by scripts/fonts.py) ─── */
@font-face {
  font-family: 'Figtree';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/fonts/figtree-wght-400-700.woff2') format('woff2-variations');
}
/* Geist Mono in two files: the site only loads "core" (basic Latin,
   Latin-1, dashes and quotes); "rest" (everything else in the font) is only
   fetched if a page shows such a character in mono. Core is declared last
   so it wins where the ranges overlap. The ranges match scripts/fonts.py. */
@font-face {
  font-family: 'Geist Mono';
  font-style: normal;
  font-weight: 400 500;
  font-display: swap;
  src: url('/fonts/geist-mono-rest-wght-400-500.woff2') format('woff2-variations');
  unicode-range: U+0000-001F, U+007F-009F, U+0100-FFFF;
}
@font-face {
  font-family: 'Geist Mono';
  font-style: normal;
  font-weight: 400 500;
  font-display: swap;
  src: url('/fonts/geist-mono-core-wght-400-500.woff2') format('woff2-variations');
  unicode-range: U+0020-007E, U+00A0-00FF, U+2013-2014, U+2018-201D, U+2022, U+2026;
}

/* ─── Tokens ─── */
:root {
  --paper: #f3f1ec;
  --paper-2: #e8e5de;
  --ink: #121211;
  --ink-2: #5d5b56;
  --line: rgba(18, 18, 17, 0.12);
  --night: #0c0c0b;
  --night-ink: #f3f1ec;
  --night-ink-2: #a9a59c;
  /* text selection: lighting-gel tints, ink text on all of them. The JS
     steps --select through the set each time a new selection is started. */
  --select-1: #efd8a2; /* tungsten amber */
  --select-2: #c7d6e6; /* daylight blue, storyboard pencil */
  --select-3: #eccbc4; /* safelight rose */
  --select-4: #cfdac7; /* sage, the green screen turned down */
  --select: var(--select-1);
  /* the home page work bar: a pale lavender at the gel tints' lightness,
     not one of the four (Josh, Oct 1 2026, after sage) */
  --bar: #d6cfe6;
  /* the same four gels at full strength, for text on night: the playing
     spot in the lightbox takes one at random each time it changes */
  --gel-1: #e9b457; /* tungsten amber */
  --gel-2: #86b6e6; /* daylight blue */
  --gel-3: #ee9489; /* safelight rose */
  --gel-4: #8fd4a4; /* green screen */

  --sans: 'Figtree', 'Avenir Next', 'Avenir', 'Helvetica Neue', system-ui, sans-serif;
  --mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;

  --gutter: clamp(20px, 4vw, 64px);
  /* side gutters for text rows: never under the notch / Dynamic Island in landscape */
  --gutter-l: max(var(--gutter), env(safe-area-inset-left));
  --gutter-r: max(var(--gutter), env(safe-area-inset-right));
  --gap: clamp(12px, 1.5vw, 22px);
  /* the grid's one even margin: sides, between tiles, above the first row */
  --grid-gap: clamp(10px, 1vw, 16px);
  --grid-edge-l: max(var(--grid-gap), env(safe-area-inset-left));
  --grid-edge-r: max(var(--grid-gap), env(safe-area-inset-right));
  /* top and bottom inset for the fixed header, the hero bar and the lightbox */
  --edge: clamp(18px, 2.2vw, 32px);
  --max: 1760px;

  --ease-out: cubic-bezier(0.2, 0.7, 0.2, 1);
  --t-fast: 180ms;
  --t-med: 420ms;
  --t-slow: 900ms;
}

/* ─── Reset ─── */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html {
  -webkit-text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;
  scroll-behavior: smooth;
  background: var(--paper);
}
body {
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.5;
  color: var(--ink);
  background: var(--paper);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: clip;
}
body:has(dialog[open]) { overflow: hidden; }
/* Behind the lightbox the paper goes to night, on the scrim's curve. The
   scrim is 94% so the page stays faintly visible, but at 6% the paper
   gutters of the grid still read as pale lines, and on a phone the two
   side margins land either side of the player (Josh, Sep 25 2026: "two
   white vertical lines" on a direct link, which scrolls to the tile first).
   The stills still show faintly; only the paper between them goes.
   Closing (.is-closing, from the start of the flight back) brings the
   paper back with the scrim, 250ms: held until the dialog shut, the
   gutters showed black under the fading scrim and then snapped to paper
   (Josh, Oct 23 2026). A close without the flight snaps back with the
   dialog. */
html:has(dialog.is-closing),
body:has(dialog.is-closing),
body:has(dialog.is-closing) .grid { transition: background-color 250ms var(--ease-out); }
html:has(dialog[open]:not(.is-closing)),
body:has(dialog[open]:not(.is-closing)),
body:has(dialog[open]:not(.is-closing)) .grid {
  background-color: var(--night);
  transition: background-color 340ms var(--ease-out);
}
img, video, svg { display: block; max-width: 100%; }
button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }
::selection { background: var(--select); color: var(--ink); }

/* ─── Type utilities ─── */
.mono {
  font-family: var(--mono);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1.4;
  font-variant-numeric: tabular-nums;
}

/* Glass pill: the hover and focus state of every text control (.ul, the
   class name is historical). A translucent blurred capsule rises behind the
   label: Josh, Sep 2026, in place of a sliding underline that felt cheap.
   This is the site's one use of glass; keep it to controls. The pill is
   drawn by ::after so ::before stays free (the sound cue dot uses it). The
   negative margin keeps the label exactly where it was; the pill overhangs. */
.ul { position: relative; padding: 7px 12px; margin: -7px -12px; border-radius: 999px; isolation: isolate; }
.ul::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: rgba(255, 255, 255, 0.12);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.16),
    inset 0 1px 0 rgba(255, 255, 255, 0.22),
    0 6px 20px rgba(0, 0, 0, 0.12);
  -webkit-backdrop-filter: blur(16px) saturate(160%);
  backdrop-filter: blur(16px) saturate(160%);
  opacity: 0;
  transform: scale(0.9);
  transition: opacity var(--t-med) ease, transform var(--t-med) var(--ease-out);
}
@media (hover: hover) { .ul:hover::after { opacity: 1; transform: none; } }
.ul:focus-visible { outline: none; }
.ul:focus-visible::after { opacity: 1; transform: none; box-shadow: inset 0 0 0 1px currentColor; }
/* On the light bars (the home page work bar, the header over paper) the
   same pill, tuned for a light ground: white over lavender reads as glass,
   a touch of ink over paper (Josh, Oct 1 2026: Contact takes the pill like
   Unmute; the sliding underline it had read as a different control).
   The white-at-12% pill turned gray over paper, hence these. */
.work-bar .ul::after {
  background: rgba(255, 255, 255, 0.5);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.6),
    inset 0 1px 0 rgba(255, 255, 255, 0.85),
    0 4px 14px rgba(18, 18, 17, 0.08);
}
.site-header.on-paper .ul::after {
  background: rgba(18, 18, 17, 0.06);
  box-shadow: inset 0 0 0 1px rgba(18, 18, 17, 0.08);
}

/* ─── Skip link ─── */
.skip-link {
  position: fixed;
  top: 12px;
  left: 12px;
  z-index: 100;
  padding: 10px 14px;
  background: var(--ink);
  color: var(--paper);
  transform: translateY(-200%);
}
.skip-link:focus { transform: none; }

/* ─── Header ─── */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 50;
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  padding: max(var(--edge), env(safe-area-inset-top)) var(--gutter-r) var(--edge) var(--gutter-l);
  color: #fff;
  text-shadow: 0 2px 24px rgba(0, 0, 0, 0.18); /* as .hero-statement */
  pointer-events: none;
  transition: background-color var(--t-med) ease, color var(--t-med) ease;
}
/* Over the reel the name is plain white like the statement (Josh, Oct 23
   2026: it used to blend into the picture with mix-blend difference and
   shifted colour with every frame). Over the grid (and the
   rest of the paper page) it becomes a paper bar with a hairline, so the
   name and Contact never read as part of the thumbnails (David, Sep 2026).
   main.js toggles .on-paper from the same observer as the theme color. */
.site-header.on-paper {
  color: var(--ink);
  text-shadow: none;
  background: var(--paper);
  box-shadow: 0 1px 0 var(--line);
  pointer-events: auto;
}
.site-header a { pointer-events: auto; }
/* Home: a lavender bar right under the reel that scrolls up with it and
   sticks at the top (Josh, Oct 1 2026: something to part the reel from
   the grid, arriving from below the reel rather than dropping in). The
   fixed header above stays over the reel only and steps aside
   (.is-tucked) once the bar is on screen, so there is one at a time.
   Pale lavender (--bar, Josh Oct 1 2026; paper, night and sage were
   tried first), no hairline, the role between name
   and Contact while the bar is under the reel, gone once it sticks over
   the grid (.is-stuck, main.js) (phones: name and Contact only). */
.work-bar {
  position: sticky;
  top: 0;
  z-index: 40;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: baseline;
  gap: 24px;
  padding: max(calc(var(--edge) + 6px), env(safe-area-inset-top)) var(--gutter-r) calc(var(--edge) + 6px) var(--gutter-l);
  color: var(--ink);
  background: var(--bar);
  /* its own layer: Safari can re-composite a sticky element when a
     transform arrives, a one-frame blink on the way out */
  will-change: opacity, transform;
  transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
}
/* Over the grid the bar keeps out of the way while the visitor scrolls
   (.is-away, main.js) and comes back once they have stopped for 2s,
   0.8s at the bottom of the page (Josh, Oct 23 2026): out smoothly (3
   steps read as flicker mid-scroll), back in 6 on twos with a small drop, a judder rather than
   a slide; called back by scrolling up it returns smoothly (.is-called). Keyboard focus brings it back
   (:focus-visible: a clicked link keeps focus and held it there). */
.work-bar.is-away:not(:has(:focus-visible)) {
  opacity: 0;
  transform: translateY(-8px);
  pointer-events: none;
}
.work-bar.is-stuck:not(.is-away) { transition: opacity 500ms steps(6, end), transform 500ms steps(6, end); }
/* scrolling back up calls it back at once: smooth, like the way out */
.work-bar.is-stuck.is-called:not(.is-away) { transition: opacity 250ms var(--ease-out), transform 250ms var(--ease-out); }
.work-bar .brand { justify-self: start; }
.work-bar .site-nav { justify-self: end; }
/* hidden until the hero's own role line has left (.has-role, main.js),
   and again once the bar sticks over the grid */
/* a step darker than --ink-2, which only just passed on lavender (4.5:1, now 6:1) */
.work-bar-role { margin: 0; font-size: 14px; color: #4a4844; opacity: 0; transition: opacity 250ms ease; }
.work-bar.has-role:not(.is-stuck) .work-bar-role { opacity: 1; }
/* 14px is too wide for the bar on small tablets */
@media (max-width: 1023px) { .work-bar-role { font-size: 12px; } }
@media (max-width: 639px) {
  .work-bar { grid-template-columns: 1fr auto; }
  .work-bar-role { display: none; }
}
.site-header.is-tucked { opacity: 0; visibility: hidden; transition: opacity 200ms ease, visibility 0s 200ms; }
.site-header .site-nav, .work-bar .site-nav { font-size: 13px; }
.work-bar .brand { font-weight: 600; font-size: 18px; letter-spacing: 0.005em; }
.site-header .brand { font-weight: 600; font-size: 18px; letter-spacing: 0.005em; opacity: 0; transition: opacity var(--t-med) ease; }
.site-header.is-scrolled .brand { opacity: 1; }
/* once the hero name has eased out (main.js, 1.5s after the reel shows) the
   header holds the name for good. On twos: it arrives in 6 frames, 1.1s
   after the hero name starts to leave, so just after it has gone (Josh,
   Oct 23 2026, was 0.5s, overlapping it) */
.site-header.has-name .brand { opacity: 1; transition: opacity 500ms steps(6, end) 1.1s; }
.site-nav { display: flex; gap: 28px; }
/* touch: a taller hit area for the small mono links, same place on screen */
@media (pointer: coarse) {
  .site-nav a { padding-block: 13px; margin-block: -13px; }
  .site-footer .f-right a { padding-block: 13px; margin-block: -13px; }
}
/* Hidden behind the lightbox, on the scrim's curve, and back with it from
   the start of the flight back (.is-closing). The transition overrides
   their own while the dialog is open (!important: .work-bar.is-stuck's
   stepped return outranks this selector): the work bar's stepped
   away/return used to run under the scrim on open and after the flight on
   close. */
body:has(dialog[open]) :is(.site-header, .work-bar, .hero-bar, .reel-progress, .hero-statement, .hero-content) {
  transition: opacity 250ms var(--ease-out) !important;
}
body:has(dialog[open]:not(.is-closing)) :is(.site-header, .work-bar, .hero-bar, .reel-progress, .hero-statement, .hero-content) { opacity: 0; }

/* ─── Hero: the showreel is the hero, the name sits on top ───
   As tall as the screen with the browser's toolbars put away (lvh): iOS
   Safari draws the page under its floating, see-through toolbar, so a
   100svh hero left a strip of the work bar showing under it on arrival
   (Josh, Oct 1 2026). Whatever the toolbars may cover at the bottom
   (--hero-dock, lvh minus svh, 0 on desktop) lifts the controls, the
   statement and the progress line, so they sit where they can be seen. */
.hero {
  --hero-dock: 0px;
  position: relative;
  height: 100vh;
  height: 100lvh;
  min-height: 520px;
  background: var(--night);
  color: #fff;
  overflow: hidden;
  isolation: isolate;
}
@supports (height: 1lvh) {
  .hero { --hero-dock: calc(100lvh - 100svh); }
}
.reel-frame, .reel-poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
/* The hero opens on night with the name alone. The player is hidden until
   main.js has seen real frames flowing (.is-live), then fades up over 1.8s,
   so Vimeo's own first frame or thumbnail is never shown. It fades up held
   on its first frame and plays once the fade is done (FADE in main.js,
   keep the two in step). The poster only
   appears when the reel cannot autoplay (.needs-click) or fails (.is-error). */
.reel-frame { opacity: 0; transition: opacity 1.8s ease; }
.hero.is-live .reel-frame { opacity: 1; }
/* cover the viewport with a 16:9 iframe, whatever the viewport aspect */
.reel-frame iframe {
  position: absolute;
  top: 50%;
  left: 50%;
  width: max(100%, calc(100vh * 16 / 9));
  width: max(100%, calc(100lvh * 16 / 9));
  height: max(100%, calc(100vw * 9 / 16));
  transform: translate(-50%, -50%);
  border: 0;
}
/* portrait screens play the vertical cut (site.showreel.vimeoPortrait) when
   there is one: same cover rule, 9:16 */
.hero.is-vertical .reel-frame iframe {
  width: max(100%, calc(100vh * 9 / 16));
  width: max(100%, calc(100lvh * 9 / 16));
  height: max(100%, calc(100vw * 16 / 9));
}
.reel-poster {
  object-fit: cover;
  z-index: 2;
  opacity: 0;
  transition: opacity 1.8s ease;
}
.hero.needs-click .reel-poster, .hero.is-error .reel-poster { opacity: 1; }
.hero.is-playing .reel-poster { opacity: 0; pointer-events: none; }
.hero-scrim {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: rgba(0, 0, 0, 0.22);
  pointer-events: none;
}
.hero-content {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: grid;
  place-items: center;
  text-align: center;
  /* centred in what is visible above the toolbars (--hero-dock) */
  padding: 0 var(--gutter-r) var(--hero-dock) var(--gutter-l);
  pointer-events: none;
  /* the hand-off: the name and role leave in 12 frames over a second with a
     small upward drift, stepped like everything else in the hero */
  transition: opacity 1s steps(12, end);
}
.hero.is-named .hero-content { opacity: 0; }
.hero-content > div { transform: translateY(-4vh); transition: transform 1s steps(12, end); }
.hero.is-named .hero-content > div { transform: translateY(-5.5vh); }
.hero h1 {
  font-size: clamp(3rem, 9.5vw, 10.5rem);
  font-weight: 500;
  letter-spacing: -0.04em;
  line-height: 0.92;
  text-wrap: balance;
  text-shadow: 0 2px 24px rgba(0, 0, 0, 0.18);
}
.hero .role {
  margin-top: clamp(16px, 2.4vh, 28px);
  color: rgba(255, 255, 255, 0.82);
}
.hero-bar {
  position: absolute;
  z-index: 4;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: flex-end;
  align-items: flex-end;
  gap: 16px;
  /* lifted off the bottom edge so Unmute does not crowd Contact in the
     work bar right under the reel */
  padding: var(--edge) var(--gutter-r) calc(max(calc(var(--edge) + 18px), env(safe-area-inset-bottom)) + var(--hero-dock)) var(--gutter-l);
  transition: opacity var(--t-med) ease;
}
/* the statement floats above the bottom edge on its own; it does not fade
   with the controls, so the video always carries one line of context */
.hero-statement {
  position: absolute;
  z-index: 4;
  left: var(--gutter-l);
  bottom: calc(clamp(72px, 12vh, 150px) + var(--hero-dock));
  max-width: 30ch;
  font-size: clamp(18px, 1.7vw, 28px);
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: -0.01em;
  text-wrap: balance;
  text-shadow: 0 2px 24px rgba(0, 0, 0, 0.18);
  transition: opacity var(--t-med) ease;
}
/* the sound toggle is the one control on the reel: a speaker icon (Oct 23
   2026, was the word Unmute) in a round glass pill with a 44px hit area.
   Muted it carries a cross; with sound on one wave, or two above half
   volume (data-level, main.js). The waves arrive one frame apart, on twos.
   The bar stays up for as long as the reel is muted; once sound is on it
   can idle away. */
.reel-sound-ctl { position: relative; display: flex; }
.reel-sound { display: block; padding: 11px; margin: -11px; color: #fff; }
.reel-sound svg { display: block; }
.reel-sound .w1, .reel-sound .w2, .reel-sound .x { opacity: 0; }
.reel-sound[data-level="0"] .x { opacity: 1; }
.reel-sound:not([data-level="0"]) .w1 { opacity: 1; }
.reel-sound[data-level="2"] .w2 { opacity: 1; transition: opacity 0s linear 83ms; }
/* Volume slider: pops out to the left of the icon on hover or keyboard
   focus, wiped open in 4 frames on twos (333ms) and shut in 2 (167ms).
   Keyboard focus only (:focus-visible): a clicked icon or dragged slider
   keeps focus, and :focus-within held it open after the pointer left.
   Same glass as the pill beside it. Fine pointers only: touch screens
   keep the volume buttons on the device. */
.reel-volume { display: none; }
@media (hover: hover) and (pointer: fine) {
  .reel-volume {
    display: flex;
    align-items: center;
    position: absolute;
    top: 50%;
    right: calc(100% + 17px);
    height: 44px;
    margin-top: -22px;
    padding: 0 18px;
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.12);
    box-shadow:
      inset 0 0 0 1px rgba(255, 255, 255, 0.16),
      inset 0 1px 0 rgba(255, 255, 255, 0.22),
      0 6px 20px rgba(0, 0, 0, 0.12);
    -webkit-backdrop-filter: blur(16px) saturate(160%);
    backdrop-filter: blur(16px) saturate(160%);
    clip-path: inset(0 0 0 100% round 999px);
    visibility: hidden;
    transition: clip-path 167ms steps(2, end), visibility 0s linear 167ms;
  }
  /* bridges the gap to the icon's pill so the pointer can cross it */
  .reel-volume::after { content: ''; position: absolute; left: 100%; top: 0; bottom: 0; width: 18px; }
  .reel-sound-ctl:hover .reel-volume,
  .reel-sound-ctl:has(:focus-visible) .reel-volume {
    clip-path: inset(0 0 0 0 round 999px);
    visibility: visible;
    transition: clip-path 333ms steps(4, end), visibility 0s;
  }
}
.reel-volume-range {
  --v: 0%;
  -webkit-appearance: none;
  appearance: none;
  display: block;
  width: 96px;
  height: 20px;
  margin: 0;
  background: transparent;
  cursor: pointer;
}
.reel-volume-range:focus-visible { outline: none; }
/* the filled part is solid white: a hard-stop background, not a gradient */
.reel-volume-range::-webkit-slider-runnable-track {
  height: 2px;
  background: linear-gradient(#fff, #fff) 0 0 / var(--v) 100% no-repeat, rgba(255, 255, 255, 0.3);
}
.reel-volume-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 10px;
  height: 10px;
  margin-top: -4px;
  border: 0;
  border-radius: 50%;
  background: #fff;
}
.reel-volume-range::-moz-range-track { height: 2px; background: rgba(255, 255, 255, 0.3); }
.reel-volume-range::-moz-range-progress { height: 2px; background: #fff; }
.reel-volume-range::-moz-range-thumb { width: 10px; height: 10px; border: 0; border-radius: 50%; background: #fff; }
.reel-volume-range:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.35); }
.reel-volume-range:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.35); }
/* sound cue: a dot at the icon's top-right pulses four times once muted
   playback starts, then is gone. main.js adds .is-cued when it has run or
   the control has been used. */
.reel-sound::before {
  content: '';
  position: absolute;
  top: 7px;
  right: 5px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #fff;
  opacity: 0;
}
/* .is-live: not during the hidden start before the intro hold, which
   would cancel it (and animationcancel marks it as seen) */
.hero.is-live.is-playing.is-muted:not(.is-cued) .reel-sound::before {
  animation: cue 1.4s ease-in-out 4;
}
@keyframes cue {
  0%, 100% { opacity: 0; transform: scale(0.5); }
  50% { opacity: 1; transform: none; }
}
@media (hover: hover) {
  .hero.is-playing.is-idle:not(.is-muted) .hero-bar { opacity: 0; }
  .hero.is-playing.is-idle:hover .hero-bar { opacity: 1; }
}
.reel-progress {
  position: absolute;
  z-index: 4;
  left: 0;
  bottom: var(--hero-dock);
  height: 2px;
  width: 100%;
  background: #fff;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 260ms linear, translate 250ms var(--ease-out);
}
/* touch screens, scrolling past the hero: the toolbars fold away and show
   the strip --hero-dock kept clear, so the controls go quickly and the
   progress line drops to the picture's real bottom edge (.is-leaving, from
   main.js) rather than floating over the video with more of it below */
@media (pointer: coarse) {
  .hero.is-leaving :is(.hero-bar, .hero-statement) { opacity: 0; pointer-events: none; transition-duration: 200ms; }
  .hero.is-leaving .reel-progress { translate: 0 var(--hero-dock); }
}
/* autoplay refused: the poster stays and a tap on the hero starts the reel.
   .reel-start is the keyboard / screen-reader path: in the tab order only in
   this state, drawn only while focused. */
.hero.needs-click { cursor: pointer; }
.reel-start {
  position: absolute;
  z-index: 6;
  left: var(--gutter-l);
  bottom: max(var(--edge), env(safe-area-inset-bottom));
  padding: 6px 0;
  opacity: 0;
}
.hero:not(.needs-click) .reel-start { display: none; }
.reel-start:focus-visible { opacity: 1; }
.hero.is-error .reel-controls { display: none; }

/* entrance */
/* Hero sequence, all on twos (12 frames a second, stepped, no easing):
   0.0s  the name is simply there, no entrance
   0.6s  the four disciplines step in one after another (6 frames each,
         420ms apart, the slash arriving with its discipline), done by 2.4s
   2.6s  the reel fades up behind (main.js, .is-live, 1.8s), 200ms after
         the last discipline's animation ends and once Vimeo has frames
   +1.5s after .is-live the name and role leave (main.js, .is-named, 1s),
         so they always get the same beat over the picture
   +1.1s the name arrives in the header, just after the hero's has gone
   +1.6s the statement settles as one block: 6 frames, opacity and a small
         lift, after the name so the two never compete (Josh, Oct 23 2026;
         it came in at 3.2s, under the name). While the reel can play it
         waits (.is-waiting, main.js); with no JS, or a reel that cannot
         play, it keeps its own clock (3.2s, or 0.5s after a late
         fallback). The inner span animates so the dialog rule above can
         still fade the outer block (a fill-mode animation would win). */
.hero .role .d {
  display: inline-block;
  animation: frame 500ms steps(6, end) both;
  animation-delay: calc(600ms + var(--i, 0) * 420ms);
}
@keyframes frame { from { opacity: 0; } to { opacity: 1; } }
.hero-statement span {
  display: block;
  animation: settle 500ms steps(6, end) both;
  animation-delay: 3.2s;
}
.hero.is-waiting:not(.is-named, .needs-click, .is-error) .hero-statement span { animation: none; opacity: 0; }
.hero.is-waiting.is-named .hero-statement span { animation-delay: 1.6s; }
.hero.is-waiting:is(.needs-click, .is-error) .hero-statement span { animation-delay: 0.5s; }
@keyframes settle {
  from { opacity: 0; transform: translateY(0.35em); }
  to { opacity: 1; transform: none; }
}
@keyframes rise {
  from { opacity: 0; transform: translateY(18px); }
  to { opacity: 1; transform: none; }
}
@media (max-width: 767px) {
  .hero-statement { font-size: 18px; max-width: 22ch; bottom: calc(max(76px, 11vh) + var(--hero-dock)); }
}
/* phones: smaller, and down on the sound toggle's row, its last line
   level with the icon's foot (the bar's bottom padding, less the half
   leading and descender under the baseline), clear of the pill */
@media (max-width: 639px) {
  .hero-statement {
    font-size: 16px;
    max-width: min(28ch, calc(100% - var(--gutter-l) - var(--gutter-r) - 64px));
    bottom: calc(max(calc(var(--edge) + 18px), env(safe-area-inset-bottom)) + var(--hero-dock) - 0.3em);
  }
}

/* ─── Work ─── */
/* one even margin: --grid-gap on the sides, between tiles and above the
   first row. Rhythm by position, on a 12-column track. main.js lays the
   rows out (.is-laid, data-slot + inline grid placement): two wide, three
   small, an offset pair (7 + 5 columns), and a feature block (one big tile with two stacked beside it,
   alternating sides), never the same row twice running. A campaign's spots
   (data-group) fill whole rows of their own, including a row of four small.
   The nth-child rules below are the no-JS fallback. */
.grid {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: var(--grid-gap);
  padding: var(--grid-gap) var(--grid-edge-r) 0 var(--grid-edge-l);
  background: var(--paper);
}
/* phone: one column of 4:3 */
.tile { grid-column: span 12; min-width: 0; }
/* no-JS fallback. tablet: one full-width 16:9, then two 4:3 */
@media (min-width: 640px) and (max-width: 1023px) {
  .grid:not(.is-laid) .tile { grid-column: span 6; }
  .grid:not(.is-laid) .tile:nth-child(3n+1) { grid-column: span 12; }
  .grid:not(.is-laid) .tile:nth-child(3n+1) .tile-media { aspect-ratio: 16 / 9; }
}
/* no-JS fallback. desktop: two wide 16:9, then three 4:3 */
@media (min-width: 1024px) {
  .grid:not(.is-laid) .tile { grid-column: span 4; }
  .grid:not(.is-laid) .tile:nth-child(5n+1),
  .grid:not(.is-laid) .tile:nth-child(5n+2) { grid-column: span 6; }
  .grid:not(.is-laid) .tile:nth-child(5n+1) .tile-media,
  .grid:not(.is-laid) .tile:nth-child(5n+2) .tile-media { aspect-ratio: 16 / 9; }
}
/* laid out by main.js: placement is inline, the slot sets the shape.
   wide / stack / full / lead are 16:9, small keeps the tile aspect (4:3),
   big fills its two rows (the stacked pair beside it sets the height) and
   side fills the row its lead sets. */
@media (min-width: 640px) {
  .grid.is-laid .tile[data-slot="wide"] .tile-media,
  .grid.is-laid .tile[data-slot="stack"] .tile-media,
  .grid.is-laid .tile[data-slot="full"] .tile-media,
  .grid.is-laid .tile[data-slot="lead"] .tile-media { aspect-ratio: 16 / 9; }
  .grid.is-laid .tile[data-slot="big"] .tile-media,
  .grid.is-laid .tile[data-slot="side"] .tile-media { aspect-ratio: auto; height: 100%; }
}

.tile-media {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: var(--ar, 16 / 9);
  background: var(--paper-2);
  overflow: hidden;
  cursor: pointer;
  color: #fff;
}
.tile-media img, .tile-media video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.tile-media img {
  /* the hover scale moves in 17 steps over 1.4s (about 12 a second): the
     slight judder of a camera move on a stop-motion set, not a smooth zoom */
  transition: transform 1.4s steps(17, end), opacity var(--t-med) ease, filter 250ms ease;
}
.tile-media img.is-loading { opacity: 0; }
.tile-media video {
  opacity: 0;
  transition: opacity var(--t-med) ease, filter 250ms ease;
  pointer-events: none;
}
.tile.is-previewing .tile-media video { opacity: 1; }
/* the stills rest a touch dim and come up past full brightness when the
   loop plays (Josh, Sep 30 2026: a scrim over the playing loop made it
   look muddy; Oct 1 2026: 16% read as dark, so 7% at rest and a small
   lift on top). Hover on a mouse, the loop starting on touch. */
.tile-media::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: rgba(0, 0, 0, 0.07);
  transition: opacity 250ms ease;
  pointer-events: none;
}
.tile.is-previewing .tile-media::after { opacity: 0; }
.tile.is-previewing .tile-media img, .tile.is-previewing .tile-media video { filter: brightness(1.06) contrast(1.04); }

/* 1px hairline along the bottom edge while a video loop plays (main.js sets the scale) */
.tile-progress {
  position: absolute;
  z-index: 3;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background: #fff;
  transform: scaleX(0);
  transform-origin: left;
  opacity: 0;
  transition: transform 260ms linear, opacity var(--t-med) ease;
  pointer-events: none;
}
.tile.is-previewing .tile-progress { opacity: 1; }

/* entrance: main.js adds .is-entering, then .is-in per tile as it scrolls
   into view, with --i counting the tiles that arrived in the same batch */
.grid.is-entering .tile { opacity: 0; }
.grid.is-entering .tile.is-in {
  opacity: 1;
  /* 400ms (Sep 25 2026, was 640): a fast scroll never meets half-faded tiles */
  transition: opacity 400ms ease calc(var(--i, 0) * 40ms);
}

/* hover reveals only the client name, no scrim (the tile brightens
   instead); a soft shadow keeps it legible on a pale frame */
.tile-label {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: grid;
  place-items: center;
  padding: 24px;
  text-align: center;
  text-shadow: 0 0 28px rgba(0, 0, 0, 0.55), 0 1px 3px rgba(0, 0, 0, 0.4);
  opacity: 0;
  /* quick (Sep 25 2026, was 420ms fade / 900ms drift): sweeping the mouse
     across the grid, the name has settled before you move on */
  transition: opacity 250ms ease;
}
.tile-label span {
  font-size: clamp(1.25rem, 2vw, 2rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.1;
  text-wrap: balance;
  transform: translateY(6px);
  transition: transform 400ms var(--ease-out);
}
@media (hover: hover) {
  .tile:hover .tile-media img { transform: scale(1.025); }
  .tile:hover .tile-media::after { opacity: 0; }
  .tile:hover .tile-media img, .tile:hover .tile-media video { filter: brightness(1.06) contrast(1.04); }
  .tile:hover .tile-label, .tile:focus-within .tile-label { opacity: 1; }
  .tile:hover .tile-label span, .tile:focus-within .tile-label span { transform: none; }
}
/* touch: no hover, so the name sits quietly in the corner */
@media (hover: none) {
  .tile-label {
    place-items: end start;
    padding: 14px 16px;
    text-align: left;
    background: linear-gradient(to top, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0) 60%);
    text-shadow: none;
    opacity: 1;
  }
  .tile-label span { font-size: 15px; letter-spacing: 0; transform: none; }
}

/* ─── Lightbox ─── */
dialog.lb {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100dvh;
  max-width: none;
  max-height: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: rgba(12, 12, 11, 0.94);
  color: var(--night-ink);
  /* short screens (a phone on its side) scroll the info below the player */
  overflow-y: auto;
  overscroll-behavior: contain;
}
dialog.lb::backdrop { background: transparent; }
/* opened without a tile (a shared link, Back): a quick smooth fade and
   rise, on the same 340ms curve as the tile flight (Josh, Sep 25 2026:
   the six-frame projector flicker made shared links feel slow) */
dialog.lb[open] { animation: lb-in 340ms var(--ease-out); }
@keyframes lb-in { from { opacity: 0; } }

/* From a tile (Josh, Sep 25 2026): the clicked still (.lb-ghost, set up by
   main.js) grows smoothly from its place in the grid into the player frame
   (340ms, ease-out) while only the scrim darkens; the column is simply
   there once it lands and the ghost hands over to the player at once.
   Closing runs it back into the tile on the same curve in 250ms.
   .from-tile stays on until the dialog closes (see main.js). */
dialog.lb.from-tile[open] { animation: lb-scrim 340ms var(--ease-out); }
@keyframes lb-scrim { from { background-color: rgba(12, 12, 11, 0); } }
.lb.is-flying .lb-inner { opacity: 0; }
.lb.from-tile .lb-player { animation: none; }
.lb-ghost {
  position: fixed;
  z-index: 2;
  background: #000 center / cover no-repeat;
  pointer-events: none;
}

/* one column exactly as wide as the player: Close sits on the player's
   top-right corner, the spot strip and the info hug its bottom edge.
   --lb-w is the player width, capped so the whole column fits the
   height. A campaign (.has-spots) also gives up a margin either side for
   the skip buttons and a band of height for the spot strip. */
.lb-inner {
  --lb-side: 0px;
  --lb-strip: 0px;
  --lb-w: min(calc(100% - 2 * var(--lb-side)), calc((100dvh - 230px - var(--lb-strip)) * 16 / 9));
  height: 100%;
  display: grid;
  grid-template-columns: var(--lb-w);
  align-content: center;
  justify-content: center;
  gap: 12px;
  padding: max(var(--edge), env(safe-area-inset-top)) var(--gutter-r) max(var(--edge), env(safe-area-inset-bottom)) var(--gutter-l);
  max-width: var(--max);
  margin-inline: auto;
}
.has-spots .lb-inner { --lb-side: 76px; --lb-strip: 76px; }
/* .lb-inner takes focus when the dialog opens (autofocus, tabindex -1) so
   keyboard and screen-reader users land inside it. It is a container, not
   a control, so it draws no ring: opened from a link on a fresh page load
   Chrome counts that focus as keyboard focus and drew its ring round the
   whole column, two white lines down the screen either side of the player
   (Josh, Sep 25 2026). The controls inside keep their own focus styles. */
.lb-inner:focus { outline: none; }
.lb-top {
  display: flex;
  justify-content: flex-end;
}

.lb-body {
  display: grid;
  gap: 18px;
  transition: opacity 160ms ease;
}
.lb-body.is-switching { opacity: 0; }
.lb-stage { position: relative; }
.lb-player {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
  animation: lb-up 340ms var(--ease-out);
}
@keyframes lb-up {
  from { opacity: 0; transform: translateY(14px); }
}
.lb-player iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}
/* Skip back / forward: campaigns only, centred on the player's side edges.
   A hairline ring at rest so they read as buttons; the glass pill (.ul)
   fills them on hover. */
.lb-skip.ul {
  position: absolute;
  top: 50%;
  width: 52px;
  height: 52px;
  margin: -26px 0 0;
  padding: 0;
  display: grid;
  place-items: center;
  border: 1px solid rgba(243, 241, 236, 0.28);
  transition: border-color var(--t-fast) ease;
}
.lb-skip[hidden] { display: none; }
.lb-prev { right: calc(100% + 24px); }
.lb-next { left: calc(100% + 24px); }
@media (hover: hover) { .lb-skip:hover { border-color: rgba(243, 241, 236, 0.5); } }
.lb-skip:active svg { transform: scale(0.9); }

/* Spots as a strip of equal cells under the player, like chapters: number
   over title, a hairline on top. The playing spot is lit in one of the
   four gels (--gel-1..4), picked at random by main.js, its rule too. */
.lb-spots {
  --spot-on: var(--gel-4);
  display: grid;
  grid-template-columns: repeat(var(--lb-spots, 1), minmax(0, 1fr));
  gap: 0 20px;
}
.lb-spots[hidden] { display: none; }
.lb-spots button {
  display: grid;
  gap: 4px;
  align-content: start;
  padding-top: 10px;
  text-align: left;
  font-size: 14px;
  line-height: 1.3;
  color: var(--night-ink-2);
  box-shadow: inset 0 1px 0 rgba(243, 241, 236, 0.16);
  transition: color var(--t-fast) ease, box-shadow var(--t-fast) ease;
}
.lb-spots button span:last-child { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.lb-spots button[aria-pressed="true"] { color: var(--spot-on); box-shadow: inset 0 2px 0 var(--spot-on); }
@media (hover: hover) { .lb-spots button:not([aria-pressed="true"]):hover { color: var(--night-ink); box-shadow: inset 0 1px 0 rgba(243, 241, 236, 0.45); } }
.lb-spots button:focus-visible { outline: 1px solid currentColor; outline-offset: 4px; }

.lb-title { font-size: 22px; font-weight: 500; letter-spacing: -0.015em; line-height: 1.25; }
/* labeled fields, always Client, Format in that order: a dim mono
   label over the value, like a credit block */
.lb-label { color: var(--night-ink-2); margin-bottom: 4px; }
.lb-fields { display: flex; flex-wrap: wrap; gap: 8px 32px; margin: 12px 0 0; }
.lb-fields:empty { display: none; }
.lb-fields dd { margin: 0; font-size: 14px; line-height: 1.35; }
/* Phones and portrait tablets (an iPad upright has height to spare, and a
   player between two skip buttons would float small in it). Not phones on
   their side: see the short-screen block below. */
@media (max-width: 767px) and (min-height: 501px), (max-width: 1023px) and (orientation: portrait) {
  /* player pinned under the top bar so it sits in the same place for every
     spot; the skip buttons drop to a row right under it, then the title,
     and the strip becomes a numbered list, one spot per line */
  .lb-inner, .has-spots .lb-inner { --lb-w: 100%; align-content: start; padding-top: max(calc(var(--edge) + 6vh), env(safe-area-inset-top)); }
  .lb-stage { display: flex; flex-wrap: wrap; justify-content: space-between; row-gap: 14px; }
  .lb-player { flex: 0 0 100%; }
  .lb-skip.ul, .lb-prev, .lb-next { position: relative; inset: auto; }
  .lb-skip.ul { margin: 0; width: 48px; height: 48px; }
  .lb-info { order: 1; }
  .lb-spots { order: 2; grid-template-columns: 1fr; }
  /* in a list the rule goes under each row (Josh, Sep 23 2026: a rule on
     top read as the row above's underline), so the lit one underlines
     the playing spot */
  .lb-spots button { grid-template-columns: 2ch 1fr; align-items: baseline; gap: 12px; padding: 10px 0; box-shadow: inset 0 -1px 0 rgba(243, 241, 236, 0.16); }
  .lb-spots button[aria-pressed="true"] { box-shadow: inset 0 -2px 0 var(--spot-on); }
}

/* ─── Contact: headline, email, blurb. The email is the goal of the page ─── */
.contact {
  padding: clamp(120px, 22vh, 260px) var(--gutter-r) clamp(64px, 12vh, 140px) var(--gutter-l);
}
.contact h2 {
  font-size: clamp(2rem, 5vw, 5rem);
  font-weight: 500;
  letter-spacing: -0.04em;
  line-height: 0.95;
  text-wrap: balance;
  max-width: 12ch;
}
.contact-row { margin-top: clamp(32px, 6vh, 72px); }
.contact-email {
  display: inline-block;
  font-size: clamp(1.625rem, 3.6vw, 3.5rem);
  font-weight: 500;
  letter-spacing: -0.025em;
  line-height: 1.1;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.18em;
  text-decoration-color: var(--line);
  transition: text-decoration-color var(--t-med) ease;
  overflow-wrap: anywhere;
}
@media (hover: hover) { .contact-email:hover { text-decoration-color: var(--ink); } }
/* the blurb, under the email: two short paragraphs at reading size */
.about {
  margin-top: clamp(48px, 9vh, 112px);
  max-width: 52ch;
  font-size: clamp(1.0625rem, 1.35vw, 1.3125rem);
  line-height: 1.5;
  text-wrap: pretty;
}
.about p + p { margin-top: 1em; }
/* ─── Footer ─── */
.site-footer {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: end;
  gap: 12px var(--gap);
  padding: 28px var(--gutter-r) max(36px, env(safe-area-inset-bottom)) var(--gutter-l);
  border-top: 1px solid var(--line);
  color: var(--ink-2);
}
.site-footer .f-right { display: flex; gap: 24px; justify-content: flex-end; }
@media (hover: hover) { .site-footer a:hover { color: var(--ink); } }
/* phones: name, city and the socials each on their own line, all on the
   same left edge as every other text row */
@media (max-width: 639px) {
  .site-footer { grid-template-columns: 1fr; gap: 20px; }
  .site-footer .f-left > span { display: block; }
  .site-footer .f-dot { display: none; }
  .site-footer .f-right { justify-content: flex-start; }
}

/* ─── 404: one still from the work, full bleed, composed like the hero ─── */
.notfound {
  position: relative;
  height: 100vh;
  height: 100svh;
  min-height: 520px;
  background: var(--night);
  color: #fff;
  overflow: hidden;
  isolation: isolate;
}
.notfound-still {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.notfound-scrim {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: rgba(0, 0, 0, 0.32);
  pointer-events: none;
}
.notfound-content {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: grid;
  place-items: center;
  text-align: center;
  padding: 0 var(--gutter-r) 0 var(--gutter-l);
}
.notfound-content > div { transform: translateY(-4vh); }
.notfound h1 {
  font-size: clamp(3rem, 9.5vw, 10.5rem);
  font-weight: 500;
  letter-spacing: -0.04em;
  line-height: 0.92;
  text-wrap: balance;
  text-shadow: 0 2px 24px rgba(0, 0, 0, 0.18);
}
.notfound-content p { margin-top: clamp(16px, 2.4vh, 28px); }
.notfound-bar {
  position: absolute;
  z-index: 4;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 16px;
  padding: var(--edge) var(--gutter-r) max(var(--edge), env(safe-area-inset-bottom)) var(--gutter-l);
  color: rgba(255, 255, 255, 0.85);
}
.notfound h1, .notfound-content p { animation: rise var(--t-slow) var(--ease-out) both; }
.notfound-content p { animation-delay: 120ms; }

/* ─── Short screens: a phone on its side (Safari leaves 320-430px).
   After the hero, lightbox and 404 rules so it wins over them. ─── */
@media (orientation: landscape) and (max-height: 500px) {
  /* the hero and the 404 fit the screen: no 520px floor, the name a touch
     higher, and the statement down on the Unmute row (left, Unmute right)
     so the two never meet */
  .hero, .notfound { min-height: 0; }
  .hero-content > div { transform: translateY(-6vh); }
  .hero.is-named .hero-content > div { transform: translateY(-7.5vh); }
  .hero h1, .notfound h1 { font-size: min(9.5vw, 24vh); }
  .hero-statement {
    bottom: calc(max(var(--edge), env(safe-area-inset-bottom)) + var(--hero-dock));
    max-width: 40ch;
    font-size: 16px;
  }
  /* the player as tall as the screen allows under Close, the skip buttons
     either side; the spot strip and credits scroll up from below */
  .lb-inner, .has-spots .lb-inner {
    --lb-w: min(calc(100% - 2 * var(--lb-side)), calc((100dvh - 2 * var(--edge) - 40px) * 16 / 9));
    height: auto;
    min-height: 100%;
    align-content: start;
    gap: 8px;
  }
}

/* ─── Scroll reveals ─── */
.reveal {
  opacity: 0;
  /* 500ms and 8px (Sep 25 2026, was 900ms / 14px): quick enough that a
     fast scroll to the email never waits on it */
  transform: translateY(8px);
  transition: opacity 500ms ease, transform 500ms var(--ease-out);
}
.reveal.is-in { opacity: 1; transform: none; }

/* ─── Reduced motion ─── */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-delay: 0ms !important;
    transition-duration: 0.01ms !important;
  }
  .reveal { opacity: 1; transform: none; }
  .tile-media img { transform: none !important; }
  .reel-sound .w2 { transition-delay: 0s !important; }
}
