/* ============================================================================
   The site stylesheet.

   Served verbatim from public/, not bundled by Astro: its CSS pipeline rewrites
   `backdrop-filter` to the `-webkit-` form and drops the standard property,
   which no browser but Safari implements (DECISIONS 25).

   The page is the set of /lab specimens that survived, assembled. The codes
   below (D-07, C-01, …) name the specimen each part came from; the lab itself
   lives on the `lab` branch.

     Register     Act IV — Halo. White accent, no teal, bloom at 1.9x.
     Background   scan transition, changing per section
     Progress     J-02 Span
     Entrances    G-02 Bloom at quarter speed — 1120ms come-into-focus
     Landing      the bloom: plate, name, copy, instruments, once on arrival
     Hover        G-09 Glow
     Backdrop     F-01 Sweep, F-05 Pulse, F-06 Ticker
     Title card   D-07 Stencil
     Section head D-01 Rail
     Work         C-01 Counter, with the first design's wheel snapping
     Vessels      A-01 Plate and A-09 Bracket, the bracket's arms out of sync

   Everything the lab learned the hard way is kept: no clip-path animation, no
   transform driven from a variable on :root, no backdrop-filter it does not
   need.
   ============================================================================ */

@font-face {
  font-family: 'Sk Modernist';
  src: url('/fonts/sk-modernist-regular.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
}

:root {
  /* --- Act IV — Halo. The accent is white and reads as an accent because it
         is the brightest thing here, not because it is a different hue. The
         ink steps down to leave it room. --- */
  --void:      oklch(0.13 0.028 251);
  --deep:      oklch(0.19 0.034 248);
  --mid:       oklch(0.68 0.115 218);
  --signal:    oklch(1 0.014 220);
  --ink:       oklch(0.95 0.008 220);
  --ink-dim:   oklch(0.78 0.014 222);
  --ink-faint: oklch(0.66 0.016 224);

  --edge-hue: oklch(1 0.008 220);
  --edge-1: color-mix(in oklch, var(--edge-hue) 62%, transparent);
  --edge-2: color-mix(in oklch, var(--edge-hue) 38%, transparent);
  --edge-3: color-mix(in oklch, var(--edge-hue) 22%, transparent);
  --edge-4: color-mix(in oklch, var(--edge-hue) 12%, transparent);
  --edge-5: color-mix(in oklch, var(--edge-hue) 6%,  transparent);

  /* Bloom carries the accent, so it is a token rather than a constant. */
  --bloom: 1.9;
  --bloom-1: 0 0 calc(8px * var(--bloom)) color-mix(in oklch, white 20%, transparent),
             0 0 calc(22px * var(--bloom)) color-mix(in oklch, var(--mid) 34%, transparent);
  --bloom-2: 0 0 calc(12px * var(--bloom)) color-mix(in oklch, white 30%, transparent),
             0 0 calc(40px * var(--bloom)) color-mix(in oklch, var(--mid) 46%, transparent);
  --bloom-sig: 0 0 calc(10px * var(--bloom)) color-mix(in oklch, var(--signal) 55%, transparent);

  --font-sans: 'Archivo', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  --font-mono: 'Sk Modernist', 'Helvetica Neue', Helvetica, Arial, sans-serif;

  --t-display: clamp(2.5rem, 7vw, 5.5rem);
  --t-title:   clamp(1.75rem, 3.2vw, 3rem);
  --t-body:    1.125rem;
  --t-micro:   1.0625rem;
  --t-nano:    0.9375rem;

  --s1: 8px;  --s2: 16px; --s3: 24px; --s4: 32px;
  --s6: 48px; --s8: 64px; --s12: 96px;
  /* Air between parts. A flat 96px reads cramped at 1440 and airless at 2560;
     this breathes with the window it is breathing in. It is also what gives
     the last boundary enough document underneath it to finish its wipe. */
  --gap-section: clamp(128px, 19vh, 260px);
  --gap-block: clamp(40px, 6vh, 72px);
  --gutter: clamp(20px, 4vw, 64px);
  --measure: 1680px;

  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-io:  cubic-bezier(0.77, 0, 0.175, 1);
  /* easeOutCubic. The landing only: stretched over two seconds, --ease-out
     spends nearly all of its change in the first 400ms, and a name that is
     there that quickly has not appeared, it has popped. */
  --ease-bloom: cubic-bezier(0.33, 1, 0.68, 1);
  --dur-press: 140ms;
  --dur-hover: 160ms;
  /* G-02 at quarter speed. 280ms is the primitive; this page wants it read as
     an event rather than as a response, so it runs at 4x its length. */
  --dur-enter: 1120ms;

  --px: 0; --py: 0;
  color-scheme: dark;
}

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body {
  margin: 0; min-height: 100svh;
  background: var(--void); color: var(--ink);
  font-family: var(--font-sans); font-size: var(--t-body); line-height: 1.6;
  overflow-x: hidden; -webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, p, figure, ul, ol, dl, dd { margin: 0; }
ul, ol { padding: 0; list-style: none; }
img { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
:focus-visible { outline: 1px solid var(--signal); outline-offset: 3px; }

.shell { width: 100%; max-width: var(--measure); margin-inline: auto; padding-inline: var(--gutter); }
.mono {
  font-family: var(--font-mono); font-size: var(--t-micro);
  letter-spacing: 0.12em; text-transform: uppercase; line-height: 1.4; color: var(--ink-dim);
}
.mono--nano { font-size: var(--t-nano); letter-spacing: 0.16em; }
.mono--faint { color: var(--ink-faint); }
.mono--sig { color: var(--signal); text-shadow: var(--bloom-sig); }
/* The halo under light text. A dark glow in the page's own navy: over the
   dark parts of a plate it is the same colour as what is behind it and
   disappears, over a blown-out patch it is the only thing there that is dark,
   so it shows exactly where the contrast runs out and nowhere else.

   Set on `main` so it inherits into every block of copy. Anything with its own
   `text-shadow` (the blooming titles, the signal text) replaces it, which is
   the point: those are bright on purpose. The stencil is a mask, not text. */
main {
  text-shadow:
    0 0 2px  color-mix(in oklch, var(--void) 55%, transparent),
    0 0 10px color-mix(in oklch, var(--void) 70%, transparent),
    0 0 26px color-mix(in oklch, var(--void) 45%, transparent);
}
.stencil { text-shadow: none; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ============================================================================
   BACKGROUND STACK
   ============================================================================ */

/* ============================================================================
   THE BACKGROUND — scrubbed by scroll, not triggered by it

   The first version fired a timed 760ms wipe when a section crossed the middle
   of the window. That model cannot be made correct, and no amount of
   retargeting fixes it: the animation owns a clock the reader does not, so the
   moment they scroll back mid-wipe the two disagree. What that produced was a
   wipe running one way while the page ran the other, and a boundary that had
   already "happened" refusing to happen again on the way back.

   Here the wipe *is* the scroll. Each boundary between two parts of the page
   is a one-pixel marker carrying a named view timeline, and the plate that
   arrives after it is animated on that timeline rather than on a duration. So:

     scroll down through a boundary   the wipe advances
     scroll up                        it retreats, frame for frame
     stop halfway                     it stays halfway
     turn round halfway              it goes back the way it came

   There is nothing to interrupt, so interruption cannot glitch. Direction is
   not tracked because direction is not a decision — the reader is the clock.
   It also deleted about a hundred and twenty lines of JavaScript: no slots, no
   queue, no reverse, no hurry, no observer.
   ============================================================================ */

body { timeline-scope: --b1, --b2, --b3, --cover, --coda; }

/* One-pixel markers sitting between the sections. They are the timeline; they
   paint nothing. */
.bound { display: block; height: 1px; view-timeline-axis: block; }
.bound--1 { view-timeline-name: --b1; }
.bound--2 { view-timeline-name: --b2; }
.bound--3 { view-timeline-name: --b3; }

.atm {
  position: fixed; inset: -7%; z-index: -4; pointer-events: none;
  /* No `filter`. The grade — contrast, saturate, hue-rotate, brightness, and
     each plate's exposure trim — is baked into the plates by
     scripts/bake-plates.sh. Live, it was a full-viewport colour pass re-run on
     every scroll frame: 33ms a frame in the reel with it, 16.7ms without, and
     the reel is the one place on the page whose scroll comes from script, so
     nothing was hiding those frames. Pixel-diffed against the live version at
     three scroll positions the bake lands within a mean of 1.4/255 — WebP
     noise. */
}

/* The plates, stacked in reading order: each one is revealed over the one
   before it, so only the arriving plate ever has to move. */
.atm__p { position: absolute; inset: 0; overflow: hidden; }
.atm__p img {
  width: 100%; height: 100%; object-fit: cover; object-position: center 45%;
}

/* Resting state: every plate above the base sits one screen-height out of
   frame, with its image counter-offset by the same amount. That pairing is the
   whole wipe — the box moves and the picture inside it does not, so the edge
   sweeps across a photograph that never slides.

   Transforms, never `clip-path`: Chromium does not composite clip-path, and
   repainting the window behind a filtered layer on every frame is the most
   expensive mistake available here. */
.atm__p--1, .atm__p--2, .atm__p--3 { transform: translate3d(0, -100%, 0); }
.atm__p--1 img, .atm__p--2 img, .atm__p--3 img { transform: translate3d(0, 100%, 0); }

.atm::after {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(128% 100% at 50% 42%, transparent 0 55%,
    color-mix(in oklch, var(--void) 40%, transparent) 100%);
}

/* The bar riding the edge. A naked hard edge sweeping a photograph reads as a
   rendering fault; this is what makes it read as an instrument. One per
   boundary, on the same timeline as the plate it accompanies. */
.tx-bar {
  position: fixed; left: 0; right: 0; height: 96px; z-index: -1;
  pointer-events: none; opacity: 0;
  background: linear-gradient(to bottom, transparent,
    color-mix(in oklch, var(--mid) 30%, transparent) 62%,
    color-mix(in oklch, white 78%, transparent) 96%,
    color-mix(in oklch, white 92%, transparent) 100%);
}

@supports (timeline-scope: --a) {
  @media (prefers-reduced-motion: no-preference) {
    /* How much scrolling the wipe takes, as a band centred on the moment the
       boundary crosses the middle of the window. `40% … 60%` is 20vh — about
       180px on a laptop, or two notches of a wheel.
       Widen the band to slow it down, narrow it to speed it up; keep it
       centred on 50% so the change still lands with the boundary. Below about
       12vh the edge stops reading as a sweep and starts reading as a cut,
       which is a different transition with its own name. */
    /* Longhands throughout, deliberately. The `animation` shorthand resets
       `animation-timeline` to `auto`, so a shorthand written after the
       timeline silently detaches the animation and hands it back to the
       document clock — where, with `both`, it runs to completion on load and
       every plate is simply already there. It is invisible in the source and
       the only symptom is that nothing scrubs. */
    .atm__p--1, .atm__p--2, .atm__p--3,
    .atm__p--1 img, .atm__p--2 img, .atm__p--3 img,
    .tx-bar--1, .tx-bar--2, .tx-bar--3 {
      --wipe-in: 40%;
      --wipe-out: 60%;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-duration: auto;
      animation-range: cover var(--wipe-in) cover var(--wipe-out);
    }

    .atm__p--1, .atm__p--2, .atm__p--3 { animation-name: scan-box; }
    .atm__p--1 img, .atm__p--2 img, .atm__p--3 img { animation-name: scan-img; }
    .tx-bar--1, .tx-bar--2, .tx-bar--3 { animation-name: scan-bar; }

    .atm__p--1, .atm__p--1 img, .tx-bar--1 { animation-timeline: --b1; }
    .atm__p--2, .atm__p--2 img, .tx-bar--2 { animation-timeline: --b2; }
    .atm__p--3, .atm__p--3 img, .tx-bar--3 { animation-timeline: --b3; }

    @keyframes scan-box { from { transform: translate3d(0, -100%, 0); } to { transform: none; } }
    @keyframes scan-img { from { transform: translate3d(0,  100%, 0); } to { transform: none; } }
    @keyframes scan-bar {
      0%   { opacity: 0; transform: translate3d(0, -96px, 0); }
      10%, 90% { opacity: 1; }
      100% { opacity: 0; transform: translate3d(0, 100vh, 0); }
    }
  }
}

/* Without scroll-driven animation — or with motion turned down — the plates
   cross-fade in place instead of wiping. `.is-on` is set by the one observer
   the script keeps for this case, and only for this case. */
@supports not (timeline-scope: --a) {
  .atm__p--1, .atm__p--2, .atm__p--3 {
    transform: none; opacity: 0; transition: opacity 520ms var(--ease-io);
  }
  .atm__p--1 img, .atm__p--2 img, .atm__p--3 img { transform: none; }
  .atm__p.is-on { opacity: 1; }
  .tx-bar { display: none; }
}

.mesh {
  position: fixed; inset: -2%; z-index: -3; pointer-events: none;
  background-image:
    repeating-linear-gradient(to right,  var(--edge-5) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(to bottom, var(--edge-5) 0 1px, transparent 1px 56px);
}
.cols { position: fixed; inset: 0; z-index: -2; pointer-events: none; display: flex; justify-content: center; }
.cols > div {
  width: 100%; max-width: var(--measure); padding-inline: var(--gutter);
  background-origin: content-box; background-clip: content-box;
  background-image:
    linear-gradient(to left, var(--edge-4) 0 1px, transparent 1px),
    repeating-linear-gradient(to right, var(--edge-4) 0 1px, transparent 1px calc(100% / 6));
}
/* TRIAL: the grid switched off, to judge the page without it. Delete this
   one rule to bring both layers back. */
.mesh, .cols { display: none; }
@media (min-width: 900px) {
  .cols > div { background-image:
    linear-gradient(to left, var(--edge-4) 0 1px, transparent 1px),
    repeating-linear-gradient(to right, var(--edge-4) 0 1px, transparent 1px calc(100% / 12)); }
}
.grain {
  position: fixed; inset: 0; z-index: 95; pointer-events: none;
  background-image: url('/grain.png'); background-size: 180px 180px;
  mix-blend-mode: overlay; opacity: 0.075;
}

/* ============================================================================
   BACKDROP — F-01 Sweep, F-05 Pulse, F-06 Ticker
   Compositor only, every one of them. Three is the number that reads as a
   room being instrumented; the fourth starts reading as decoration.
   ============================================================================ */

.bd { position: fixed; inset: 0; z-index: -1; pointer-events: none; }
.bd span, .bd i { position: absolute; background: var(--edge-3); box-shadow: var(--bloom-1); }
.bd .h { height: 1px; } .bd .v { width: 1px; }

/* F-01 — the HUD arms retract and redraw, each on its own offset. */
.bd--sweep .h, .bd--sweep .v { transform-origin: left center; }
.bd--sweep .h.r, .bd--sweep .v.b { transform-origin: right bottom; }
.bd--sweep .h { animation: sweep-x 9s var(--ease-io) infinite; }
.bd--sweep .v { animation: sweep-y 11s var(--ease-io) infinite; }
@keyframes sweep-x { 0%,8% { transform: scaleX(0); } 38%,62% { transform: scaleX(1); } 92%,100% { transform: scaleX(0); } }
@keyframes sweep-y { 0%,10% { transform: scaleY(0); } 40%,64% { transform: scaleY(1); } 94%,100% { transform: scaleY(0); } }

/* F-06 — a monospace crawl up the left gutter, the one column no line of text
   occupies. Two copies at twice the height, translated by exactly half: no
   seam anywhere in the loop. */
.bd--ticker { overflow: hidden; }
.bd--ticker__rail {
  position: absolute; left: max(8px, calc(var(--gutter) / 2 - 7px)); top: 0;
  display: grid; animation: ticker 44s linear infinite;
}
/* Each copy is as long as its text and never shorter than the window, so the
   loop has no gap on a tall screen and no clipped tail on a short one. */
.bd--ticker__rail p {
  min-height: 100svh;
  writing-mode: vertical-rl; font-family: var(--font-mono); font-size: var(--t-nano);
  letter-spacing: 0.3em; text-transform: uppercase; color: var(--edge-3);
  white-space: pre;
}
@keyframes ticker { to { transform: translate3d(0, -50%, 0); } }

/* ============================================================================
   PROGRESS — J-02 Span
   One hairline drawn across the head of the window as the document goes by,
   with a reticle riding its leading end. Driven by `animation-timeline:
   scroll(root block)`: no listener, no observer, and exact during a fling
   where a rAF handler would be a frame behind.
   ============================================================================ */

.pg { position: fixed; inset: 0; z-index: -1; pointer-events: none; }
.pg__line {
  position: absolute; top: 74px; left: 0; right: 0; height: 1px;
  /* The bright end *is* the marker now. With no reticle riding it, the ramp
     from a faint edge up to a blooming signal is what says which way the line
     is travelling and how far it has got. */
  background: linear-gradient(to right,
    var(--edge-3) 0%, var(--edge-1) 55%, var(--signal) 100%);
  box-shadow: var(--bloom-1); transform-origin: left center; transform: scaleX(0);
}
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    /* Longhands, not the `animation` shorthand: the shorthand resets
       `animation-timeline` to `auto`, so anything written after it silently
       detaches the animation and hands it back to the document clock. */
    .pg__line {
      animation-name: fill-x;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-duration: auto;
      animation-timeline: scroll(root block);
    }
    @keyframes fill-x { from { transform: scaleX(0); } to { transform: scaleX(1); } }
  }
}
/* Without scroll timelines the rail would otherwise run on the clock and fill
   itself in a second. Better to show a finished line than a lying one. */
@supports not (animation-timeline: scroll()) {
  .pg__line { transform: scaleX(1); opacity: 0.4; }
}

/* ============================================================================
   MOTION — G-02 Bloom (quarter speed) and G-09 Glow
   ============================================================================ */

/* Not an entrance any more — a pass.

   Each block is a function of where it sits in the window rather than
   something fired once when it crossed a line. Coming up from the bottom it is
   pushed down, out of focus and transparent; through the middle it settles at
   its true position, sharp and solid; leaving at the top it is pushed up and
   dissolves again. Scroll back and it comes back, exactly, because there is no
   animation running to disagree with.

   That distinction matters. A *triggered* reveal that replays every time a
   block re-enters is an interface fighting its reader — the animation owns a
   clock the reader does not, so scrolling up mid-play looks broken. A scrubbed
   one cannot: it has no clock. The reader is the clock.

   The plateau is the important number. Between 30% and 70% the block is
   completely at rest — for a 900px window that is roughly 400px of scrolling
   where nothing moves at all — so the motion happens either side of what you
   are reading and never underneath it. Narrow that and prose starts sliding
   around while it is being read, which is the one thing this must not do.

   Elements are pushed *away* from the centre, so a screenful is spread at the
   edges and squeezed at the middle. Neighbours in a row carry different throws
   so they fan rather than move as a block. */

[data-in] { --spread: 56px; }

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    /* Longhands throughout: the `animation` shorthand resets
       `animation-timeline` to `auto`, which silently hands the whole thing
       back to the document clock. */
    [data-in] {
      animation-name: focus-pass;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-duration: auto;
      animation-timeline: view();
    }

    /* The cover is already on screen when the reader arrives, so it has no
       entry to play — only an exit. Given the full range it would load at 12%
       progress with its own metadata half faded, which is not an animation,
       it is a bug.

       And the exit is timed against the *cover*, not against each block.
       `animation-range: exit` is measured per element, so every block waits
       until it personally touches the top of the window before anything
       happens: the intro sits at y=513, does nothing for 550px of scrolling,
       then dumps over its own 188px. Measured, that reads as something slow
       to leave and then abrupt — which is exactly backwards.

       On the cover's own timeline they leave as one thing, starting as soon as
       the reader scrolls away from it. The three ranges are staggered so the
       supporting material clears first and the name holds longest; each block
       still has its own throw, because the stencil is large and should barely
       move while the strip can afford to travel. */
    [data-in='exit'] {
      animation-name: focus-exit;
      animation-timeline: --cover;
      /* A default range, and not an optional one. Without it the fallback is
         the *full* cover range of the first screen — which at load is already
         about halfway through, so the block arrives at 70% opacity. Anything
         marked `exit` has to be correct before its page has been scrolled. */
      animation-range: exit 8% exit 60%;
    }
    .cover__strip { --spread: 70px; animation-range: exit  6% exit 52%; }
    .cover__intro { --spread: 56px; animation-range: exit  9% exit 58%; }
    .stencil      { --spread: 30px; animation-range: exit 14% exit 66%; }
  }
}

@keyframes focus-pass {
  0% {
    opacity: 0;
    transform: translate3d(0, var(--spread), 0) scale(0.97);
    filter: blur(5px);
  }
  30%, 70% {
    opacity: 1;
    transform: none;
    filter: blur(0);
  }
  100% {
    opacity: 0;
    transform: translate3d(0, calc(var(--spread) * -1), 0) scale(0.97);
    filter: blur(5px);
  }
}

@keyframes focus-exit {
  0%, 30% {
    opacity: 1;
    transform: none;
    filter: blur(0);
  }
  100% {
    opacity: 0;
    transform: translate3d(0, calc(var(--spread) * -0.7), 0) scale(0.98);
    filter: blur(5px);
  }
}

/* The fan. Same progress, different throw, so a row opens out instead of
   sliding as one plate. */
.stack-grid > *:nth-child(3n + 1) { --spread: 42px; }
.stack-grid > *:nth-child(3n + 2) { --spread: 58px; }
.stack-grid > *:nth-child(3n + 3) { --spread: 74px; }

/* G-09. The glow is pre-rendered on a pseudo-element and only its opacity
   animates — animating box-shadow repaints a blur on every frame. */
.glow { position: relative; }
.glow::before {
  content: ''; position: absolute; inset: -1px; pointer-events: none;
  box-shadow:
    0 0 14px color-mix(in oklch, white 26%, transparent),
    0 0 44px color-mix(in oklch, var(--mid) 44%, transparent);
  opacity: 0; transition: opacity var(--dur-hover) ease;
}
@media (hover: hover) and (pointer: fine) {
  .glow:hover::before, .glow:focus-visible::before { opacity: 1; }
}

.btn {
  display: inline-flex; align-items: center; gap: var(--s2);
  min-height: 48px; padding: 14px 22px;
  border: 1px solid var(--edge-2);
  /* A-01's glass, so a button reads as a small plate rather than an outline. */
  background: color-mix(in oklch, white 7%, transparent);
  backdrop-filter: blur(16px) brightness(1.16) saturate(1.28);
  -webkit-backdrop-filter: blur(16px) brightness(1.16) saturate(1.28);
  font-family: var(--font-mono); font-size: var(--t-micro);
  letter-spacing: 0.1em; text-transform: uppercase;
  transition: transform var(--dur-press) var(--ease-out), border-color var(--dur-hover) var(--ease-out);
}
/* Except in the reel: every slide carries a pair, the reel's scroll is driven
   from script, and `backdrop-filter` is ~6ms a frame per panel on screen — the
   same reason .slide__slot gave it up. The wash stays so they still match. */
.slide .btn { backdrop-filter: none; -webkit-backdrop-filter: none; }
.btn:active { transform: scale(0.97); }
.btn--primary { border-color: color-mix(in oklch, var(--signal) 60%, transparent); color: var(--signal); text-shadow: var(--bloom-sig); }
@media (hover: hover) and (pointer: fine) { .btn:hover { border-color: var(--edge-1); } }

/* ============================================================================
   VESSELS — A-01 Plate and A-09 Bracket
   ============================================================================ */

/* A-01. Glass, one hairline, nothing else. The blur brightens what is behind
   it rather than covering it: there is no tint anywhere on this page. */
.plate {
  position: relative; padding: var(--s4);
  background: color-mix(in oklch, white 7%, transparent);
  backdrop-filter: blur(16px) brightness(1.16) saturate(1.28);
  -webkit-backdrop-filter: blur(16px) brightness(1.16) saturate(1.28);
  border: 1px solid var(--edge-2);
}

/* A-09. No plate at all — four corner ticks, none of them touching, each one
   overshooting the content box.

   Built as eight elements rather than as one pseudo-element with eight
   background layers, which is what /lab had. The arms have to move
   independently, and eight backgrounds on one box can only move together.

   The durations below are the whole trick. They are deliberately not multiples
   of each other, so the combined cycle does not repeat on any timescale a
   reader will sit through — the corners never snap back into formation, which
   is what stops it reading as a loop and starts it reading as something idling.
   Transform and opacity only, so eight of them cost nothing. */
.brk { position: relative; padding: var(--s4); }
.brk__a {
  position: absolute; background: var(--edge-1); box-shadow: var(--bloom-1);
  animation: brk-drift var(--t, 7s) var(--ease-io) infinite alternate;
  animation-delay: var(--dl, 0s);
}
@keyframes brk-drift {
  from { transform: translate3d(0, 0, 0); opacity: 0.58; }
  to   { transform: translate3d(var(--dx, 0px), var(--dy, 0px), 0); opacity: 1; }
}
.brk__a:nth-child(1) { top: -14px; left: -14px;  width: 46px; height: 1px; --dx:  6px; --t: 5.9s; --dl: -1.2s; }
.brk__a:nth-child(2) { top: -14px; left: -14px;  width: 1px; height: 46px; --dy:  5px; --t: 6.7s; --dl: -3.4s; }
.brk__a:nth-child(3) { top: -14px; right: -14px; width: 46px; height: 1px; --dx: -5px; --t: 7.3s; --dl: -0.6s; }
.brk__a:nth-child(4) { top: -14px; right: -14px; width: 1px; height: 46px; --dy:  7px; --t: 8.1s; --dl: -4.1s; }
.brk__a:nth-child(5) { bottom: -14px; left: -14px;  width: 46px; height: 1px; --dx:  7px; --t: 9.1s; --dl: -2.3s; }
.brk__a:nth-child(6) { bottom: -14px; left: -14px;  width: 1px; height: 46px; --dy: -6px; --t: 6.1s; --dl: -5.0s; }
.brk__a:nth-child(7) { bottom: -14px; right: -14px; width: 46px; height: 1px; --dx: -6px; --t: 7.9s; --dl: -1.7s; }
.brk__a:nth-child(8) { bottom: -14px; right: -14px; width: 1px; height: 46px; --dy: -5px; --t: 8.7s; --dl: -3.9s; }

.v__k { display: block; margin-bottom: 10px; }
.v__h { font-size: 1.125rem; font-weight: 500; letter-spacing: -0.01em; text-shadow: var(--bloom-1); }
.v__p { margin-top: 8px; color: var(--ink-dim); font-size: 1.0625rem; }
.v__row {
  display: flex; align-items: baseline; justify-content: space-between; gap: var(--s2);
  margin-top: var(--s3); padding-top: 10px; border-top: 1px solid var(--edge-4);
}

/* ============================================================================
   D-07 STENCIL — the name
   The photograph is the type and the field around it is solid white. Done in
   SVG because a mask built from live text is the only version that scales
   without a raster and stays a real string in the accessibility tree.
   ============================================================================ */

/* The stencil takes the centre of the screen on its own; everything else is
   pushed to the foot of the first screenful, so the name is the only thing the
   reader has to deal with on arrival. */
.cover {
  min-height: 100svh;
  display: grid; grid-template-rows: 1fr auto;
  gap: var(--gap-block);
  /* The gap *between* the cover and what follows belongs to the next section's
     top padding, not to the cover's bottom. Spending it twice pushed the
     metadata strip off the first screen. */
  padding-block: var(--s8) var(--gap-block);
}
/* `align-content` and not `place-content`. Centring on both axes content-sizes
   the column track, and the stencil's `width: min(…, 100%)` then resolves
   against a track that is sizing itself from the stencil — so it collapses to
   its intrinsic width. Vertical centring is all that was wanted; the SVG
   centres itself horizontally with an auto margin. */
.cover__name { display: grid; align-content: center; }
/* Capped against the window as well as the container. Hugging the name means
   a short one renders a tall block, and on a laptop that alone would fill the
   screen and push everything under it out of sight. `meet` scales the whole
   drawing down, white field included, so the cap costs nothing but size. */
.stencil {
  width: min(920px, 100%);
  max-height: 40svh;
  height: auto;
  margin-inline: auto;
}
.cover__intro { display: grid; justify-items: start; gap: var(--s4); }
.cover__lead { max-width: 46ch; font-size: clamp(1.0625rem, 1.7vw, 1.375rem); color: var(--ink-dim); }
.cover__foot { display: grid; gap: var(--gap-block); }
.cover__strip {
  position: relative;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: var(--s3) var(--s4);
  padding-top: var(--s3);
}
/* The rule along its head. A pseudo-element rather than `border-top` so the
   landing can draw it across; out of flow, so it is not a grid item. */
.cover__strip::before {
  content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
  background: var(--edge-3); transform-origin: left center;
}
.cover__strip div { display: grid; gap: 3px; }
.cover__strip dd { font-family: var(--font-mono); font-size: var(--t-nano); letter-spacing: 0.08em; color: var(--ink); }

/* ============================================================================
   LANDING — the bloom
   The one animation on this page that runs on the clock. Played once, on
   arrival, by the home page and the archive (`intro` on the layout sets
   `body.intro`), so moving between the two lands the same way both times.

   The room comes up out of the dark in the order an eye would find it: the
   plate first, overexposed and out of focus, settling to its grade; then the
   grid; then the name, a blown-out halo condensing into the stencil; then the
   copy and the strip; the instruments last.

   Why a timed animation is allowed here when the rest of the page refuses
   them: it is not tied to a position, so there is no reader's clock for it to
   disagree with. And it never touches an element that carries `data-in` —
   it sits on the wrapper (`.cover__name` round the stencil) or on the
   children (the lead, the button, the strip's cells) — so opacity multiplies,
   filters and transforms nest, and scrolling away mid-bloom simply fades the
   blooming thing out. Put it on the same element and one would overwrite the
   other.

   `backwards`, never `both`: once finished, nothing is left applied — no
   filter holding a layer open, no transform blocking `.btn:active`.
   ============================================================================ */

.intro :is(.atm, .mesh, .cols, .bd, .pg),
.cover__name, .cover__intro > *, .cover__strip > div, .cover__strip::before,
.intro .archive__head :is(.rail__side, .rail__title, .archive__back, .rail__lead, .rail__strip > div, .slide__actions) {
  animation: intro-fade 700ms var(--ease-bloom) backwards;
}

@media (prefers-reduced-motion: no-preference) {
  .intro .atm { animation: intro-plate 3000ms var(--ease-bloom) backwards; }
  .intro :is(.mesh, .cols) { animation-duration: 1800ms; animation-delay: 300ms; }

  .cover__name { animation: intro-stencil 2400ms var(--ease-bloom) 500ms backwards; }

  .cover__intro > * { animation: intro-rise var(--dur-enter) var(--ease-bloom) 1300ms backwards; }
  .cover__intro > * + * { animation-delay: 1380ms; }

  .cover__strip::before { animation: intro-draw 1400ms var(--ease-bloom) 1450ms backwards; }
  .cover__strip > div {
    animation: intro-rise var(--dur-enter) var(--ease-bloom) backwards;
    animation-delay: calc(1600ms + var(--i, 0) * 80ms);
  }

  .intro :is(.bd, .pg) { animation-duration: 1600ms; animation-delay: 2000ms; }

  /* The archive, on the same beats. Its title stands in for the name and
     condenses out of the same halo; the rest rises on the cover's timings.
     The `data-in="exit"` header itself is never touched, only what is in it,
     for the reason given above. */
  .intro .archive__head .rail__title { animation: intro-stencil 2400ms var(--ease-bloom) 500ms backwards; }
  .intro .archive__head .archive__back { animation: intro-rise var(--dur-enter) var(--ease-bloom) 1300ms backwards; }
  .intro .archive__head .rail__lead { animation: intro-rise var(--dur-enter) var(--ease-bloom) 1380ms backwards; }
  .intro .archive__head .rail__strip > div { animation: intro-rise var(--dur-enter) var(--ease-bloom) 1600ms backwards; }
  .intro .archive__head .rail__strip > div:nth-child(2) { animation-delay: 1680ms; }
  .intro .archive__head .rail__strip > div:nth-child(3) { animation-delay: 1760ms; }
  .intro .archive__head .rail__strip > div:nth-child(4) { animation-delay: 1840ms; }
  .intro .archive__head .rail__side { animation-duration: 1600ms; animation-delay: 2000ms; }
  .intro .archive__head .slide__actions { animation: intro-rise var(--dur-enter) var(--ease-bloom) 1600ms backwards; }
}

@keyframes intro-fade { from { opacity: 0; } }

/* Over-bright and soft, pushed in a little: a lens finding the room. The
   `.atm` box already overhangs the window by 7%, so neither the scale nor the
   blur's dark fringe ever reaches an edge.
   `scale`, not `transform`: parallax writes `transform` inline on `.atm`, and
   an animated `transform` would override it for the whole bloom and then jump
   to wherever the pointer had got to. The two properties compose instead.
   The plate is fully up by 35% and still blown out: that stop is what makes
   the overexposure visible at all. On one curve, the brightness is spent
   before there is enough picture on screen to see it. */
@keyframes intro-plate {
  from { opacity: 0; scale: 1.06; filter: brightness(2)    blur(28px); }
  35%  { opacity: 1;              filter: brightness(1.45) blur(12px); }
  to   { opacity: 1; scale: 1;    filter: brightness(1)    blur(0); }
}

/* The white field arrives as a halo and contracts onto the letters. The glow
   peaks as it comes into focus, then burns off — the overexposure, not a
   shadow: it is white, and it lights the edges of the cut-outs too. */
@keyframes intro-stencil {
  from {
    opacity: 0; transform: scale(1.04);
    filter: blur(22px) drop-shadow(0 0 0 transparent);
  }
  45% {
    opacity: 1;
    filter: blur(3px) drop-shadow(0 0 calc(14px * var(--bloom)) color-mix(in oklch, white 50%, transparent));
  }
  to {
    opacity: 1; transform: none;
    filter: blur(0) drop-shadow(0 0 0 transparent);
  }
}

/* G-02, the page's own come-into-focus, at its quarter speed. */
@keyframes intro-rise {
  from { opacity: 0; transform: translate3d(0, 16px, 0) scale(0.97); filter: blur(5px); }
  to   { opacity: 1; transform: none; filter: blur(0); }
}

@keyframes intro-draw { from { transform: scaleX(0); } to { transform: none; } }

/* ============================================================================
   D-01 RAIL — every section head below the cover
   Marginalia up the left edge, the title at optical centre, a strip along the
   foot. On the cover it would compete with the stencil; here it is the thing
   that says a new part has started.
   ============================================================================ */

.rail { display: grid; grid-template-columns: 36px minmax(0, 1fr); column-gap: var(--s6); padding-block: var(--gap-section) var(--gap-block); }
.rail__side { display: flex; flex-direction: column; align-items: center; gap: var(--s3); }
.rail__side p { writing-mode: vertical-rl; transform: rotate(180deg); white-space: nowrap; font-family: var(--font-mono); font-size: var(--t-nano); letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-faint); }
.rail__side i { display: block; width: 1px; flex: 1; min-height: 48px; background: var(--edge-3); box-shadow: var(--bloom-1); }
.rail__title {
  font-size: var(--t-display); font-weight: 500; line-height: 0.88;
  letter-spacing: -0.045em; text-transform: uppercase; text-shadow: var(--bloom-2);
}
.rail__title em { font-style: normal; font-weight: 400; color: var(--ink-dim); }
.rail__lead { max-width: 54ch; margin-top: var(--s4); color: var(--ink-dim); }
.rail__strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: var(--s3) var(--s4); margin-top: var(--gap-block); padding-top: var(--s3); border-top: 1px solid var(--edge-3); }
.rail__strip div { display: grid; gap: 3px; }
.rail__strip dd { font-family: var(--font-mono); font-size: var(--t-nano); letter-spacing: 0.08em; color: var(--ink); }
@media (max-width: 760px) {
  .rail { grid-template-columns: minmax(0, 1fr); row-gap: var(--s3); }
  /* Wraps rather than running off a phone: the contact rail carries the email,
     and an address is as long as it is. */
  .rail__side { flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: flex-start; }
  .rail__side p { writing-mode: horizontal-tb; transform: none; }
  .rail__side i { width: 40px; height: 1px; flex: 0 0 40px; min-height: 0; }
}

/* ============================================================================
   C-01 COUNTER — the reel, with the first design's wheel snapping
   ============================================================================ */

.reel { display: grid; gap: var(--gap-block); }
.reel__count { display: flex; align-items: baseline; gap: 0.1em; font-variant-numeric: tabular-nums; letter-spacing: -0.045em; line-height: 0.85; }
.reel__now { font-size: clamp(3rem, 8vw, 8rem); font-weight: 400; text-shadow: var(--bloom-2); }
.reel__total { font-size: clamp(1.125rem, 2.2vw, 1.75rem); color: var(--ink-faint); }
.reel__marks { display: grid; gap: 4px; align-content: start; }
.reel__mark {
  display: flex; align-items: center; gap: var(--s2); min-height: 44px; text-align: left;
  font-family: var(--font-mono); font-size: var(--t-nano); letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--ink-faint);
  transition: color var(--dur-hover) var(--ease-out), transform var(--dur-press) var(--ease-out);
}
.reel__mark:active { transform: scale(0.97); }
.reel__mark i { display: block; flex: 0 0 28px; height: 1px; background: currentColor; transform: scaleX(0.42); transform-origin: left center; transition: transform 320ms var(--ease-out); }
.reel__mark[aria-current='true'] { color: var(--signal); text-shadow: var(--bloom-sig); }
.reel__mark[aria-current='true'] i { transform: scaleX(1); box-shadow: var(--bloom-sig); }
@media (hover: hover) and (pointer: fine) { .reel__mark:hover { color: var(--ink); } }

.reel__list { display: grid; }
.slide {
  display: grid; align-content: center; gap: var(--s4); padding-block: var(--s8);
  /* Everything under the plate, plus the gap and the padding around it. Read
     off the rendered block rather than guessed: the caption runs 244-280px
     depending on how the title wraps, and this leaves it room to grow. */
  --slide-caption: 540px;
}
.slide__media { width: 100%; max-width: 780px; }
.slide__media :is(img, video), .slide__slot { display: block; width: 100%; aspect-ratio: 16 / 10; border: 1px solid var(--edge-3); object-fit: cover; }
.slide__media :is(img, video) { background: var(--void); }
/* An illustration shown whole, on the same glass and grid as the empty and
   figure slots, so a square drawing sits in the plate instead of being cut to
   its shape. */
.slide__media img.is-contain {
  object-fit: contain; padding: var(--s3);
  background-color: color-mix(in oklch, var(--void) 40%, transparent);
  background-image:
    repeating-linear-gradient(to right,  var(--edge-5) 0 1px, transparent 1px 48px),
    repeating-linear-gradient(to bottom, var(--edge-5) 0 1px, transparent 1px 48px);
}

/* The diagrams. Nodes are the site's glass without the blur (a plate per node
   would be a backdrop-filter per node), labels are the mono face, and the one
   accent is signal white on the stages that were Adrien's own. */
/* The drawing is laid over the slot rather than sized by it: on a short
   window the slot is capped below 16:10, and an SVG in flow would keep its
   own height and spill out over the caption. Absolutely placed, `meet`
   scales it down to whatever box the slot has. */
.slide__slot--diagram { overflow: hidden; }
.slide__slot--diagram::after { display: none; }
.dg { position: absolute; inset: var(--s2); width: calc(100% - 2 * var(--s2)); height: calc(100% - 2 * var(--s2)); }
.dg__node { fill: color-mix(in oklch, white 5%, transparent); stroke: var(--edge-2); stroke-width: 1.5; }
.dg__node--mine { stroke: var(--signal); filter: drop-shadow(0 0 10px color-mix(in oklch, white 30%, transparent)); }
.dg__edges path { fill: none; stroke: var(--edge-1); stroke-width: 2; }
.dg__head { fill: none; stroke: var(--edge-1); stroke-width: 1.5; }
.dg text { font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; }
.dg__title { font-size: 34px; fill: var(--ink); }
.dg__sub { font-size: 26px; fill: var(--ink-dim); }
.dg__edge { font-size: 24px; fill: var(--ink-dim); }
.dg__tag { font-size: 24px; fill: var(--signal); }
.slide__slot {
  position: relative;
  background-color: color-mix(in oklch, var(--void) 40%, transparent);
  background-image:
    repeating-linear-gradient(to right,  var(--edge-5) 0 1px, transparent 1px 48px),
    repeating-linear-gradient(to bottom, var(--edge-5) 0 1px, transparent 1px 48px);
  /* No `backdrop-filter`.

     It blurred a photograph that is already blurred to sigma 7 at bake time,
     which is to say it cost a per-frame pass to change nothing: measured
     against the same frame without it, mean difference 0.03 of 255, and the
     only pixels moving at all are the hairlines of its own grid.

     What it did cost was frames. `backdrop-filter` is about 6ms a frame per
     panel on this page and the only lever on it is how many are on screen at
     once, so a full-screen one riding the reel is the worst place to spend it
     — this is the section whose scroll the reel drives from script, where
     there is no compositor to hide a long frame behind. */
}
.slide__slot::after {
  content: ''; position: absolute; top: 50%; left: 50%; width: 22px; height: 22px;
  transform: translate(-50%, -50%); background:
    linear-gradient(var(--edge-2), var(--edge-2)) center / 1px 100% no-repeat,
    linear-gradient(var(--edge-2), var(--edge-2)) center / 100% 1px no-repeat;
}
/* The figure plate: until a project has a real image, its slot carries the
   one number that argues for it. Set in the display face at the size of a
   section head so the plate still reads as the picture, not as a caption.
   The crosshair stays underneath as the plate's registration mark. */
.slide__slot--fig {
  display: grid; align-content: center; justify-items: start; gap: var(--s2);
  padding: clamp(var(--s3), 5vw, var(--s8));
}
.slide__slot--fig::after { top: var(--s3); left: auto; right: var(--s3); transform: none; }
.slide__figV {
  font-size: clamp(2.75rem, 9vw, 7.5rem); font-weight: 500; line-height: 0.9;
  letter-spacing: -0.05em; color: var(--signal); text-shadow: var(--bloom-2);
  overflow-wrap: anywhere;
}
.slide__figL { max-width: 36ch; color: var(--ink); }
.slide__ctx { margin-top: var(--s1); color: var(--ink-dim); }
.slide__name { font-size: var(--t-title); font-weight: 500; letter-spacing: -0.03em; line-height: 1.02; text-shadow: var(--bloom-2); }
.slide__lead { max-width: 48ch; margin-top: var(--s2); color: var(--ink-dim); }
.slide__stack { display: flex; flex-wrap: wrap; gap: var(--s1); margin-top: var(--s3); }
.slide__stack li { padding: 5px 10px; border: 1px solid var(--edge-4); font-family: var(--font-mono); font-size: var(--t-nano); letter-spacing: 0.1em; text-transform: uppercase; }
.slide__actions { display: flex; flex-wrap: wrap; gap: var(--s1); margin-top: var(--s3); }
.slide__actions .btn { min-height: 44px; padding: 11px 16px; }

/* Held back to 22% unless live. Scoped to .js so a reader without it gets
   every project at full strength rather than a page of ghosts.

   This is the fallback now — see the scrubbed version below. */
.js .reel__list .slide { opacity: 0.22; transition: opacity 340ms var(--ease-out); }
.js .reel__list .slide.is-live { opacity: 1; }

/* The same focus, scrubbed by each slide's own position instead of switched
   when it crosses the midline.

   The switched version is what made the reel feel like it was catching up
   with the reader: a 340ms fade started at a threshold that the snap crosses
   at full speed, so the fade always ran *after* the movement that caused it,
   and scrolling back re-fired it from the wrong end. Same reasoning as the
   plates, one section up — a transition with a duration owns a clock the
   reader does not.

   Longhands, not the shorthand: `animation:` resets `animation-timeline` to
   auto and hands this back to the document clock, where `both` simply applies
   the end state on load and nothing scrubs.

   Plateau rather than a point: 40-60% of `cover` is roughly a fifth of a
   screen either side of centre, so the slide the counter is naming is at full
   strength for the whole time it is the answer, not only at one scroll
   position. */
@supports (animation-timeline: view()) {
  @media (min-width: 900px) {
    .js .reel__list .slide {
      transition: none;
      animation-name: slide-focus;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-duration: auto;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
    }
    @keyframes slide-focus {
      0%          { opacity: 0.16; }
      40%, 60%    { opacity: 1; }
      100%        { opacity: 0.16; }
    }
  }
}

@media (min-width: 900px) {
  /* The reel is capped and centred as one piece — rail and project together.

     Capping the project alone is what put it off-centre: the plate's right edge
     is the composition's right edge, so any slack left inside the track is
     slack on one side only, and the rail is real ink on the other. Capping the
     pair and centring the pair keeps the midpoint of rail-plus-project on the
     midpoint of the window at every width, whatever size the plate ends up.
     The section's own header is capped with it so the two share a left edge. */
  .reel,
  #work > .shell > .rail,
  .archive__head .rail { max-width: 1180px; margin-inline: auto; }

  .reel { grid-template-columns: minmax(190px, 3fr) minmax(0, 9fr); column-gap: var(--s6); align-items: start; }

  /* The coda gets the same screen a project gets, so it arrives clean rather
     than bleeding into the bottom of the last one. */
  .reel__end {
    min-height: 100svh;
    display: grid;
    align-content: center;
    /* The timeline the rail leaves on. */
    view-timeline-name: --coda;
    view-timeline-axis: block;
  }

  /* The rail counts projects. On the coda there is no project to count, so a
     number reading 03/03 beside a block that is not project three is just
     furniture claiming to mean something. It clears as the coda arrives and
     comes back if the reader scrolls up, because it is scrubbed by the coda's
     own position rather than switched by a threshold.

     Not gated on reduced motion: nothing moves. It is an opacity that happens
     to be a function of scroll position. */
  @supports (animation-timeline: view()) {
    .reel__rail {
      animation-name: rail-out;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-duration: auto;
      animation-timeline: --coda;
      /* Done fading before the coda settles: by `entry 100%` the coda is fully
         on screen, so finishing at 80% means it never arrives with the rail
         still half there. */
      animation-range: entry 25% entry 80%;
    }
    @keyframes rail-out {
      from { opacity: 1; }
      to   { opacity: 0; pointer-events: none; }
    }
  }
  .reel__rail { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: var(--s6); }

  /* One project per screenful. The sticky counter depends on it: a slide the
     reader has to scroll to finish breaks what the number is claiming.

     The plate and the caption share one column, centred in the track. They
     used to share only a left edge: the plate was capped by *width* out of
     whatever height the caption left over, so on anything but a tall window
     it came out narrower than the paragraph beneath it and sat in a column
     twice its own width. That is the "not centred" — a picture floating in
     the left third of its own track with a hole beside it.

     The budget is the same, spent on the plate's height instead. Width is
     now constant down the whole block, so the composition has one edge on
     each side at every window size and short windows crop the plate rather
     than shrinking it away from its caption.

     Filling the track rather than centred in it: the track is 9/12 of a shell
     that is itself centred, so anything narrower than the track — centred in
     it or not — leaves slack on one side that the rail does not balance. */
  .slide {
    min-height: 100svh;
    padding-block: var(--s6);
  }
  /* Fills its track. Any cap narrower than the track leaves a dead tail on the
     right of it, and that tail is off-centre weight: the rail is real ink on
     the left, the tail is nothing on the right, so the composition reads as
     shoved left even though the grid is symmetrical. Full-bleed in the track
     puts the midpoint of rail-plus-project on the midpoint of the window at
     every width. */
  .slide__media { max-width: none; }
  /* What is left of the screen once the caption, the gap and the padding have
     taken theirs — floored, so a short window letterboxes the plate rather
     than reducing it to a strip, and ceilinged at 46svh so a tall one does not
     hand it half the screen and leave the caption reading as a footnote. */
  .slide__media :is(img, video), .slide__slot { max-height: max(180px, min(calc(100svh - var(--slide-caption)), 46svh)); }

  /* Below this the caption alone is most of the screen; the plate keeps its
     floor and the padding gives back what it can. */
  @media (max-height: 720px) {
    .slide { padding-block: var(--s3); }
  }
}

@media (max-width: 899px) {
  .reel__rail { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--s3); }
  /* Wraps rather than running off a phone: six marks at 28px plus their gaps
     are wider than a 320px window once the counter has its share. */
  .reel__marks { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0 var(--s2); min-width: 0; }
  .reel__markName { display: none; }
  .js .reel__list .slide { opacity: 1; }
}

/* The record: experience and education as a ledger, one hairline per row.
   Period in the margin, the entry beside it: the same rail-and-body split
   the section heads use, so it reads as part of the sheet. */
.record { margin-top: var(--gap-block); border-top: 1px solid var(--edge-3); }
.record__row {
  display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: var(--s1) var(--s4);
  padding-block: var(--s4); border-bottom: 1px solid var(--edge-4);
}
.record__row .v__h { text-shadow: var(--bloom-1); }
.record__row .v__p { max-width: 62ch; }
@media (max-width: 760px) { .record__row { grid-template-columns: minmax(0, 1fr); } }

/* The toolkit, as a spec sheet. A section's worth of air above it so it
   does not read as the tail of the record. Four columns, each headed by its
   ordinal on a hairline, then one ruled line per skill. The rules line up across columns, which is what makes it read as one sheet
   left to right rather than four lists. */
.spec {
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--gap-block) var(--s6);
  margin-top: var(--gap-section);
}
.spec__n { display: flex; align-items: center; gap: var(--s2); color: var(--ink-dim); }
.spec__n::after { content: ''; flex: 1; height: 1px; background: var(--edge-2); box-shadow: var(--bloom-1); }
.spec__h { margin-top: var(--s2); padding-bottom: var(--s3); }
.spec__row {
  display: flex; align-items: baseline; justify-content: space-between; gap: var(--s2);
  padding-block: 12px; border-top: 1px solid var(--edge-4);
}
.spec__row:last-child { border-bottom: 1px solid var(--edge-4); }
.spec__name { color: var(--ink); }
@media (max-width: 1100px) { .spec { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px)  { .spec { grid-template-columns: minmax(0, 1fr); } }

.stack-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: var(--gap-block) var(--s4); }
/* ============================================================================
   THE ENDING — contact

   One screen: the section head, the address set large beneath the title, and
   three ways in. The footer closes the screen.

   Where the plate changes. The boundary is the first thing inside the body,
   directly above the heading, and the section is pushed down by a gap of its
   own. The wipe runs while that marker crosses the middle band of the window,
   so by then the skills are already in the top fifth of it, leaving, and the
   change lands on "Get in touch" instead of on the toolkit.

   How far the page scrolls. Exactly to the foot of this screen and no further:
   no min-height padding and no bottom padding on the footer. The marker is a
   third of the way down a full-height section, so it still reaches the top
   40% of the window before the page runs out, and the wipe completes.
   ============================================================================ */
.contact {
  min-height: 100svh;
  margin-top: max(var(--gap-section), 30vh);
  display: grid; grid-template-rows: 1fr auto;
}
.contact__body { display: grid; align-content: center; }
/* The rail's own section padding would push a one-screen section off its
   screen; the section's top margin already buys the air. */
.contact .rail { padding-block: 0; }
.contact__main { margin-top: var(--gap-block); }
.contact__rule {
  display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--s2);
  padding-top: var(--s3); border-top: 1px solid var(--edge-3);
}
.contact__main .contact__mail { margin-top: var(--s3); }
.contact__main .links { max-width: none; }
/* The address is the thing to take away, so it outranks everything but the
   title: signal white, the title's bloom, a hairline that draws in on hover. */
.contact__mail {
  display: inline-block;
  font-size: clamp(1.35rem, 5.4vw, 5rem); font-weight: 500;
  line-height: 1.05; letter-spacing: -0.035em;
  color: var(--signal); text-shadow: var(--bloom-2);
  overflow-wrap: anywhere;
  background: linear-gradient(var(--edge-1), var(--edge-1)) left bottom / 0% 1px no-repeat;
  padding-bottom: 0.1em;
  transition: background-size 420ms var(--ease-out);
}
.contact__mail:hover, .contact__mail:focus-visible { background-size: 100% 1px; }

/* The language switch: both labels always present, the current one lit, in
   a glass pill pinned to the top corner. Two targets of 44px, not one tiny
   toggle. */
.lang {
  position: fixed; top: var(--s2); right: var(--gutter); z-index: 20;
  display: flex; border: 1px solid var(--edge-3);
  background: color-mix(in oklch, var(--void) 35%, transparent);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
}
.lang a {
  display: grid; place-items: center; min-width: 48px; min-height: 44px; padding-inline: var(--s2);
  font-family: var(--font-mono); font-size: var(--t-nano); letter-spacing: 0.16em; color: var(--ink-faint);
  transition: color var(--dur-hover) var(--ease-out);
}
.lang a + a { border-left: 1px solid var(--edge-4); }
.lang a[aria-current='page'] { color: var(--signal); text-shadow: var(--bloom-sig); }
@media (hover: hover) and (pointer: fine) { .lang a:hover { color: var(--ink); } }

/* The contact links. Between a button and a strip cell: A-01's glass and a
   hairline, like every button on the page, but sized as a tile and carrying
   a label over its value, so each says where it goes before it is pressed.
   Three across, stacked on a phone. The address above is the mail link. */
.links {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s2);
  max-width: 1040px; margin-top: var(--s6);
}
.link {
  display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: 'k a' 'v v';
  align-content: space-between; gap: var(--s3) var(--s2);
  min-height: 104px; padding: var(--s2) var(--s2) var(--s3);
  border: 1px solid var(--edge-2);
  background: color-mix(in oklch, white 7%, transparent);
  backdrop-filter: blur(16px) brightness(1.16) saturate(1.28);
  -webkit-backdrop-filter: blur(16px) brightness(1.16) saturate(1.28);
  transition: border-color var(--dur-hover) var(--ease-out), transform var(--dur-press) var(--ease-out);
}
.link:active { transform: scale(0.98); }
.link__k { grid-area: k; color: var(--ink-dim); }
.link__v { grid-area: v; color: var(--ink); }
.link__arrow {
  grid-area: a; line-height: 1;
  font-family: var(--font-mono); font-size: var(--t-nano); color: var(--ink-dim);
  transition: transform 260ms var(--ease-out), color var(--dur-hover) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .link:hover { border-color: var(--edge-1); }
  .link:hover .link__arrow { transform: translate3d(3px, -3px, 0); color: var(--signal); }
}
@media (max-width: 760px) { .links { grid-template-columns: minmax(0, 1fr); } }
@media (prefers-reduced-motion: reduce) { .link__arrow { transition: none; } }

.foot {
  margin-top: var(--gap-block); padding-block: var(--s4); border-top: 1px solid var(--edge-4);
  display: flex; flex-wrap: wrap; gap: var(--s2) var(--s3); justify-content: space-between;
}

/* A phone's skills column is several screens tall, so its last box sits
   lower when the marker arrives: it needs more air to have left in time. */
@media (max-width: 760px) { .contact { margin-top: 45vh; } }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  [data-in] { opacity: 1; animation: none; transform: none; filter: none; }
  .bd { display: none; }
  .brk__a { animation: none; opacity: 0.85; }
  /* The wipe becomes a cross-fade: the plate still changes, but nothing
     sweeps across the window. */
  .atm__p--1, .atm__p--2, .atm__p--3 {
    transform: none; opacity: 0; transition: opacity 200ms linear;
  }
  .atm__p--1 img, .atm__p--2 img, .atm__p--3 img { transform: none; }
  .atm__p.is-on { opacity: 1; }
  .tx-bar { display: none; }
  .reel__mark i { transition: none; }
  /* Back to the switched version: it is a step, not a cross-fade. */
  .js .reel__list .slide { animation-name: none; }
  .btn:active { transform: none; }
}

/* ============================================================================
   THE WAY OUT — the block that ends a reel

   A coda, not a section. It sits in the reel's own content column, under the
   still-sticky counter, separated from the last project by a hairline and a
   little air — the same relationship the last row of a table has to the rows
   above it.

   It had a section's worth of everything: `--gap-section` above, 68svh of its
   own, and the page gutter for a left edge while every project sat in the
   reel's column. Any one of those reads as "new part"; all three together made
   it look like a different page.

   Still deliberately not a slide: it must not be dimmed by the reel's focus
   rule, and it must not be a wheel-snap stop, or the gesture that leaves the
   last project would land on it instead of leaving.
   ============================================================================ */

.more {
  display: grid; justify-items: start; gap: var(--s3);
  max-width: 58ch;
  margin-top: var(--s4);
  padding-block: var(--s6) var(--s8);
  border-top: 1px solid var(--edge-4);
}
.more__h {
  /* Below the section heads on purpose. This closes a part, it does not open
     one, and at `--t-display` it was competing with "Selected Work". */
  font-size: clamp(1.75rem, 3.6vw, 2.75rem); font-weight: 500;
  line-height: 0.96; letter-spacing: -0.035em; text-transform: uppercase;
  text-shadow: var(--bloom-1);
}
.more__h em { font-style: normal; font-weight: 400; color: var(--ink-dim); }
.more__p { color: var(--ink-dim); }
.more__cta { margin-top: var(--s2); }

/* The archive's header is a page title rather than a section head, so it gets
   a screen of its own before the reel starts. */
.archive__head { min-height: 100svh; display: grid; align-content: center; }

/* Whatever occupies the first screen defines `--cover`: the timeline its own
   contents leave on. Both pages have one, and naming it here rather than on
   `.cover` alone is what stops `[data-in="exit"]` pointing at a timeline that
   does not exist — which is silent. The animation simply never attaches and
   the header sits at full opacity forever. */
.cover,
.archive__head {
  view-timeline-name: --cover;
  view-timeline-axis: block;
}
.archive__back {
  display: inline-flex; align-items: center; gap: 10px;
  padding: 10px 14px; margin-left: -14px;
  color: var(--ink-dim);
  transition: color var(--dur-hover) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) { .archive__back:hover { color: var(--signal); } }
