/* ============================================================
   ryan-graves.com
   A small design system: tokens → grid → primitives → patterns
   ============================================================ */

/* ------------------------------------------------------------
   1. TOKENS
   ------------------------------------------------------------ */

:root {
  /* --- color ------------------------------------------------ */
  --bg:          #f5f4f2;
  --bg-sunken:   #edece9;
  --ink:         #2b2b2b;
  /* All text reads as full black by request, and every secondary/label color in
     the file routes through these two tokens (36 uses, all `color` or SVG
     `fill`, nothing structural), so pointing both at --ink here is enough to
     flip the whole site rather than editing each selector. */
  --ink-2:       var(--ink);
  --ink-3:       var(--ink);
  /* The one deliberate exception to the all-black rule above: the footer's
     label/time/credit text reads as quieter than its links, by request. 0.68
     is tuned against --bg for 4.82:1 (AA); see .footer-label, .footer-info
     and .footer-base below. */
  --muted:       rgba(43, 43, 43, 0.68);
  --rule:        rgba(43, 43, 43, 0.14);   /* hairlines */
  --rule-strong: rgba(43, 43, 43, 0.28);
  --guide:       rgba(43, 43, 43, 0.075);  /* background column guides, faint by request */
  /* Was its own near-black (#191919), one shade off from --ink (#2b2b2b),
     the page-wipe curtain's color. Routed through --ink so the menu curtain,
     the inverted header, and the between-page curtain are the same charcoal
     instead of three close-but-different blacks. */
  --inverse:     var(--ink);
  --inverse-ink: #f5f4f2;
  --accent:      #2b2b2b;
  --draw:        #ffe600;                  /* pencil ink on the portrait; same highlighter yellow as ::selection */
  --flag:        #ffe9a8;                  /* unfilled placeholder */

  /* --- type -------------------------------------------------
     Two families, each with one job.
     Space Grotesk (Florian Karsten, OFL) is the voice: a grotesque built on
     Space Mono's skeleton, so it keeps the squared terminals and odd curves
     without the fixed width. All display type.
     Inter (Rasmus Andersson, OFL) is the reading face, and it also carries
     labels and data: uppercased and tracked out it reads as a distinct
     register, and its tabular figures keep numbers aligned in the stats and
     figure captions, the job a mono would otherwise be doing. */
  --font-display: "Space Grotesk", "Helvetica Neue", Helvetica, Arial, sans-serif;
  --font-text: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;

  /* modular scale: 3 display steps + 3 text steps, nothing else.
     Everything is fluid between 360 and 1728. Text steps grow far more slowly
     than display steps: reading size should barely change, headlines should
     scale with the canvas. */
  --t-display-1: clamp(2.5rem, 1.2rem + 6.6vw, 9rem);      /* hero            */
  --t-display-2: clamp(1.875rem, 1.1rem + 3.6vw, 4.25rem); /* card title      */
  --t-display-3: clamp(1.5rem, 1.1rem + 1.9vw, 2.5rem);    /* section heading */
  --t-body-lg:   clamp(1.125rem, 1.02rem + 0.5vw, 1.5rem);
  --t-body:      clamp(1rem, 0.955rem + 0.2vw, 1.1875rem);
  --t-small:     clamp(0.9062rem, 0.88rem + 0.12vw, 1rem);
  --t-label:     clamp(0.6875rem, 0.665rem + 0.1vw, 0.8125rem);

  /* reading measures: line length is set here, not by column spans, so it
     stays in the 45–75ch band no matter how many columns the grid is on */
  --measure:    64ch;
  --measure-lg: 46ch;   /* lead paragraphs run larger, so fewer characters */

  --lh-tight: 0.94;
  --lh-snug:  1.12;
  --lh-body:  1.6;
  --ls-tight: -0.025em;
  --ls-label: 0.04em;

  /* --- space: 8pt scale ------------------------------------ */
  --s-1: 0.25rem;   /*  4 */
  --s-2: 0.5rem;    /*  8 */
  --s-3: 0.75rem;   /* 12 */
  --s-4: 1rem;      /* 16 */
  --s-5: 1.5rem;    /* 24 */
  --s-6: 2rem;      /* 32 */
  --s-7: 3rem;      /* 48 */
  --s-8: 4rem;      /* 64 */
  --s-9: 6rem;      /* 96 */
  --s-10: 8rem;     /* 128 */
  --s-11: 11rem;    /* 176 */

  /* section rhythm */
  --section-y: clamp(3.5rem, 2rem + 5vw, 9rem);
  /* Homepage-only: the gap under the hero's name/tag before the case list
     starts, on top of the usual --section-y rhythm. Shared as a variable
     (rather than repeating the calc) because .hero's min-height (§7) has to
     subtract this exact value to keep its cover-art peek at a fixed amount
     regardless of how tall this gap is — see the comment there. */
  --home-top-gap: calc(var(--section-y) + var(--s-7));

  /* --- grid ------------------------------------------------- */
  /* Tight gutters, wide margins: the guides sit closer together and the page
     holds a broad quiet edge. Both feed .grid AND .grid-lines; that shared
     origin is the only reason the guides land on the real columns, so change
     these rather than giving .grid-lines a gap of its own. */
  --gutter: clamp(0.5rem, 0.35rem + 0.5vw, 1rem);
  --margin: clamp(1.5rem, 1rem + 2vw, 4rem);
  --max-w: 90rem;    /* 1440, grid stops widening past a 1440 laptop */
  --cols: 12;

  /* --- motion ----------------------------------------------- */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* overshoots by ~3% and settles; used only where a box changes size */
  --ease-spring: cubic-bezier(0.22, 1.12, 0.28, 1);
  --fast: 180ms;
  --mid: 320ms;
  --slow: 720ms;
  /* the arrival cascade: one step, and how far body copy lifts. Re-time the
     whole sequence by changing --stagger; nothing hard-codes a delay. */
  --stagger: 90ms;
  --rise: 1.25rem;

  /* --- chrome ----------------------------------------------- */
  --nav-h: 4.5rem;
}

/* ------------------------------------------------------------
   2. RESET
   ------------------------------------------------------------ */

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

html {
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--nav-h) + var(--s-6));
  -webkit-text-size-adjust: 100%;
}

html { background: var(--bg); }

body {
  background: transparent;
  color: var(--ink);
  /* reading text is Inter at its text optical size: larger apertures,
     looser spacing, drawn for paragraphs rather than headlines */
  font-family: var(--font-text);
  font-variation-settings: "opsz" 14;
  font-weight: 400;
  font-size: var(--t-body);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
  /* Without this, hovering any paragraph shows the text-select I-beam:
     technically correct (the text is selectable) but reads as "this is an
     input" on a site with no inputs. Interactive elements are unaffected:
     links and buttons get their pointer cursor from a rule that targets
     them directly, which beats this inherited value regardless of order. */
  cursor: default;
}

img, svg, video { display: block; max-width: 100%; }
img, video { height: auto; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
::selection { background: var(--draw); color: var(--ink); }

/* ------------------------------------------------------------
   3. FONTS
   ------------------------------------------------------------ */

/* self-hosted: no third-party request, no layout shift from a slow CDN */
@font-face {
  font-family: "Space Grotesk";
  src: url("fonts/space-grotesk-var.woff2") format("woff2-variations");
  font-weight: 300 700;
  font-display: swap;
}
@font-face {
  font-family: "Inter";
  src: url("fonts/inter-var.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
}

/* ------------------------------------------------------------
   4. GRID
   ------------------------------------------------------------ */

.grid {
  display: grid;
  grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
  gap: var(--gutter);
  max-width: var(--max-w);
  margin-inline: auto;
  padding-inline: var(--margin);
}

/* column spans used across the site */
.col-full   { grid-column: 1 / -1; }
.col-main   { grid-column: 1 / span 8; }
.col-side   { grid-column: 10 / -1; }
.col-half-l { grid-column: 1 / span 6; }
.col-half-r { grid-column: 7 / -1; }
.col-text   { grid-column: 4 / span 7; }   /* reading column */
.col-label  { grid-column: 1 / span 2; }

.section { padding-block: var(--section-y); }
.section--sunken { background: var(--bg-sunken); }

/* ------------------------------------------------------------
   5. TYPE PRIMITIVES
   ------------------------------------------------------------ */

/* labels are Inter doing a different job: uppercased, tracked out, one step
   heavier. Tabular figures keep numbers aligned without a monospace face. */
.label {
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-3);
  line-height: 1.45;
  font-variation-settings: "opsz" 14;
  font-variant-numeric: tabular-nums;
}

.meta {
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  line-height: 1.45;
  font-variation-settings: "opsz" 14;
  font-variant-numeric: tabular-nums;
}

/* Space Grotesk has no width axis, so tracking carries the optical work:
   the larger the type, the tighter it sets, which is what a display cut
   would do for you. Weight steps down as size steps down. */
.display-1, .display-2, .display-3 {
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  line-height: var(--lh-tight);
  text-wrap: balance;
  font-variation-settings: normal;
}
.display-1 { font-size: var(--t-display-1); letter-spacing: -0.038em; }
.display-2 { font-size: var(--t-display-2); letter-spacing: -0.028em; }
.display-3 {
  font-size: var(--t-display-3);
  font-weight: 600;
  line-height: var(--lh-snug);
  letter-spacing: -0.018em;
}

/* the lead is Inter at an intermediate optical size, between headline and
   paragraph, which is exactly what the axis is for */
.lead {
  font-family: var(--font-text);
  font-size: var(--t-body-lg);
  font-variation-settings: "opsz" 24;
  font-weight: 400;
  line-height: 1.42;
  letter-spacing: -0.014em;
  max-width: var(--measure-lg);
  text-wrap: pretty;
}

.prose { max-width: var(--measure); text-wrap: pretty; }
.prose + .prose { margin-top: var(--s-5); }
.prose--dim { color: var(--ink-2); }

.sup {
  font-family: var(--font-text);
  font-weight: 300;
  font-size: 0.22em;
  vertical-align: super;
  letter-spacing: 0;
  color: var(--ink-3);
}

.flag { background: var(--flag); padding-inline: var(--s-1); }

/* ------------------------------------------------------------
   6. NAV
   ------------------------------------------------------------ */

.skip-link {
  position: absolute;
  left: var(--margin);
  top: var(--s-3);
  z-index: 300;
  padding: var(--s-2) var(--s-4);
  background: var(--ink);
  color: var(--bg);
  font-family: var(--font-text);
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--ls-label);
  transform: translateY(-250%);
  transition: transform var(--fast) var(--ease);
}
.skip-link:focus { transform: none; }

.nav {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: 100;
  height: var(--nav-h);
  display: grid;
  grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
  gap: var(--gutter);
  align-items: center;
  max-width: var(--max-w);
  margin-inline: auto;
  padding-inline: var(--margin);
  /* Content underneath varies wildly (light page, dark section, photos),
     so instead of picking a color that only works against some of it, the
     nav renders in white and gets diffed against whatever's behind it.
     mix-blend-mode: difference on white is a true per-pixel invert
     (255 - backdrop), which is why the mark and labels below are pushed to
     pure white/black rather than the page's --ink/--bg grays: anything
     less than full white leaves a residual color instead of a clean flip. */
  mix-blend-mode: difference;
  transition: transform 550ms cubic-bezier(0.55, 0, 0.35, 1);
}

/* Case-study reading progress: a hairline that fills left-to-right with
   scroll position. Sits above the nav (which is itself z-index 100) so it
   reads as the outermost edge of the page. Only ever appended to the DOM
   on pages with a .cs-hero — see the progress() IIFE in main.js. */
.progress {
  position: fixed;
  inset-block-start: 0;
  inset-inline-start: 0;
  z-index: 101;
  width: 0%;
  height: 3px;
  background: var(--ink);
  pointer-events: none;
}

.nav-brand { grid-column: 1 / span 2; display: flex; align-items: center; }

/* The header carries almost no weight: a mark, two columns of small caps, and
   a short statement. The current item and a hovered item share one
   vocabulary (a black highlight behind the label) rather than a current
   item marked one way and a hovered item another. The brand wordmark gets
   the identical treatment (§ below) rather than its own hover language. */
.nav-link, .nav-brand-text {
  position: relative;
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: #fff;
  line-height: 1.7;
  transition: color var(--fast) var(--ease), transform var(--mid) var(--ease);
}
/* Sized to a stacking context of its own (z-index -1 on a positioned parent)
   so it paints under the text rather than covering it. Background is #fff,
   not var(--ink): .nav runs mix-blend-mode: difference (above), and per
   that comment anything less than full white leaves a residual color
   instead of a clean flip. var(--ink) (#2b2b2b) diffed against the page
   comes out mid-grey, not black, which is exactly why this needs to match
   the same pure white the nav text itself uses. */
.nav-link::after, .nav-brand-text::after {
  content: "";
  position: absolute;
  inset: -0.1em -0.5em -0.08em -0.5em;
  background: #fff;
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--mid) var(--ease);
  z-index: -1;
}
/* #000, not var(--bg): .nav's difference blend flattens the pill and the
   text into one layer before it ever diffs against the page, so the pill
   being pure white doesn't make it a "backdrop" the text can contrast
   against locally; both still only diff against the true page behind the
   whole nav. The fix is picking source colors that land on opposite sides
   of that diff on their own: 0 and 255 are complementary for any backdrop
   (|0-bg| and |255-bg| always sit apart, extremes included), so black text
   over the white pill comes out light while the pill itself comes out dark.
   var(--bg) landed close to the page color itself, which diffed to
   near-zero and read as just as black as the pill. */
.nav-link.is-on,
.nav-link:hover, .nav-link:focus-visible,
.nav-brand:hover .nav-brand-text, .nav-brand:focus-visible .nav-brand-text { color: #000; }
.nav-link.is-on::after,
.nav-link:hover::after, .nav-link:focus-visible::after,
.nav-brand:hover .nav-brand-text::after, .nav-brand:focus-visible .nav-brand-text::after { transform: scaleX(1); }
.nav-link:hover, .nav-brand:hover .nav-brand-text { transform: translateY(-1px); }
.nav-n { color: var(--ink-3); margin-left: 0.35em; }

.nav-contact {
  grid-column: 11 / -1;
  grid-row: 1;
  justify-self: end;
}
/* The case menu (§12d) is a full-screen ink-ground overlay above the nav's
   old z-index, so without this the header just disappears under it.
   main.js already toggles [data-menu-open] on <html> for the trigger
   button's own state, reused here rather than adding a second flag. Raised
   past .menu's z-index only while open, not permanently, so it doesn't also
   climb above .skip-link (300) the rest of the time and start hiding the
   keyboard focus indicator that's meant to sit on top of it. No color
   override needed here anymore: .nav's difference blend already inverts
   against the menu's ink ground the same way it does against everything
   else underneath it. */
html[data-menu-open] .nav { z-index: 360; }

/* heroNameLockup() (further down this file's JS) ties navBrandText's
   opacity to scroll position via inline style — 0 until the giant hero
   name has scrolled into it, by design, since the giant name is still on
   screen doing that job below scroll 0. But the menu opens as a full-bleed
   curtain over the hero at any scroll position, including 0 — so opening
   it from the top of the page leaves nothing on screen reading "Ryan
   Graves" at all. !important beats the inline style JS keeps writing, and
   only applies while the menu is actually open, so scrolling back up after
   closing it still un-lands the wordmark exactly as before. */
html[data-menu-open] .nav-brand-text { opacity: 1 !important; }
/* heroNameLockup() also parks pointer-events: none on .nav-brand itself for
   the same mid-flight stretch (main.js) — a plain inline style, so it loses
   to nothing on its own. The menu-open case above forces the wordmark
   visible regardless of scroll, and this has to win the same way for the
   same reason: without it the now-visible wordmark sits there unclickable
   until the JS scroll state happens to catch up. */
html[data-menu-open] .nav-brand { pointer-events: auto !important; }

/* A navigating departure (§12d, pageWipe's openMenu branch) used to leave the
   header exactly where it was: .menu-inner wipes its rows away, then the
   real navigation tears the whole document down a beat later, cutting the
   header off mid-frame instead of letting it go anywhere. Set alongside
   .menu-leaving, on the same 550ms/easing as that wipe, so the header lifts
   clear of the viewport in step with the rows instead of just vanishing when
   the page unloads underneath it. */
html[data-menu-leaving] .nav { transform: translateY(-100%); }

.nav-links {
  grid-column: 8 / span 5;
  grid-row: 1;
  display: flex;
  gap: var(--s-6);
  align-items: center;
}

.nav a, .nav .nav a:hover { color: #fff; }

/* current-section marker */
.nav a[aria-current]::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -0.4rem;
  height: 1px;
  background: currentColor;
}
.nav a[aria-current] { color: #fff; }

/* ------------------------------------------------------------
   7. HERO
   ------------------------------------------------------------ */

.hero {
  /* Short of full viewport on purpose: the first case's cover art should
     peek in at the bottom edge as a scroll hint, rather than the case list
     staying fully hidden until the visitor scrolls. Subtracting
     --home-top-gap here cancels out <main>'s own top padding (.home, §8),
     so the peek amount below (6rem) is exactly how much of the first case
     is visible on load no matter how that gap itself is sized. Widening the
     gap under the name just trades hero height for whitespace 1:1 — the
     point where the case starts, and so the peek, doesn't move.
     The 6rem is also what sets the gap above the name: align-content: end
     below pins the name to the bottom of this box regardless of its own
     padding-top, so that gap is really "however much of this min-height
     isn't spoken for by the name/tag and the peek below" — shrinking the
     peek is what grows the space above, not the padding-top declaration.
     min-height is still a floor, not a fixed height, so on a viewport too
     short for the scaled-up name the section just grows past this and
     scrolls, rather than clipping anything.
     align-content: end plus a small fixed padding-bottom is what pins the
     name to the actual bottom edge inside that box; removing min-height
     earlier didn't fix a real gap-under-Graves bug, it just broke this. */
  min-height: calc(100svh - var(--home-top-gap) - 6rem);
  padding-block: calc(var(--nav-h) + var(--s-5)) var(--s-1);
  align-content: end;
}

/* Static, no pointer interaction: one shared size for both lines, each
   opening on its own column line rather than filling or opposing edges.
   Sized in vw so it scales with the viewport rather than sitting at a fixed
   rem step, capped well short of where "Graves" would run past its column
   at the site's max container width (847px word at the 15rem cap, against a
   ~958px budget at the 1440px max-width, see tests/fonttools measurement
   in the git history for how that budget was checked; the cap tracks
   --max-w's own 1728→1440 change by the same 5/6 ratio, since vw has no
   idea the grid stopped growing and would otherwise keep pushing the name
   past its column on anything wider than a 1440 laptop). The line-gap is a
   percentage of the same size, so it scales with it instead of sitting at a
   fixed token that would go slack once the name gets this much bigger. */
.hero-lead {
  position: relative;
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
  --hero-name-size: clamp(5rem, 3.5rem + 9.5vw, 15rem);
  row-gap: calc(var(--hero-name-size) * 0.015);
}
/* display: contents so l1/l2 become direct items of .hero-lead's own grid,
   the only way the tag below can share a row with just the first line rather
   than the whole two-line h1 box. */
.hero-headline {
  display: contents;
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  font-variation-settings: normal;
  font-size: var(--hero-name-size);
  line-height: 0.94;
  letter-spacing: -0.035em;
}
.hero-headline .l1 { grid-column: 1 / -1; grid-row: 1; display: block; text-align: left; white-space: nowrap; }
.hero-headline .l2 { grid-column: 5 / -1; grid-row: 2; display: block; text-align: left; white-space: nowrap; }

/* Plain body copy, not the small tracked label style: same size, weight
   and color as a paragraph elsewhere on the site. */
.hero-tag { grid-column: 8 / -1; grid-row: 1; align-self: center; }

/* Grid alignment (baseline, then end, then end with matched font metrics)
   went through four rounds without ever reliably landing flush against
   "Graves"; whatever the actual cause, guessing at CSS wasn't converging.
   heroCue() in main.js measures Graves' real rendered bottom-right corner
   with getBoundingClientRect() and writes it here as --cue-x/--cue-y, so
   this is now flush by measurement, not by theory. */
.hero-cue {
  position: absolute;
  left: var(--cue-x, auto);
  top: var(--cue-y, auto);
  /* no transform here on purpose: .hero-cue also carries .reveal, which
     owns transform for its own fade/rise-in animation. heroCue() bakes the
     -100%/-100% offset into --cue-x/--cue-y themselves (by subtracting the
     cue's own measured width/height) instead of applying it as a second
     transform here, which would just get clobbered by .reveal's. */
  color: var(--ink);
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 1rem + 2vw, 3rem);
  line-height: 0.94;
}

/* heroNameLockup() in main.js reads these two words' rendered per-letter
   position (via Range, not per-letter markup — see that function's own
   header comment) and font, then hides the whole word in place as a set of
   standalone per-letter flyers (.hero-name__flyer below) scatter off toward
   the nav wordmark. inline-block, not the plain inline this text would
   otherwise be, so its static metrics are identical to what
   getBoundingClientRect() expects — this is a display fix for accurate
   measuring, not for transformability: these two spans are never themselves
   scaled, translated, or rotated, only faded via the opacity written on
   them inline by that script. */
.hero-name__word { display: inline-block; }

/* One flyer per letter, created once in JS and left in <body> — not nested
   next to the real word — specifically so none of them ever has a
   transforming ancestor between it and the viewport (see heroNameLockup()'s
   own header comment for why that matters: a transformed ancestor becomes
   the containing block for a position: fixed descendant, which silently
   broke this exact effect for anyone scrolling immediately after a reload).
   position: fixed and opacity: 0 are the resting default here, not just the
   scroll-path state, so a flyer JS hasn't touched yet (or ever, under
   reduced motion, where none are even created) can never paint anything.
   Font/size/color start out copied from the real word's letter inline,
   per-instance, since a flyer outside .hero-headline inherits none of it
   from the cascade — the font then flips again mid-flight, toward the
   header's own, as part of the scatter itself (see that same comment). */
.hero-name__flyer {
  position: fixed;
  top: 0;
  left: 0;
  display: inline-block;
  transform-origin: top left;
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
}

/* The glyph-shape ripple's canvas, one per letter, overlaid on its plain
   text — see buildLetterWave() in main.js. position: absolute (not the
   default static a <canvas> would otherwise take) is load-bearing here,
   not cosmetic: an unsized <canvas> defaults to 300x150 CSS px, and left
   in normal flow that would inflate .hero-name__flyer's own measured
   natural size (natWidth/natHeight, read directly off this element) long
   before JS ever gets around to sizing the canvas correctly. Taken out of
   flow, its default size is irrelevant to anything until build time sets
   real dimensions. Starts at opacity: 0 for the same reason the flyer
   itself does: nothing here should paint before JS says so. */
.hero-name__flyer-wave {
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  pointer-events: none;
}

/* Double the standard section rhythm on both sides: this is the one break
   in the page meant to read as a pause, not just the usual gap between
   stacked sections. */
.intro { padding-block: calc(var(--section-y) * 2); }
.intro-lead { font-size: clamp(1.375rem, 1.15rem + 1vw, 2rem); }

/* ------------------------------------------------------------
   8. WORK INDEX
   ------------------------------------------------------------ */

/* subgrid, not a nested .grid: a nested grid recomputes its own columns from
   its own width and drifts off the parent's lines by up to ~46px at 1920 */
/* ---------- case index ----------
   A tall editorial row per case: index number and year/category caption the
   image the way a magazine spread does; a short description and the project
   title anchor the bottom. Text on the left 7 columns, artwork filling the
   right 5 full-bleed to the row's own height, not the 3:1 strip it used to
   be, so the cover SVGs sit centered in a much taller frame and letterbox
   rather than fill it. That's fine: they were already drawn with slack above
   and below for the scroll parallax, and .cover's own scale(1.02) plus
   object-position: center keeps the composition centered either way. */
.case-list { grid-column: 1 / -1; display: grid; }

.case { display: block; }
/* Past --s-11, the largest step on the scale, a plain bump wasn't reading
   as more air between rows this tall, so it's stacked with --s-7 instead of
   just picking a bigger unlisted number. */
.case + .case { margin-top: calc(var(--s-11) + var(--s-7)); }

/* The homepage list is the whole page's content, not a compact "related
   work" strip (see .next .case-list elsewhere), so it gets extra air under
   the hero (--home-top-gap, shared with .hero's peek math in §7) and again
   before the footer below — scoped to .home so case-study pages, including
   their own "related work" list, are untouched. Row-to-row spacing between
   the cards themselves is tighter than the base gap above, not looser: the
   .case-simple cards (§8b) are a compact cover-plus-title row, not the full
   text-and-image layout that gap was sized for. */
.home { padding-top: var(--home-top-gap); }
.home .case + .case { margin-top: var(--s-11); }
.home ~ .footer { margin-top: calc(var(--section-y) + var(--s-9)); }

/* Hovering one case used to dim the other three. Removed by request, and it was
   the right call: dimming the rest to signal focus punishes you for reading, and
   the border and cover art already carry the hover state. */

.case {
  position: relative;
  display: grid;
  grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
  gap: var(--gutter);
  min-height: 34rem;
}

.case-idx {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--t-display-3);
  letter-spacing: -0.02em;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* "[view]" rides the pointer instead of sitting in the layout, but only
   where there is a pointer to ride. On touch there is no hover state to
   attach it to and no cursor to sit beside, so it's simply not shown there;
   the whole card is already the tap target.

   Same invert as .nav (§6): color: #fff plus mix-blend-mode: difference is a
   true per-pixel invert (255 - backdrop), so the label reads black over the
   light page or a light cover and flips to white over a dark one, with no
   pill to carry a fixed background that would only be legible against half
   of what it can land on. Source has to be pure white, not var(--bg) — same
   reasoning as §6: anything less than full white leaves a residual color
   instead of a clean flip. */
@media (hover: hover) and (pointer: fine) {
  .case-go {
    position: absolute;
    left: 0; top: 0;
    z-index: 2;
    pointer-events: none;
    font-family: var(--font-text);
    font-weight: 500;
    font-size: var(--t-label);
    letter-spacing: var(--ls-label);
    text-transform: uppercase;
    color: #fff;
    mix-blend-mode: difference;
    opacity: 0;
    /* --gx/--gy are written by caseCursor() in main.js. Nudged clear of the
       cursor and centred on it vertically. Opacity is the only thing that
       transitions: easing the translate makes the label swim behind the
       pointer instead of tracking it. */
    transform: translate3d(calc(var(--gx, 0px) + 1.1rem), calc(var(--gy, 0px) - 50%), 0);
    transition: opacity var(--fast) var(--ease);
    will-change: transform;
  }
  .case:hover .case-go { opacity: 1; }
}

/* align-self: flex-start so the box hugs the title instead of stretching to
   the flex column's full width the way a block-level h3 normally would
   here: inline-block alone doesn't survive being a flex item, since a flex
   item's outer display gets blockified back to block regardless. */
.case-title {
  align-self: flex-start;
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  font-size: var(--t-display-2);
  letter-spacing: -0.028em;
  line-height: 0.98;
}

.case-media {
  grid-column: 6 / -1;
  background: var(--bg-sunken);
  overflow: hidden;
  position: relative;
  /* the cover parallaxes inside this box, so it has to be the clipping frame */
  transition: border-color var(--mid) var(--ease), box-shadow var(--mid) var(--ease-spring);
  /* a sheet of paper never lies flush with the page under it: the first layer
     is a small constant shadow so the cover reads as resting slightly above
     the ground even at rest; the second layer is --lift, written per frame by
     mediaScroll() from scroll velocity, so the sheet visibly rises as it's
     caught mid-scroll and settles back down (via the transition above, not a
     rAF write) once you stop. box-shadow paints outside the border box, so
     `overflow: hidden` above doesn't clip it. */
  box-shadow:
    0 0.5rem 1rem -0.6rem color-mix(in srgb, var(--ink) 14%, transparent),
    0 1.75rem 3rem -1rem color-mix(in srgb, var(--ink) var(--lift, 0%), transparent);
}
.case-media-inner { position: absolute; inset: 0; }

/* a case-study cover with no shot yet: the same "clean plate" hatch treatment
   keeps an empty card reading as designed rather than broken. Static —
   unlike shotFallback() there's no image request to fail, so nothing needs
   to detect the miss at runtime. */
.case-media[data-empty]::after {
  content: "";
  position: absolute; inset: 0;
  background:
    repeating-linear-gradient(135deg, transparent 0 18px,
      color-mix(in srgb, var(--ink) 4%, transparent) 18px 19px);
}

/* There was a paper-grain layer here -- two fine crosshatched line-gradients
   over every cover and every case-study hero. Removed 17 Aug 2026: at a 3-4px
   line pitch it was only ~5/255 per pixel but it covered 74% of them in a
   regular weave, and on a zoomed screenshot of a hero it read unmistakably as
   a grid laid over the photograph rather than as texture in it. It was also
   inconsistent by construction -- suppressed under .has-wave, so it only ever
   appeared on covers that had no canvas (reduced motion, coarse pointer) and
   on the heroes, which is exactly where it got noticed.

   Nothing replaces it: the wave is the material cue this was standing in for.
   Removing it also means caseFlight()'s crossfade has one less thing it cannot
   match, since a canvas had no equivalent for it. */

/* A sheet catches light as it moves: a soft diagonal highlight, faded in via
   --sheen (written by mediaScroll() from the same scroll velocity that drives
   --lift above), so the cover glints briefly while it's in motion and goes
   dark again at rest. Excludes [data-empty] cards, which already own ::after
   for the hatch above -- an element only gets one. */
.case-media:not([data-empty])::after {
  content: "";
  position: absolute; inset: 0;
  z-index: 1;
  pointer-events: none;
  mix-blend-mode: overlay;
  background: linear-gradient(115deg,
    transparent 32%,
    color-mix(in srgb, #fff 55%, transparent) 48%,
    transparent 64%);
  opacity: var(--sheen, 0);
}

@media (max-width: 64rem) {
  .case-media { grid-column: 1 / -1; margin-top: var(--s-6); aspect-ratio: 3 / 2; }
  .case { min-height: 0; }
}

@media (max-width: 40rem) {
  .case-title { font-size: var(--t-display-3); }
  .case + .case { margin-top: calc(var(--s-9) + var(--s-5)); }
  /* .home .case + .case (§8) outweighs the rule above on specificity alone,
     so without this override the homepage would keep its desktop-sized
     11rem gap even on a phone. Tighter than the plain mobile gap above, same
     "compact card" reasoning as the desktop rule it mirrors. */
  .home .case + .case { margin-top: var(--s-9); }
}

/* Cover art, then just the index and title on one row underneath — no year,
   tags or description. Used for both the homepage list and the single
   next-project card at the foot of each case study, so the two read as the
   same component (see .next .case-list below).

   .case-list-simple subgrids the page's own 12 columns rather than
   recomputing its own, same reasoning as .work-row above — a nested grid
   drifts off the parent's lines by up to ~46px at 1920. Each card gets an
   explicit start line rather than a bare "span 7" — staggered per card by
   request, and a fixed start is also what forces one per row instead of
   auto-placement packing a second card into whatever's left on the row. */
.case-list-simple { grid-template-columns: subgrid; }
.case-simple {
  /* subgrids again, one level down: .case-row below places the title on
     the card's own 3rd column (not the page's), which only lines up with
     a real column edge if this card's local columns are the same tracks
     the outer subgrid already resolved for it. */
  display: grid;
  grid-template-columns: subgrid;
  min-height: 0;
}
.case-list-simple .case-simple:nth-child(1) { grid-column: 2 / span 7; }
.case-list-simple .case-simple:nth-child(2) { grid-column: 6 / span 7; }
.case-list-simple .case-simple:nth-child(3) { grid-column: 4 / span 7; }

/* The next-project card is a list of one, not three, so the homepage's
   stagger (above) has nothing to stagger against — full width instead.
   Four selector components outweighs the three-component rule above on
   specificity alone, so this wins at every viewport without needing the
   64rem override below. */
.next .case-list-simple .case-simple:nth-child(1) { grid-column: 1 / -1; }

/* 7 columns staggered off the left edge has no equivalent at 8 or 4 cols —
   full width instead, same fallback .col-main/.col-text etc. already use
   at this breakpoint. Matches the base rules' selector weight so it wins
   on source order rather than needing !important. */
@media (max-width: 64rem) {
  .case-list-simple .case-simple:nth-child(1),
  .case-list-simple .case-simple:nth-child(2),
  .case-list-simple .case-simple:nth-child(3) {
    grid-column: 1 / -1;
  }
}
.case-simple .case-media {
  grid-column: 1 / -1;
  /* a deliberate ratio per case (all 4:3, for now), written inline as --ar
     (§9b's shots use the same pattern) rather than three near-duplicate
     rules here. .cover's object-fit: cover crops each screenshot to fit. */
  aspect-ratio: var(--ar, 4 / 3);
  margin-top: 0;
}
.case-row {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 1 / -1;
  align-items: baseline;
  gap: var(--gutter);
  margin-top: var(--s-3);
}
.case-simple .case-idx,
.case-simple .case-title {
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-small);
  letter-spacing: var(--ls-label);
  /* .case-idx is a span with no line-height of its own, so it falls back to
     the page default (~1.6) while .case-title inherits the base .case-title
     rule's 0.98 — two different amounts of leading above the text, which
     .case-row's align-items: baseline can't correct since it aligns the
     baseline, not the line box. Matching them here puts both baselines at
     the same distance from the row's top so they read as one line. */
  line-height: 1;
}
.case-simple .case-idx {
  grid-column: 1;
  color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}
.case-simple .case-title {
  /* left edge pinned to the card's own 3rd column, whatever page column
     that lands on for a given card's stagger — not a fixed offset. */
  grid-column: 3 / -1;
  color: var(--ink);
  text-transform: uppercase;
}

/* the next-project link at the foot of a case study is still a row */
.work-row { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; gap: var(--gutter); align-items: start; }
.work-row .work-idx { grid-column: 1 / span 1; color: var(--ink-3); }
.work-row .work-body { grid-column: 2 / span 6; }
.work-row .work-thumb { grid-column: 9 / -1; }
.work-row .work-thumb-inner {
  position: relative;
  aspect-ratio: 3 / 2;
  overflow: hidden;
  background: var(--bg-sunken);
  border: 1px solid var(--rule);
}
.work-row .work-title { transition: transform var(--mid) var(--ease); }
.work-row:hover .work-title { transform: translateX(var(--s-2)); }

.work-facts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-4);
  color: var(--ink-3);
  align-items: baseline;
}
.work-facts b { font-weight: 500; color: var(--ink-2); }
.work-idx {
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.work-title {
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  font-size: clamp(1.5rem, 0.95rem + 1.5vw, 2.375rem);
  letter-spacing: -0.025em;
  line-height: 1.04;
  transition: transform var(--mid) var(--ease);
}

@media (prefers-reduced-motion: reduce) {
  .img-wipe { clip-path: none; }
}

:where(a, button, input):focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

/* ------------------------------------------------------------
   9. CASE STUDY
   ------------------------------------------------------------ */

/* The title leads now, full width and on its own row, after
   jasonjerez.com/case: title first, then an image/details row below it,
   rather than a full-bleed image band with the title sized to sit under it. */
/* +4rem (64px) of extra top clearance on top of nav-h, asked for
   explicitly, on top of the breathing room --s-6 already gave it. */
.cs-hero { padding-block: calc(var(--nav-h) + var(--s-6) + 8rem) var(--s-8); row-gap: var(--s-7); }
/* text-wrap: balance was still reading as off-left even with text-align set
   explicitly: balance is meant to leave alignment alone, but it's young
   enough across browsers to not trust blind, and it's not worth keeping
   next to a real alignment complaint twice in a row. Lines just wrap at
   their natural point now. */
.cs-title {
  grid-column: 1 / -1;
  text-align: left;
  font-size: clamp(2.75rem, 1.25rem + 6.5vw, 7rem);
  /* .cs-title also carries .display-1, which sets text-wrap: balance in its
     own shared rule (§4); removing the redundant copy that used to be here
     didn't touch that one, so balance was still silently winning the
     cascade. This has to actively override it, not just stay silent. */
  text-wrap: wrap;
  /* This is the actual "weird alignment" bug, found by rendering the page
     in a real browser and measuring pixels rather than guessing a fourth
     time: inter-word spacing was a margin-left on the .mask + .mask sibling
     pair. A margin on an inline box does not get discarded when that box
     wraps onto a new line, so whichever word started the second line was
     still carrying its left margin from no longer being adjacent to the
     word before it, and sat indented relative to the first line. word-
     spacing targets the actual whitespace character between the masks
     instead of a box edge, and a space character at a line-wrap point
     collapses to nothing per the CSS spec, so wrapped lines land flush
     left with no per-title-length luck involved. */
  word-spacing: 0.26em;
}
.cs-title .mask { display: inline-block; }

/* Details now sit between the title and the image, not beside or under it:
   the meta list comes first in the row, the image comes second. Company,
   Role and Date each take a narrow slice on the left; Summary (labeled
   like the other three now, not a bare paragraph) claims the right half
   outright via the same subgrid rather than growing into it as leftover
   flex space. */
.cs-head-row {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
  row-gap: var(--s-6);
}
.cs-meta {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
  column-gap: var(--gutter);
  row-gap: var(--s-6);
}
.cs-meta > div { display: flex; flex-direction: column; }
.cs-meta > div:nth-child(1) { grid-column: 1 / 3; }
.cs-meta > div:nth-child(2) { grid-column: 3 / 5; }
.cs-meta > div:nth-child(3) { grid-column: 5 / 7; }
.cs-meta > div:last-child   { grid-column: 7 / -1; }
.cs-meta dt { margin-bottom: var(--s-2); }
.cs-meta dd { font-size: var(--t-small); }
/* No max-width here on purpose: this item already gets an explicit 6-column
   span above (grid-column: 7 / -1), not leftover flex space to grow into, so
   a ch cap was just fighting that and leaving the column half-empty. */
.cs-meta .cs-desc { font-size: var(--t-small); color: var(--ink-2); line-height: 1.55; }

/* --- the hero image swallows the copy on the way up ---
   Asked for directly: the title and details hold their place while the hero
   image scrolls up over them, and once it has passed they are gone for the
   rest of the page -- "stuck behind the hero image."

   Not `position: sticky`, which is the obvious reach and does not work here.
   A sticky grid item is confined to its own grid area, and .cs-title's area is
   exactly one row tall, so it has nowhere to stick to; the travel it needs
   spans the image's row as well. heroPin() in main.js writes --pin instead,
   which also gives the release its real condition (the image's bottom edge)
   rather than a hand-tuned distance.

   `translate`, not `transform`: .cs-meta carries .reveal, whose entrance IS a
   transform (§12d), and writing transform here would clobber it. `translate`
   is an independent property that composes with transform rather than
   replacing it, so the entrance still plays and the pin rides on top of it.
   With no JS --pin stays 0 and the hero is an ordinary section.

   The occlusion is paint order, not z-index. Both pinned elements get a
   stacking context from `translate` and .cs-hero-media gets one from its own
   tilt transform, all at z-index auto, so they paint in tree order and
   .cs-hero-media is last. A z-index here would buy nothing and start a fight
   with the nav's (§12). */
.cs-title, .cs-hero .cs-meta { translate: 0 var(--pin, 0px); }
@media (prefers-reduced-motion: reduce) {
  .cs-title, .cs-hero .cs-meta { translate: none; }
}

/* The sticky section index is gone by request. With nothing in the left
   columns the content no longer has to hug the right edge, so it runs the
   full width and the figures breathe. */
.cs-layout { row-gap: var(--s-8); }
.cs-content { grid-column: 1 / -1; }
.cs-section + .cs-section { margin-top: var(--s-9); }
.cs-section > .label { display: block; margin-bottom: var(--s-5); }

.figure { background: var(--bg-sunken); width: 100%; }
.figure + .figure { margin-top: var(--gutter); }
.figure-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gutter); margin-top: var(--s-6); }
.figure-row--3 { grid-template-columns: repeat(3, 1fr); }

/* numbered cards */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: var(--gutter); margin-top: var(--s-6); }
.card { border-top: 1px solid var(--rule); padding-top: var(--s-4); }
.card .n { font-family: var(--font-text); font-size: var(--t-label); color: var(--ink-3); }
.card h3 { font-size: var(--t-body); font-weight: 600; letter-spacing: -0.01em; margin-block: var(--s-3) var(--s-2); }
.card p { font-size: var(--t-small); color: var(--ink-2); line-height: 1.5; text-wrap: pretty; }

/* stats — rides the page's own 12-track grid (inherited via .cs-copy's
   subgrid) rather than an independent auto-fit track, so "2 columns each"
   means the same thing here as it does for a .shot span: a real grid
   measure, not flex-stretched columns with growing gaps between them. */
.stats { display: grid; grid-template-columns: subgrid; grid-column: 4 / span 6; gap: var(--s-7) var(--gutter); margin-top: var(--s-6); }
.stat { grid-column: span 2; }
@media (max-width: 40rem) {
  .stats { grid-template-columns: 1fr; }
  .stat { grid-column: 1 / -1; }
}
.stat .n {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(2.75rem, 5vw, 4.5rem);
  letter-spacing: var(--ls-tight);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.stat p { margin-top: var(--s-3); font-size: var(--t-small); color: var(--ink-2); line-height: 1.5; }

/* stats laid over a photo instead of sitting below it. The figure becomes
   the positioning context (.shot-frame keeps its own aspect-ratio/overflow
   untouched, so the crop is unaffected); the block gets its own plain 3-up
   grid rather than the page subgrid above, since there's no page grid track
   to inherit inside a figure. A hairline between each stat reads as the
   "one column of separation" a bare grid gap wasn't giving them, and a
   gradient scrim keeps the white text legible over whatever's underneath.
   Below 40rem there's rarely enough photo height for three stacked stats to
   sit on top of it without eating the crop, so the block drops out of the
   overlay and back into normal flow below the image, dark-on-light like
   every other stats block on the site. */
.shot--stats-overlay { position: relative; }
.stats--overlay {
  position: absolute;
  inset: auto 0 0 0;
  grid-template-columns: repeat(3, 1fr);
  grid-column: auto;
  gap: 0;
  margin-top: 0;
  padding: var(--s-8) var(--s-6) var(--s-6);
  background: linear-gradient(to top, rgba(20, 18, 15, 0.78) 0%, rgba(20, 18, 15, 0.42) 60%, transparent 100%);
}
.stats--overlay .stat { grid-column: auto; padding-inline: var(--s-5); border-left: 1px solid rgba(245, 244, 242, 0.3); }
.stats--overlay .stat:first-child { border-left: none; padding-left: 0; }
.stats--overlay .stat:last-child { padding-right: 0; }
.stats--overlay .n { color: #f5f4f2; }
.stats--overlay .stat p { color: rgba(245, 244, 242, 0.82); }
@media (max-width: 40rem) {
  .stats--overlay { position: static; grid-template-columns: 1fr; background: none; padding: var(--s-6) 0 0; }
  .stats--overlay .stat { grid-column: 1 / -1; border-left: none; padding-inline: 0; border-top: 1px solid var(--rule); padding-top: var(--s-5); }
  .stats--overlay .stat:first-child { border-top: none; padding-top: 0; }
  .stats--overlay .n { color: var(--ink); }
  .stats--overlay .stat p { color: var(--ink-2); }
}

/* before/after pairs: the honest version of a stat block when the change is
   real but nobody instrumented it */
.shifts { margin-top: var(--s-6); display: grid; gap: 0; }
.shift {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2.2fr);
  gap: var(--s-4) var(--s-5);
  padding-block: var(--s-5);
  border-top: 1px solid var(--rule);
}
.shift:last-child { border-bottom: 1px solid var(--rule); }
.shift dt { font-family: var(--font-display); font-size: var(--t-small); letter-spacing: -0.01em; }
.shift dd { margin: 0; display: grid; gap: var(--s-2); }
.shift .was, .shift .now { display: block; font-size: var(--t-small); }
.shift .was { color: var(--ink-3); text-decoration: line-through; text-decoration-thickness: 1px; }
.shift .now { color: var(--ink); }
.shift .now::before { content: "\2192  "; color: var(--ink-3); }

/* decision record */
.decision { margin-top: var(--s-6); border: 1px solid var(--rule); background: var(--bg-sunken); }
.decision-head {
  display: flex; justify-content: space-between; gap: var(--s-4);
  padding: var(--s-3) var(--s-4); border-bottom: 1px solid var(--rule);
}
.decision-q {
  font-family: var(--font-display); font-weight: 600;
  font-size: var(--t-body-lg); letter-spacing: -0.02em;
  padding: var(--s-5) var(--s-5) var(--s-4);
}
.tried { list-style: none; padding: 0 var(--s-5) var(--s-5); display: grid; gap: var(--s-4); }
.tried li { display: grid; grid-template-columns: 2.5rem 1fr; gap: var(--s-3); align-items: start; }
.tried .v { font-family: var(--font-text); font-size: var(--t-label); color: var(--ink-3); padding-top: 0.2em; font-variant-numeric: tabular-nums; }
.tried h3 { font-size: var(--t-small); font-weight: 600; }
.tried .why { font-size: var(--t-small); color: var(--ink-2); line-height: 1.5; margin-top: var(--s-1); }
.tried .kept h3::after {
  content: "Kept";
  margin-left: var(--s-3);
  font-family: var(--font-text); font-weight: 500;
  font-size: var(--t-label); letter-spacing: var(--ls-label);
  text-transform: uppercase; color: var(--bg);
  background: var(--ink); padding: 0.15em 0.5em;
}

/* A takeaway ties a case study to the essay it produced. Deliberately small:
   it should read as a margin note, not another section. */
.takeaway {
  margin-top: var(--s-6);
  padding-left: var(--s-5);
  border-left: 2px solid var(--ink);
  display: grid;
  gap: var(--s-2);
  max-width: 54ch;
}
.takeaway p {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--t-body-lg);
  letter-spacing: -0.015em;
  line-height: 1.28;
  text-wrap: pretty;
}
.takeaway a {
  justify-self: start;
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-3);
  transition: color var(--fast) var(--ease);
}
.takeaway a:hover { color: var(--ink); }
.takeaway a .arw { display: inline-block; transition: transform var(--mid) var(--ease); }
.takeaway a:hover .arw { transform: translateX(4px); }

/* horizontal rails */
.rail-wrap { margin-top: var(--s-6); }
.rail-head { display: flex; justify-content: space-between; gap: var(--s-4); margin-bottom: var(--s-3); }
.rail {
  display: flex; gap: var(--gutter);
  overflow-x: auto; scroll-snap-type: x mandatory;
  scrollbar-width: none; padding-bottom: var(--s-2);
}
.rail::-webkit-scrollbar { display: none; }
.rail > * { flex: 0 0 min(22rem, 76%); scroll-snap-align: start; }
/* dragging an <img> starts a native drag-and-drop, which cancels the pointer
   stream main.js's drag-scroll relies on; see .portrait-draw img for the
   same fix applied to the sketch canvas. */
.rail img { -webkit-user-drag: none; user-select: none; }
.rail--tall > * { flex-basis: min(28rem, 84%); }

.next { padding-block: var(--section-y); }
/* the next project is the same object as a row on the homepage, so it is the
   same component, which also means it inherits the cover transition for free */
.next .case-list { margin-top: var(--s-6); }

/* Sits between the last section of a case study and the next-project card,
   so a reader who has scrolled the whole page has a way back up without
   using the browser chrome. */
.cs-top-row { display: flex; justify-content: center; padding-top: var(--s-8); }
.cs-top {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink);
  background: none;
  border: none;
  padding: var(--s-2);
  cursor: pointer;
}
.cs-top-arrow { transition: transform var(--fast) var(--ease); }
.cs-top:hover .cs-top-arrow { transform: translateY(-2px); }

/* Same black-highlight vocabulary as the nav items and .footer-social's
   LinkedIn/Figma links, not the plain underline .u-link uses inline: a
   button reads more like those pill-style controls than body copy. */
.cs-top-text {
  position: relative;
  transition: color var(--fast) var(--ease);
}
.cs-top-text::after {
  content: "";
  position: absolute;
  inset: -0.15em -0.35em;
  background: var(--ink);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--mid) var(--ease);
  z-index: -1;
}
.cs-top:hover .cs-top-text { color: var(--bg); }
.cs-top:hover .cs-top-text::after { transform: scaleX(1); }
.credits { font-size: var(--t-small); color: var(--ink-2); line-height: 1.6; max-width: var(--measure); text-wrap: pretty; }
.credits strong { font-weight: 600; color: var(--ink); }
.sub { display: block; margin-bottom: var(--s-2); }
.wide { grid-column: 1 / -1; }
.mono, .num { font-variant-numeric: tabular-nums; }

/* ------------------------------------------------------------
   9b. SHOTS: real UI, at size
   ------------------------------------------------------------ */

/* The case studies lead with finished screens rather than diagrams. One
   element does all of it: <figure class="shot shot--plate">. The modifier sets
   the column span, and an inline --ar (written into the markup from the real
   pixel dimensions in images/manifest.json) reserves the exact height before
   the file arrives. Reserving that space is not a nicety: without it every
   image on the page shifts the layout as it decodes, which on a page that is
   mostly images means the whole thing walks around under the reader. */

.shots { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; row-gap: var(--s-6); }
.shots + .shots { margin-top: var(--s-6); }

.shot { grid-column: 2 / span 10; margin: 0; }
.shot-frame {
  position: relative;
  aspect-ratio: var(--ar, 16 / 9);
  overflow: hidden;
  background: var(--bg-sunken);
}
.shot-frame img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* contain, not cover: these are screenshots of real UI and cropping one is
     losing part of the argument. The frame's --ar comes from the file's own
     pixel dimensions, so contain and cover agree to the pixel anyway; contain
     just means a rounding error shows a hairline of ground instead of eating
     a hairline of interface. */
  object-fit: contain;
  object-position: var(--pos, 50% 50%);
}
/* the exception: a photograph, which is art-directed and meant to be cropped */
.shot--crop .shot-frame img { object-fit: cover; }
/* a screenshot of a whole page is read, not cropped */
.shot--fit .shot-frame img { object-fit: contain; }
/* file not downloaded yet: keep the reserved plate, drop the broken glyph */
.shot-frame[data-missing] { background: var(--bg-sunken); }
.shot-frame[data-missing]::after {
  content: "";
  position: absolute; inset: 0;
  background:
    repeating-linear-gradient(135deg, transparent 0 18px,
      color-mix(in srgb, var(--ink) 4%, transparent) 18px 19px);
}
.shot--fit .shot-frame { background: var(--bg-sunken); }

/* a mat around the asset instead of a flush fill: for diagrams, mockups and
   UI captures that read as an object sitting on the grey ground rather than
   a screenshot meant to fill the frame edge to edge */
.shot--pad .shot-frame img {
  top: var(--shot-pad, clamp(1.5rem, 5vw, 3.5rem));
  left: var(--shot-pad, clamp(1.5rem, 5vw, 3.5rem));
  width: calc(100% - 2 * var(--shot-pad, clamp(1.5rem, 5vw, 3.5rem)));
  height: calc(100% - 2 * var(--shot-pad, clamp(1.5rem, 5vw, 3.5rem)));
}

/* the opposite of --pad: no mat, no fill, no frame at all — the asset sits
   directly on the page canvas, same as a rail plate but outside a rail */
.shot--bare .shot-frame { background: none; border-radius: 0; }

/* true edge-to-edge across all 12 tracks, not the 10-of-12 "full measure"
   .shot--bleed already gives everything else */
.shot--full { grid-column: 1 / -1; }

.shot figcaption {
  margin-top: var(--s-3);
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-3);
  display: flex;
  justify-content: space-between;
  gap: var(--s-4);
}

/* --- spans ---------------------------------------------------------------
   plate  the default: as wide as the body ever gets, one column of margin
          either side; nothing here spans the full 12 or breaks past them
   bleed  no longer a true bleed (that broke the "stays in the grid" rule);
          same width as plate now, kept as its own class for markup that
          still asks for a hero moment rather than rewriting every case page
   half / third  side by side, on real columns
   tall   a portrait screen, so it does not become a skyscraper
   inset  narrower than the measure, for a single component rather than a page */
.shot--bleed { grid-column: 2 / span 10; }
.shot--half  { grid-column: span 6; }
.shot--third { grid-column: span 4; }
.shot--inset { grid-column: 3 / span 8; }
.shot--tall  { grid-column: 4 / span 6; }

/* --- the horizontal rail -------------------------------------------------
   For a sequence (a flow, a set of iterations) where the point is that
   there are many of them. Scroll-snap plus the existing drag-scroll handler.
   The track itself used to run to the viewport edge so the last card didn't
   look clipped by the margin; it's inset like everything else now, so the
   clipped-card feeling is just the ordinary cue that there's more to scroll. */
.shot-rail {
  grid-column: 2 / span 10;
  display: flex;
  /* wider than the grid's own --gutter on purpose: most rail plates are now
     flush/bare (no frame, no background — see .shot-rail .shot-frame), so a
     column-gutter's worth of space reads as one plate bleeding into the
     next rather than as two distinct cards. */
  gap: var(--s-6);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
.shot-rail::-webkit-scrollbar { display: none; }
.shot-rail img { -webkit-user-drag: none; user-select: none; }
.shot-rail > .shot {
  grid-column: auto;
  flex: 0 0 min(38rem, 78vw);
  scroll-snap-align: start;
}
.shot-rail--tight > .shot { flex-basis: min(22rem, 62vw); }

/* a rail is a filmstrip, not a stack of cards — each plate sits bare on the
   page canvas, no mat and no frame, so the sequence reads as one continuous
   strip rather than a row of boxed thumbnails */
.shot-rail .shot-frame { background: none; border-radius: 0; }

/* --- auto-scrolling rail ---------------------------------------------------
   A rail with too many plates to expect anyone to drag through by hand: the
   bill breakdown, the cost-card iterations. main.js clones the plates once and
   drives scrollLeft on a rAF loop instead of scroll-snap, so it needs snap off
   and its own grab affordance; the loop itself slows (not stops) on hover and
   yields to any real scroll/drag input.

   Unlike a plain shot-rail, this one runs edge to edge, past .grid's own
   margin AND past its max-width, all the way to the true viewport edge on a
   screen wider than 1440, so a motion piece reads as a strip of film rather
   than another inset card. grid-column takes every track so it still
   participates in the section's row order, then width/margin break it out
   with a pixel calc rather than a percentage: percentage margins on a grid
   item resolve against the item's own track, not the viewport, and would
   drift the edge on every screen wider or narrower than the grid's content
   box. body keeps overflow-x: hidden (§2) as the backstop for any rounding. */
.shot-rail--auto {
  grid-column: 1 / -1;
  width: 100vw;
  margin-inline: calc(-1 * (max(0px, (100vw - var(--max-w)) / 2) + var(--margin)));
  scroll-snap-type: none;
  cursor: grab;
}
.shot-rail--auto.dragging { cursor: grabbing; }

/* --- before / after ------------------------------------------------------ */
.shot-pair { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; row-gap: var(--s-6); }
.shot-pair > .shot { grid-column: span 6; }
.shot-pair .shot-tag {
  display: block;
  margin-bottom: var(--s-3);
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-3);
}

/* Shots ride the same parallax and velocity skew as the case covers, so a page
   of screenshots moves as one piece rather than as a stack of static blocks. */
.shot-frame {
  transform: skewY(var(--sk, 0deg));
  transform-origin: 50% 50%;
  will-change: transform;
}
/* Shots do NOT parallax. The frame is sized to the screenshot exactly, so any
   offset needs a scale to cover the travel, and a 6% scale on a UI screenshot
   crops the edge of the interface, which is the one thing these images exist
   to show. The frame still takes the velocity skew, and because the frame
   clips its own contents that costs nothing. */
.shot-frame img { transform: none; }
@media (prefers-reduced-motion: reduce) {
  .shot-frame { transform: none; }
  .shot-frame img { transform: none; }
}

/* --- section rhythm ------------------------------------------------------
   Copy is narrow and short; the images are wide and many. That contrast is
   the whole layout. */
.cs-copy { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; }
.cs-copy > * { grid-column: 4 / span 6; }
.cs-copy > .label { grid-column: 1 / span 2; margin-bottom: 0; }
/* label and prose set at different sizes/line-heights, so top-aligning the
   grid row leaves the label sitting above the paragraph's first baseline.
   Scoped to the section tag so it skips the stats variant (a bare
   .cs-copy div with an empty label slot), which has no text baseline to
   align to. */
section.cs-copy { align-items: baseline; }
.cs-copy p + p { margin-top: var(--s-4); }
.cs-copy + .shots, .cs-copy + .shot-rail, .cs-copy + .shot-pair { margin-top: var(--s-7); }
/* two copy blocks in a row (Outcome then Reflection, a stat row after its
   heading) need the same air as a copy block following an image, or they read
   as one run-on section */
.cs-copy + .cs-copy { margin-top: var(--s-9); }
.cs-copy + .cs-part { margin-top: 0; }
.shots + .cs-copy, .shot-rail + .cs-copy, .shot-pair + .cs-copy { margin-top: var(--s-10); }

@media (max-width: 64rem) {
  .shot--inset, .shot--tall { grid-column: 1 / -1; }
  .cs-copy > * { grid-column: 1 / -1; }
  .cs-copy > .label { grid-column: 1 / -1; margin-bottom: var(--s-3); }
}
@media (max-width: 40rem) {
  .shot--half, .shot--third { grid-column: 1 / -1; }
  .shot-pair > .shot { grid-column: 1 / -1; }
  .shot-rail > .shot { flex-basis: 86vw; }
}

/* The rebuilt case body: copy blocks and shots as direct grid children, no
   inner wrapper. .cs-layout / .cs-content are gone with the side nav. */
.cs-body { row-gap: 0; padding-block: var(--section-y); }

/* --- part breaks, numbered lists, prototype links -----------------------
   Cases 03 and 04 are transcribed from the live site, which chapters the long
   one into two Parts and uses numbered lists in a few places. */
.cs-part {
  grid-column: 1 / -1;
  padding-block: var(--s-10) var(--s-7);
  margin-top: var(--s-10);
}
.cs-part .label { display: block; margin-bottom: var(--s-4); }
.cs-part .display-3 { max-width: 20ch; }
.cs-part > div { margin-top: var(--s-5); max-width: var(--measure); }

.numlist { list-style: none; margin-top: var(--s-5); }
.numlist li {
  display: grid;
  grid-template-columns: 3rem 1fr;
  gap: var(--gutter);
  align-items: baseline;
  padding-block: var(--s-4);
  font-size: var(--t-small);
}
.numlist .label { color: var(--ink-3); }

.proto-link {
  display: inline-block;
  margin-top: var(--s-4);
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
}

/* A figure the live site animates from a data attribute whose digits are not
   in the page text. Flagged rather than invented: fill these in or cut them. */
.stat .n .flag { font-size: 0.6em; vertical-align: 0.15em; }

/* ------------------------------------------------------------
   9c. THE FIGMA FILE: a canvas you can zoom into
   ------------------------------------------------------------ */

/* Nick asked for the zoomed-out Figma view three times, and the reason he
   gave is the reason this is interactive rather than a screenshot: "easy to
   judge someone based off how they keep their Figma file." A flat export of
   a hundred frames at page width is an argument you have to take on faith —
   every frame is four millimetres tall and unreadable. The zoom is what turns
   it into evidence. Resting state says "there was a lot of work"; zoomed in
   says "and here is what it actually said."

   Deliberately NOT an iframe of the real file. An embed is someone else's
   loading spinner, someone else's auth wall, and a scroll container that
   fights the page. This is one image and about a hundred lines of transform
   math, so it costs a request and behaves.

   The stage does not touch the wheel. Zoom is on explicit buttons (plus
   trackpad pinch, which arrives as ctrl+wheel and is already exempted by
   easeScroll in §"the page eases its own scroll"). A reader who scrolls past
   this thing scrolls past it; nothing captures them. That is the whole reason
   the buttons exist instead of the more Figma-authentic wheel gesture. */

.figfile { grid-column: 2 / span 10; margin: 0; }
.figfile--full { grid-column: 1 / -1; }

.figfile-stage {
  position: relative;
  /* All twelve columns, and no further: this stays inside the grid like
     everything else on the page. (It ran to the viewport edge for one round —
     the .shot-rail--auto breakout — and the extra width bought space around
     the file that the mat below now provides on its own, without putting a
     second edge-rule on the page.)

     Definite width, and it has to be: with an auto width the max-height below
     makes the box shrink on BOTH axes to keep the ratio, so a tall file came
     out 979px wide in a 1350px column with the page showing through either
     side. A definite width is what lets max-height clamp the height alone. */
  width: 100%;
  aspect-ratio: var(--figfile-ar, 16 / 9);
  /* A file whose own aspect is nearly square would otherwise open a stage
     taller than the viewport, and the reader has to scroll the page to see
     the bottom of a thing they are supposed to be panning around inside.
     Capping the height letterboxes the file horizontally instead, which is
     free: the mat is computed from the stage box that actually resulted, so
     the ground either side just becomes more canvas. */
  max-height: 78vh;
  overflow: hidden;
  /* Flat, and very close to the page's own --bg (#f5f4f2) on purpose. With the
     caption gone the hairline is the only thing marking where the canvas
     starts, so it has to be visible — but only just. Lighter than --rule
     (0.14), which is the site's hairline for things that are being separated;
     this one is only saying "the surface continues past here". */
  background: #f5f5f5;
  border: 1px solid rgba(43, 43, 43, 0.08);
  /* the canvas grid. Two hairline sets at 1/8 opacity, 24px apart, which is
     close enough to Figma's own 8px-at-a-glance texture to be read as "a
     design tool" without anyone consciously identifying it. It sits under the
     art, scaled with nothing, because a grid that zoomed WITH the art would
     read as part of the artwork rather than as the surface under it. */
  /* at rest the page scrolls straight through a vertical swipe; JS flips this
     to `none` only once there is somewhere to pan to (see .is-zoomed) */
  touch-action: pan-y;
  cursor: default;
}
.figfile-stage:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

.figfile-art {
  position: absolute;
  /* Inset zero, and the mat applied as a SCALE in the transform instead (see
     FIT in main.js). The obvious version — a px inset on all four sides — is
     what shipped first and it silently halved the pan range: insetting a
     1350x844 stage by 4rem gives a 1222x716 box whose aspect ratio no longer
     matches the file's, so object-fit: contain letterboxes a SECOND time and
     the painted art comes out 1145 wide instead of 1242. Every clamp is a
     function of that width, so the file ran out of room to drag almost as
     soon as you started. A scalar mat cannot introduce a second letterbox
     because it does not change the shape of the box. */
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  transform-origin: 50% 50%;
  transform: translate(0px, 0px) scale(1);
  /* Sharpness at zoom is the entire point of the component, so the browser is
     told twice not to soften it. crisp-edges is wrong here (it kills the
     downscale at rest); high-quality is the one that matters on the way up. */
  image-rendering: auto;
  -webkit-user-drag: none;
  user-select: none;
  will-change: auto;
}
/* only while a zoom is animating — held permanently it costs a layer for the
   entire page, on an element that is usually not moving */
/* There used to be a grid texture on this surface, on its own layer, taking
   the artwork's transform. It is gone — the ground is flat now — but the
   measurement that put it on a layer is worth keeping, because the obvious way
   to bring any texture back here is the slow one. Driving background-size +
   background-position on .figfile-stage from the pan looks identical to a
   transform and repaints a 1350x844 stage at dpr 2 every frame of a drag.
   Median frame time over three runs each, panning at 410%:

       size + position, every frame     26.0 / 27.9 / 26.3 ms
       position only  (size frozen)     27.9 / 25.3 / 25.8 ms
       size only      (position frozen) 16.7 / 16.7 / 16.6 ms
       no grid at all                   16.7 / 16.6 / 16.6 ms

   It is background-POSITION that costs the 10ms. Pixel-snapping the offset — a
   plausible story about subpixel gradient resampling — measured 28.3 / 28.6 /
   25.3, which is what ruled that explanation out. If a texture comes back,
   it goes on an element and moves on a transform. */

.figfile-stage.is-easing .figfile-art { transition: transform var(--mid) var(--ease); }
.figfile-stage.is-zoomed { cursor: grab; touch-action: none; }
.figfile-stage.is-zoomed .figfile-art { will-change: transform; }
.figfile-stage.is-panning { cursor: grabbing; }

/* --- the window chrome --- */

.figfile-chrome {
  position: absolute;
  right: var(--s-4);
  bottom: var(--s-4);
  display: flex;
  gap: var(--s-2);
}

/* Two separate white squares rather than one segmented control. The cluster
   sits on a canvas the reader is dragging around under it, so each control
   reads better as its own object on that surface than as a bar attached to
   the frame. Solid white, not a translucent fill: the thing sliding beneath
   them is artwork, and anything showing through would move while the buttons
   do not. */
.figfile-btn {
  appearance: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.5rem;
  height: 2.5rem;
  padding: 0;
  background: #fff;
  border: 1px solid rgba(43, 43, 43, 0.10);
  color: var(--ink);
  font-family: var(--font-text);
  font-weight: 500;
  /* 20px, set in px on purpose: this is a glyph doing an icon's job, so it
     wants an icon's fixed size rather than the fluid type scale. line-height 1
     because the flex centring is what positions it, and a leading of 1.6 would
     just push it off-centre inside a fixed-height box. */
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.figfile-btn:hover { background: #f2f2f2; border-color: rgba(43, 43, 43, 0.16); }
.figfile-btn:disabled { color: rgba(43, 43, 43, 0.28); cursor: default; }
.figfile-btn:disabled:hover { background: #fff; border-color: rgba(43, 43, 43, 0.10); }
.figfile-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* The affordance, bottom left: present at rest, gone the moment it has been
   obeyed. A hint that stays on screen after you have done the thing is just
   furniture. It is also the only label left on the component now that the
   caption is gone, so it carries both verbs rather than just the one. */
.figfile-hint {
  position: absolute;
  left: var(--s-4);
  bottom: var(--s-4);
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--muted);
  pointer-events: none;
  transition: opacity var(--mid) var(--ease);
}
.figfile-stage.is-zoomed .figfile-hint { opacity: 0; }

/* The controls are hidden until main.js has actually taken the stage over and
   set .is-live. Not `html:not(.motion)` — the head script sets .motion before
   anything is parsed, so it is true even if main.js never arrives, and a Zoom
   button that cannot zoom is worse than no button. Without JS the artwork is
   still there and still readable at rest, which is the honest fallback. */
.figfile-chrome, .figfile-hint { display: none; }
.figfile-stage.is-live .figfile-chrome { display: flex; }
.figfile-stage.is-live .figfile-hint { display: block; }

@media (max-width: 40rem) {
  .figfile { grid-column: 1 / -1; }
  /* A phone cannot honour the file's own aspect and stay tall enough to be
     worth panning around inside, so the stage squares up — but only to 5/4.
     4/5 was tried first and a landscape file inside a portrait window
     letterboxes into two dead grey bands taller than the artwork itself. The
     mat comes off at the same time: on 340px of screen, 4rem of ceremonial
     margin is a quarter of the picture. */
  .figfile-stage { aspect-ratio: var(--figfile-ar-sm, 5 / 4); }
  .figfile-chrome { right: var(--s-3); bottom: var(--s-3); }
  .figfile-stage.is-live .figfile-hint { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .figfile-stage.is-easing .figfile-art { transition: none; }
}

/* ------------------------------------------------------------
   10. FIGURES: inline SVG diagrams
   ------------------------------------------------------------ */

.diagram {
  margin-top: var(--s-7);
  border: 1px solid var(--rule);
  background: var(--bg-sunken);
  padding: var(--s-7) var(--s-6) var(--s-5);
}
.diagram svg { width: 100%; height: auto; overflow: visible; }
.diagram figcaption {
  margin-top: var(--s-4);
  padding-top: var(--s-3);
  border-top: 1px solid var(--rule);
  display: flex;
  justify-content: space-between;
  gap: var(--s-4);
  font-family: var(--font-text);
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-3);
}

.diagram [data-draw] {
  stroke-dasharray: var(--len, 400);
  stroke-dashoffset: var(--len, 400);
  transition: stroke-dashoffset 900ms var(--ease);
}
.diagram.in [data-draw] { stroke-dashoffset: 0; }
.diagram [data-pop] {
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 500ms var(--ease), transform 500ms var(--ease);
}
.diagram.in [data-pop] { opacity: 1; transform: none; }
.diagram.in [data-pop="1"] { transition-delay: 80ms; }
.diagram.in [data-pop="2"] { transition-delay: 200ms; }
.diagram.in [data-pop="3"] { transition-delay: 320ms; }
.diagram.in [data-pop="4"] { transition-delay: 440ms; }
.diagram.in [data-draw="2"] { transition-delay: 160ms; }
.diagram.in [data-draw="3"] { transition-delay: 280ms; }
.diagram.in [data-draw="4"] { transition-delay: 400ms; }

@media (prefers-reduced-motion: reduce) {
  .diagram [data-draw] { stroke-dashoffset: 0; }
  .diagram [data-pop] { opacity: 1; transform: none; }
}

/* Figure type sits against a 720-unit viewBox. These sizes are the whole
   reason the diagrams read at presentation scale rather than as fine print. */
.dg-label { font-family: var(--font-text); font-weight: 500; font-size: 9.5px; letter-spacing: 0.07em; text-transform: uppercase; fill: var(--ink-3); }
.dg-title { font-family: var(--font-text); font-weight: 600; font-size: 11.5px; letter-spacing: 0.03em; text-transform: uppercase; fill: var(--ink); }
.dg-body  { font-family: var(--font-text); font-size: 10px; fill: var(--ink-2); }
.dg-box   { fill: var(--bg); stroke: var(--rule-strong); stroke-width: 1.25; }
.dg-box--solid { fill: var(--ink); stroke: var(--ink); }
/* Inversion is opt-in via .on-solid only. An adjacency selector silently
   whitened any title that followed a filled shape. */
.on-solid { fill: var(--inverse-ink); }
.dg-line  { stroke: var(--rule-strong); stroke-width: 1.25; fill: none; }
.dg-line--dash { stroke-dasharray: 4 4; }
/* Arrow shafts are open paths and must not be filled. Heads carry the fill. */
.dg-arrow { stroke: var(--ink); stroke-width: 1.25; fill: none; }
.dg-head  { fill: var(--ink); stroke: none; }

/* A 720-unit diagram squeezed into a phone column renders its labels at about
   4px. Below the fold-out width the figure keeps its drawn size and pans. */
@media (max-width: 52rem) {
  .diagram { overflow-x: auto; overscroll-behavior-x: contain; }
  .diagram svg { min-width: 46rem; overflow: hidden; }
  .diagram figcaption { position: sticky; left: 0; }
  .diagram figcaption span:last-child::after { content: " \00b7 pan \2192"; color: var(--ink-3); }
}

/* ------------------------------------------------------------
   11. LIVE DEMOS: stepped UI players
   ------------------------------------------------------------ */

.demo { margin-top: var(--s-6); border: 1px solid var(--rule); background: var(--bg-sunken); }
.demo-bar {
  display: flex; justify-content: space-between; align-items: center;
  gap: var(--s-4); padding: var(--s-3) var(--s-4);
  border-bottom: 1px solid var(--rule);
  font-family: var(--font-text); font-weight: 500;
  font-size: var(--t-label); letter-spacing: var(--ls-label);
  text-transform: uppercase; color: var(--ink-3);
}
.demo-steps { display: inline-flex; gap: var(--s-2); align-items: center; }
.demo-dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--rule-strong);
  transition: background var(--mid) var(--ease), transform var(--mid) var(--ease);
}
.demo-dot[data-on] { background: var(--ink); transform: scale(1.35); }
.demo-replay { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--ink-3); transition: color var(--fast) var(--ease); }
.demo-replay:hover { color: var(--ink); }

.demo figcaption {
  padding: var(--s-3) var(--s-4);
  border-top: 1px solid var(--rule);
  display: flex;
  justify-content: space-between;
  gap: var(--s-4);
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-3);
}

.demo-stage {
  position: relative;
  padding: var(--s-6) var(--s-5);
  display: grid;
  /* one row that tracks the stage's own height: without this the row keeps
     the tallest scene's height and short steps centre in a phantom box */
  grid-template-rows: minmax(0, 1fr);
  place-items: center;
  overflow: hidden;
  transition: height 420ms var(--ease);
}
@media (prefers-reduced-motion: reduce) { .demo figcaption {
  padding: var(--s-3) var(--s-4);
  border-top: 1px solid var(--rule);
  display: flex;
  justify-content: space-between;
  gap: var(--s-4);
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-3);
}

.demo-stage { transition: none; } }

.scene {
  grid-area: 1 / 1; width: 100%;
  opacity: 0; transform: translateY(8px);
  transition: opacity 420ms var(--ease), transform 420ms var(--ease);
  pointer-events: none;
}
.scene[data-on] { opacity: 1; transform: none; pointer-events: auto; }
.scene-note { margin-top: var(--s-4); font-size: var(--t-small); color: var(--ink-2); text-align: center; text-wrap: pretty; }

.ui-stack { display: grid; gap: var(--s-3); justify-items: center; }
.ui-row { display: flex; gap: var(--s-3); justify-content: center; flex-wrap: wrap; }
.ui-card {
  background: var(--bg); border: 1px solid var(--rule-strong);
  padding: var(--s-3) var(--s-4); min-width: 7.5rem;
  font-family: var(--font-text); font-weight: 500;
  font-size: var(--t-label); letter-spacing: var(--ls-label);
  text-transform: uppercase; text-align: center;
}
.ui-card small { display: block; margin-top: var(--s-1); text-transform: none; letter-spacing: 0; font-weight: 400; opacity: 0.7; }
.ui-card--solid { background: var(--ink); color: var(--inverse-ink); border-color: var(--ink); }
.ui-card--ghost { opacity: 0.42; border-style: dashed; }
.ui-bar { height: 0.5rem; background: var(--rule-strong); width: 100%; }
.ui-bar--fill { background: var(--ink); }
.ui-arrow { color: var(--ink-3); align-self: center; }

/* ------------------------------------------------------------
   12. COVER ART, PAGE TRANSITION, TOKEN PLAYGROUND
   ------------------------------------------------------------ */

/* Every cross-document navigation gets the same thing: a solid black fill
   sweeps left to right to cover the departing page, then, in rapid
   succession, sweeps left to right again to uncover the arriving one. The
   page's own arrival cascade (§12e) is untouched and simply starts playing
   once the wipe uncovers it.

   This used to be the browser's native cross-document View Transitions
   (`@view-transition { navigation: auto }`), which is the "smart" bit that
   got dropped, but the deeper problem was that the whole effect was riding
   on an API that Safari and Firefox don't implement for cross-document
   navigation at all. There, `@view-transition` is silently ignored and the
   browser just navigates instantly with no fill whatsoever, which is
   exactly the "didn't take" symptom. pageWipe() in main.js drives this with
   plain classList + setTimeout instead, so it's the same effect in every
   browser rather than a progressive enhancement only Chromium gets. */

/* Safe default if JS never runs at all: fully clipped away, invisible, page
   reads normally. html.motion is set synchronously in <head> before first
   paint, so "JS is present" flips this to fully covering before anything
   else ever paints: no flash of the raw page first. */
.page-wipe {
  position: fixed;
  inset: 0;
  z-index: 9999;
  background: var(--ink);
  clip-path: inset(0 100% 0 0);
  pointer-events: none;
}
html.motion .page-wipe { clip-path: inset(0 0 0 0); }

/* A page reached by flipping a case card open skips this curtain outright
   (main.js pendingCaseFlip()/reveal()) -- the hero unfolding is the reveal,
   so there's nothing left for a black sweep to do. Same fully-clipped-away
   end state the tile curtain's own uncover() reaches, just landed on
   directly with no animation at all, since this page was never visibly
   covered in the first place. Needs the same html.motion prefix as the rule
   above to outrank it: this is a plain declaration, not an animation, so it
   doesn't get the animation cascade's automatic priority and has to win on
   specificity instead. */
html.motion .page-wipe.wipe-skip { clip-path: inset(0 0 0 100%); }
/* Same end state, reached from the class the <head> script sets rather than the
   one pageWipe() adds partway down main.js. The difference is not stylistic: the
   covering state above is the DEFAULT under html.motion, so on a flight arrival
   the screen is black until JS says otherwise, and anything Chromium paints
   before main.js executes is black. Keyed off flight-in, which now exists from
   the first line of <head>, that window closes entirely. .wipe-skip stays as the
   fallback for a browser where the head script threw. */
html.motion.flight-in .page-wipe { clip-path: inset(0 0 0 100%); }

/* The curtain is drawn, not clipped. It used to be this element itself
   sweeping across on a clip-path -- two @keyframes, `wipe-cover` and
   `wipe-uncover`, both long gone (the menu's own departing rows were the
   last thing still riding `wipe-uncover`, and that went too -- a leftover
   directional sweep playing right after the tile curtain had already
   finished closing, reading as two curtains for one departure). It is now a
   field of rectangles that arrive and leave in a flowing scattered order
   (pageWipe() in main.js, inkfishnyc.com as the reference).

   This element keeps its own solid background and stays the bootstrap: it is
   what covers an arriving page before any JS has run, which matters because
   `html.motion .page-wipe` above makes covering the DEFAULT state and the
   first paint can happen before main.js executes. The canvas takes over
   inside it, and .is-cells is what hands the colour from one to the other --
   added only once the canvas is already carrying the right pixels, so the two
   swap with no frame in between where neither is painting. */
.page-wipe.is-cells,
.menu.is-cells { background: transparent; }

/* While the menu's own curtain is closing over the screen the menu is not
   .is-open yet (main.js waits for it), but it is already covering the page
   and has to swallow the clicks aimed at what is disappearing underneath. */
.menu.is-cells { pointer-events: auto; }

/* Negative z-index, not source order: the canvas is absolutely positioned and
   .menu-inner is not, so appending it would paint it OVER the rows however
   early it sits in the DOM. At -1 it lands where a background belongs --
   above the host's own background box, below everything in flow -- inside the
   stacking context .page-wipe and .menu each already establish. */
.curtain-cells {
  display: block;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
}

/* Was portrait (0.85, a jasonjerez.com-style phone-screenshot box) back when
   the hero images were portrait phone photos, and shared its row with the
   meta list at 1/8 rather than running the full width. The current heroes
   are all landscape device shots (laptop/monitor screens, ~4:3), and full-
   width now that the meta list has its own row above rather than sitting
   beside it: 4:3 at that width read as too tall, so this is a wide band
   instead, cropping more of the source image by request rather than
   dominating the page the way the taller box did. */
.cs-hero-media {
  grid-column: 1 / -1;
  position: relative;
  aspect-ratio: 2 / 1;
  background: var(--bg-sunken);
  overflow: hidden;
}
/* .cover's shared object-position (50% 22%) was tuned for the old portrait
   phone-in-hand shots, where the phone sat near the top of the frame. The
   current heroes are all device-on-a-desk compositions with the mockup
   sitting close to the image's own vertical center (measured per image,
   roughly 49-55%), so 22% was cropping in from the wrong end and pushing
   the actual subject toward the bottom of the now much shorter box. 50%
   is the right default, but measuring each image precisely (gridlines
   over the source, not a guess) turned up one, the BBR phone-in-hand
   shot, where the subject runs tall enough (top to bottom roughly 23% to
   87% of the frame) that the crop window barely clears it even centered
   perfectly; --pos overrides that one specifically, inline on the <img>,
   the same custom-property pattern §5's .shot-frame img already uses for
   per-image position tuning. Scoped to the hero specifically rather than
   changed on .cover itself, since the case-list and menu-preview
   thumbnails using the same source images still rely on the old value.

   Unconditional animation now, no @supports gate needed: this used to be
   conditional on "no View Transitions support," back when Chromium was
   expected to get a morph-driven entrance instead. That system is gone,
   so every browser needs this fade or Chromium users get no cover
   entrance at all. */
.cs-hero-media .cover {
  object-position: var(--pos, 50% 50%);
  animation: cover-in 700ms var(--ease) both;
}
@keyframes cover-in { from { opacity: 0; transform: scale(1.05); } }

/* The next-project card shows the same source image as the destination
   page's own hero, so it should crop the same way — the shared .cover
   default (50% 22%, tuned for the case-list/menu-preview thumbnails) would
   otherwise fight whatever --pos that hero sets inline. Same var(--pos,
   50% 50%) fallback as .cs-hero-media above, no animation needed since this
   isn't a page-load entrance. */
.next .case-media .cover { object-position: var(--pos, 50% 50%); }

@media (prefers-reduced-motion: reduce) {
  .cs-hero-media .cover { animation: none; }
  /* pageWipe() returns before it builds anything under reduced motion, so
     the canvas never exists and this only has to put the container itself
     out of the way. */
  html.motion .page-wipe {
    clip-path: inset(0 100% 0 0);
    animation: none;
  }
  /* caseMenu() builds no curtain at all under reduced motion, so the menu
     is just its own background, shown and hidden by [hidden]. */
  .menu { animation: none; }
  .menu.menu-leaving .menu-inner, .menu.menu-leaving .menu-preview { animation: none; }
  .menu.is-open { clip-path: inset(0 0 0 0); }
  .nav { transition: none; }
  html[data-menu-leaving] .nav { transform: none; }
}

/* --- covers move with the scroll ---
   Three independent motions, all written by mediaScroll() as custom properties.
   --pk is a parallax offset: the artwork drifts inside its frame as the frame
   crosses the viewport, so the image is never quite where the box is. --sk is
   scroll velocity, applied as a skew to the frame itself, so a fast flick bends
   the whole panel and it unbends as you stop. --rx is a gentle rotateX read
   from the frame's own position in the viewport (not velocity), so a cover
   leans back as it enters from below, lies flat through the center, and leans
   the other way on its way out -- a sheet of paper lying at a grade, not a
   flat sticker. `perspective()` is inlined on the transform itself rather than
   set on a parent, since neither element has 3D children that need to share
   its space.

   The covers are not scaled up to cover the travel, which is the usual trick:
   every one of them is drawn with ~8% of empty viewBox above and below the
   composition, so it can drift by 28px inside a 450px frame without any art
   leaving the box. Scaling instead cropped the artwork: on case 02 it cut the
   solid block clean off the right edge. */
/* --hrx/--hry are the pointer-tracked hover tilt, written by coverWave().
   They compose into the same single transform as the scroll-driven --rx/--sk
   rather than either system owning `transform` on this element outright --
   two rules setting transform on one element is just one of them losing, and
   the loser here would be whichever happened to come second in the file. */
.case-media, .cs-hero-media {
  transform:
    perspective(1600px)
    rotateX(calc(var(--rx, 0deg) + var(--hrx, 0deg)))
    rotateY(var(--hry, 0deg))
    skewY(var(--sk, 0deg));
  transform-origin: 50% 50%;
  will-change: transform;
}

/* Once a cover is being drawn by coverWave()'s canvas, the frame gives up
   almost everything it used to own. Two separate reasons, both load-bearing:

   PERFORMANCE. An animating 3D transform on an ancestor of animating children
   forces the whole subtree to re-rasterise every frame instead of letting the
   children composite independently. Measured on this page: each effect alone
   59.9fps, both nested 45.5fps, and slice count was nearly irrelevant (18 vs 8
   differed by ~1fps) which is what identified nesting rather than volume as
   the cost. So the frame does not transform; the canvas does, and it is a
   single element whose contents change by drawing rather than by moving child
   layers -- measured at 59.9fps with its own 3D tilt animating on top.

   SILHOUETTE. `overflow: hidden` is why the old slice-div version could only
   ever be a rigid rectangle however much its contents rippled. The canvas
   overhangs the frame by BLEED and the strips' own ends form the outline, so
   the left and right edges of the sheet visibly undulate -- which only works
   if nothing here clips or paints a rectangle behind it. Hence no clip, no
   sunken ground, no box-shadow, and no velocity sheen (::after), which is an
   `inset: 0` rectangle and would hang off the wavy edges as hard right-angled
   corners. The wave itself is the material cue those were standing in for.

   .cs-hero-media and .shot-frame have no canvas and keep the frame transform
   above, along with their ground, shadow and grain. */
.case-media.has-wave {
  transform: none;
  overflow: visible;
  background: transparent;
  box-shadow: none;
}
/* only the sheen remains to suppress -- it is an `inset: 0` rectangle and
   would hang off the wavy edges as hard right-angled corners */
.case-media.has-wave::after { display: none; }

/* The flying sheet (caseFlight(), main.js). Fixed, above everything including
   the header, and clipping: the canvas inside is sized every frame to *cover*
   this box, so morphing the box from the card's 4:3 to the hero's 2:1 crops
   the artwork rather than squashing it. JS owns width/height/transform and the
   shadow; none of it belongs in the stylesheet because all four are
   interpolated per frame off one shared `air` value. */
.case-flight {
  position: fixed;
  left: 0; top: 0;
  /* Under the header (z-index 100), not over it. The sheet is paper moving
     across the page; the header is the frame the page is seen through, and on
     a transition whose whole claim is continuity it must never be the thing
     that gets covered up. */
  z-index: 95;
  overflow: hidden;
  pointer-events: none;
  /* width/height are no longer animated (the morph is clip-path + transform),
     and hinting a property that never changes just costs layer bookkeeping */
  will-change: transform, clip-path, opacity;
}
.case-flight canvas {
  position: absolute; left: 0; top: 0;
  display: block;
  max-width: none;
  transform-origin: 0 0;   /* JS positions and scales it with one transform */
}

/* The canvas coverWave() draws into. JS sets left/top/width/height in px off
   the measured frame, including the bleed; this owns only the composed
   rotation. --rx/--sk come from mediaScroll() and --hrx/--hry from the pointer
   tilt, both written to .case-media and inherited down. No scale-to-cover
   compensation here on purpose -- a rotation pulling the artwork's corners in
   is now wanted, it's part of the sheet not being a rectangle. Parallax is
   deliberately absent too: --pk assumed a clipping frame to drift inside, and
   without one it would just slide the whole sheet over the caption below. */
.cover-canvas {
  position: absolute;
  transform:
    perspective(1600px)
    rotateX(calc(var(--rx, 0deg) + var(--hrx, 0deg)))
    rotateY(var(--hry, 0deg))
    skewY(var(--sk, 0deg));
  transform-origin: 50% 50%;
  will-change: transform;
}
.case-media-inner .cover, .cs-hero-media .cover {
  transform: translate3d(0, var(--pk, 0px), 0) scale(var(--pz, 1));
  transform-origin: 50% 50%;
  will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
  .case-media, .cs-hero-media { transform: none; }
  .case-media-inner .cover, .cs-hero-media .cover { transform: none; }
  /* the static ambient layer stays -- it's not motion -- but the --lift
     layer and the sheen are, so they're forced off rather than trusted to
     mediaScroll() never running. */
  .case-media { box-shadow: 0 0.5rem 1rem -0.6rem color-mix(in srgb, var(--ink) 14%, transparent); }
  .case-media:not([data-empty])::after { opacity: 0; }
  /* coverWave() returns before building anything under reduced motion, so
     there is normally no .cover-canvas in the DOM to suppress here at all --
     this is the belt to that braces, and it matters because the canvas hides
     the real <img> when it exists: killing only its motion would leave a
     static duplicate covering the original. .has-wave is reverted with it, so
     a frame that somehow got one still gets its clip and ground back. */
  .cover-canvas { display: none; }
  .case-flight { display: none; }
  .case-media.has-wave { overflow: hidden; background: var(--bg-sunken); }
}

/* Abstract SVG compositions standing in for screenshots, drawn in the same
   ink as everything else. */
/* .cover is now a real screenshot rather than a drawn SVG. It still fills its
   frame absolutely, so the parallax and the view transition are unchanged, but
   it needs object-fit to crop rather than squash a 3:2 shot into a 3:1 band. */
.cover {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  display: block;
  object-fit: cover;
  object-position: 50% 22%;
  transform: scale(1.02);
  transition: transform var(--slow) var(--ease);
}
.cv-box   { fill: none; stroke: var(--rule-strong); stroke-width: 2; }
.cv-line  { fill: none; stroke: var(--rule-strong); stroke-width: 2; }
.cv-solid { fill: var(--ink); stroke: none; }
.work-row:hover .cover, .case:hover .cover { transform: scale(1.06); }

/* The hero art keeps moving, slowly. Each shape carries an index stamped by
   JS, so one set of keyframes produces a wave across any composition. */
@media (prefers-reduced-motion: no-preference) {
  .cs-hero-media.is-live .cv-box {
    animation: amb-box 7s var(--ease) infinite;
    animation-delay: calc(var(--i, 0) * 90ms);
  }
  .cs-hero-media.is-live .cv-solid {
    transform-box: fill-box; transform-origin: center;
    animation: amb-solid 7s var(--ease) infinite;
    animation-delay: calc(var(--i, 0) * 90ms);
  }
  .cs-hero-media.is-live path.cv-line { stroke-dasharray: 5 9; animation: amb-flow 2.6s linear infinite; }
  .cs-hero-media.is-live line.cv-line {
    animation: amb-line 7s var(--ease) infinite;
    animation-delay: calc(var(--i, 0) * 90ms);
  }
  .case:hover .cover .cv-solid {
    transform-box: fill-box; transform-origin: center;
    animation: amb-solid 3.2s var(--ease) infinite;
    animation-delay: calc(var(--i, 0) * 60ms);
  }
  .case:hover .cover path.cv-line { stroke-dasharray: 5 9; animation: amb-flow 1.8s linear infinite; }
}
@keyframes amb-box   { 0%, 100% { stroke: var(--rule-strong); opacity: 0.85; } 45% { stroke: var(--ink); opacity: 1; } }
@keyframes amb-solid { 0%, 100% { transform: scale(1); opacity: 0.92; } 45% { transform: scale(1.06); opacity: 1; } }
@keyframes amb-line  { 0%, 100% { opacity: 0.55; } 45% { opacity: 1; } }
@keyframes amb-flow  { to { stroke-dashoffset: -28; } }

/* ------------------------------------------------------------
   12b. ABOUT + FOOTER
   ------------------------------------------------------------ */

/* Three blocks placed as a staircase down the grid rather than one left-hand
   column, with the head sitting in the middle of it. There are no headings:
   the separation is entirely positional, which is why every piece is pinned to
   explicit columns and rows rather than left to auto-placement. Auto-placement
   would drop these into source order across whatever tracks were free, and the
   whole composition is about which column each thing starts in.

   The stagger walks left → middle → right down the page:
     row 1   lede, columns 4–8
     row 2   the head, columns 3–5   ·  how-I-work, columns 7–11
     row 3   "Based in New York", columns 2–4  ·  before-this, columns 7–11 */
.info-top { row-gap: var(--s-10); }

.bio-lede          { grid-column: 4 / span 5; grid-row: 1; }
.portrait          { grid-column: 3 / span 3; grid-row: 2; }
/* Centred against the head rather than top-aligned with it. The two share a
   row, and starting both at the row's top edge reads as a caption beside a
   photograph instead of two independent things sitting on a canvas. */
.bio-block--work   { grid-column: 7 / span 5; grid-row: 2; align-self: center; }
.bio-aside         { grid-column: 2 / span 3; grid-row: 3; }
.bio-block--before { grid-column: 7 / span 5; grid-row: 3; margin-top: var(--s-8); }

/* .bio-aside carries only its position; the type comes from .label in the
   markup, the same voice as "Experience" below and the nav above. It was set
   at reading weight for one round on the theory that a sentence shouldn't
   wear a heading's clothes — but it is the only piece of the composition
   sitting on its own, and at reading weight it read as an orphaned line of
   body copy rather than as the caption to the whole page. */
/* narrower and squarer than a full half-width photo */

/* The portrait is a halftone with a transparent ground and no frame: a head
   floating on the page, which also happens to be a drawing surface. */
.portrait-draw {
  aspect-ratio: 700 / 958;
  /* Fills its three columns exactly. It used to be 66% of a five-column block
     with a 2% inline-start bias — a way of nudging the head off centre when its
     span was wider than the head wanted to be. The span IS the head's size now,
     so the same bias would only knock it off the grid. */
  width: 100%;
  margin-inline: 0;
  /* deliberately not position: relative (and no transform, for the same
     reason: it also creates a containing block); the canvas below needs
     to anchor to the page itself (see its own comment), and a positioned
     ancestor here would hijack that and pin it to this small box instead */
}
.portrait-draw img {
  width: 100%;
  height: 100%;
  /* the halftone is stored as ink-on-alpha, so it needs no colour treatment */
  /* and it must not be the hit target: dragging an <img> starts a native
     drag-and-drop, which cancels the pointer stream two moves into a stroke */
  pointer-events: none;
  -webkit-user-drag: none;
  user-select: none;
}
.portrait-draw { touch-action: none; }
/* Document-sized rather than a box around the head: any bleed amount still
   has an edge, and a stroke would stop dead the moment it crossed it. Sizing
   the canvas to the whole page removes the edge instead of just pushing it
   out.
   position: absolute, not fixed: fixed pins to the viewport, so a stroke
   would visibly slide as the page scrolled under it instead of staying put
   on the content it was drawn on. Absolute (with no positioned ancestor,
   .portrait-draw deliberately isn't one) anchors it to the page itself, and
   main.js sizes/writes to it in page coordinates (pageX/pageY) to match.
   It takes no pointer events, so the pencil cursor still belongs to the head
   alone; pointer capture on pointerdown is what keeps the stroke alive once
   you leave it. Under the nav's raised z-index (and the case menu's) so an
   open overlay isn't drawn on. */
.portrait-draw canvas {
  position: absolute;
  top: 0;
  left: 0;
  pointer-events: none;
  z-index: 200;
}

/* Only offer the pencil where there's a real cursor. On touch the canvas would
   have to swallow the gesture, which would break scrolling over the portrait.
   Raster rather than the SVG: a cursor image is rasterised at its intrinsic
   size, so image-set is what keeps the point sharp on a retina screen. The
   hotspot sits on the graphite tip. */
@media (hover: hover) and (pointer: fine) {
  .portrait-draw { cursor: url("pencil.png") 6 25, crosshair; }
  .portrait-draw {
    cursor: -webkit-image-set(url("pencil.png") 1x, url("pencil-2x.png") 2x) 6 25, crosshair;
    cursor: image-set(url("pencil.png") 1x, url("pencil-2x.png") 2x) 6 25, crosshair;
  }
}

/* The invitation to draw, not a caption, so it only exists where the
   gesture actually works (touch never gets the canvas, §above), and steps
   out of the way for the duration of a stroke rather than sitting there
   fighting the line for attention. main.js toggles .is-hidden on
   pointerdown/up, not on hover, so it's gone specifically while the pointer
   is down and drawing, and back the moment it's released. */
.portrait-hint { display: none; }
@media (hover: hover) and (pointer: fine) {
  .portrait-hint {
    display: block;
    /* matches .portrait-draw's own width/margin-inline exactly, so
       text-align: center lands under the head itself rather than over
       whatever empty column space sits beside it */
    width: 100%;
    margin-inline: 0;
    text-align: center;
    margin-top: var(--s-4);
    transition: opacity var(--mid) var(--ease);
  }
  .portrait-hint.is-hidden { opacity: 0; }
}

.rows { margin-top: var(--s-5); }
.row {
  display: flex; justify-content: space-between; gap: var(--s-5);
  padding-block: var(--s-4); border-top: 1px solid var(--rule);
  font-size: var(--t-small);
}
.row .when { color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
.row b { font-weight: 600; }

/* Experience is one line per role: organisation, title, dates. The
   descriptions are gone by request: the case studies are the description. */
.cv { margin-top: var(--s-5); }
.cv-role {
  display: grid;
  /* the page's own twelve tracks, so org / title / dates each land on a column
     instead of on three ratios that happen to look about right */
  grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
  gap: var(--s-2) var(--gutter);
  align-items: baseline;
  padding-block: var(--s-4);
  border-top: 1px solid var(--rule);
  color: var(--ink);
}
.cv-org { grid-column: 1 / span 4; }
.cv-title { grid-column: 5 / span 5; }
.cv-role .when { grid-column: 11 / -1; }
.cv-role:last-child { border-bottom: 1px solid var(--rule); }
.cv-org {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--t-body);
  letter-spacing: -0.015em;
}
.cv-title { font-size: var(--t-small); color: var(--ink-2); }
/* A company held under one date range but more than one title becomes a div
   of stacked spans instead of a single middot-joined line, each role reads
   as its own line without inventing a per-role date split that isn't known. */
.cv-title span { display: block; }
.cv-title span + span { margin-top: 0.2em; }
.cv-role .when {
  font-family: var(--font-text); font-weight: 500; font-size: var(--t-label);
  letter-spacing: var(--ls-label); text-transform: uppercase;
  font-variant-numeric: tabular-nums; color: var(--ink-3);
  text-align: right; white-space: nowrap;
}
@media (max-width: 40rem) {
  /* org and dates share the first line, the title takes the second; the rows
     have to be pinned or auto-placement pushes the dates onto a third */
  .cv-org { grid-column: 1 / span 3; grid-row: 1; }
  .cv-role .when { grid-column: 4 / -1; grid-row: 1; }
  .cv-title { grid-column: 1 / -1; grid-row: 2; }
}

/* --- info page layout ---------------------------------------------------
   One intro block on the left, the head on the right, experience beneath the
   pair. The intro is now three labelled sections rather than one run of
   paragraphs; the page still has no title of its own above them. */
.info-top { padding-top: calc(var(--nav-h) + var(--s-11)); }
.about-detail { grid-column: 1 / -1; margin-top: var(--s-11); }

/* No divider by request. The footer just starts. Rebuilt as one small row
   of four groups (reach out / socials / time+place / copyright) rather than
   a full-width headline over a contact list: small and quiet, not a second
   CTA section. */
.footer {
  padding-block: var(--s-6) var(--s-8);
  margin-top: var(--section-y);
  align-items: start;
}

.footer-reach, .footer-social {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}
.footer-reach { grid-column: 1 / span 3; }
.footer-social { grid-column: 4 / span 2; }
/* Time and place read as one quiet line, not a stack: .footer-reach and
   .footer-social are two-line groups (label/link), so this one line sits
   at their bottom edge, level with the email/Figma line, rather than the
   label/LinkedIn line above it. */
.footer-info {
  grid-column: 6 / span 3;
  align-self: end;
  display: flex;
  gap: var(--s-2);
}
/* Pinned to the last column and right-aligned so it reads as the row's far
   edge no matter how wide the grid gets, the same "claims its own slot"
   logic the socials/base split used to rely on. Bottom-aligned for the same
   reason as .footer-info above. */
.footer-base {
  grid-column: 11 / -1;
  align-self: end;
  justify-self: end;
  text-align: right;
}

.footer-reach, .footer-social, .footer-info, .footer-base {
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
}
/* Grey for the plain label/time/copyright text; the actionable links
   (email, LinkedIn, Figma) override back to --ink below. */
.footer-label, .footer-info, .footer-base { color: var(--muted); }
.footer-reach a.u-link, .footer-social a.u-link { color: var(--ink); }
/* Flex items default to align-items: stretch, which would widen each link's
   box to the column's full width and make its hover mark below cover the
   whole row instead of just the text. */
.footer-reach > *, .footer-social > * { align-self: flex-start; }

/* underline that draws in from the left, still the site-wide default for
   .u-link (the inline "Prototypes create momentum" links, etc). The footer's
   email/LinkedIn/Figma links override it below with a black highlight
   instead. */
.u-link { position: relative; }
.u-link::after {
  content: ""; position: absolute; left: 0; bottom: -0.18em;
  width: 100%; height: 1px; background: currentColor;
  transform: scaleX(0); transform-origin: left;
  transition: transform var(--mid) var(--ease);
}
.u-link:hover::after, .u-link[aria-current]::after { transform: scaleX(1); }

/* Same black-highlight vocabulary as the nav items and the case titles,
   scoped to the footer only: .u-link elsewhere (inline body links) keeps
   the plain underline above, which is the right weight mid-paragraph. */
.footer-reach a.u-link::after, .footer-social a.u-link::after {
  content: "";
  position: absolute;
  inset: -0.15em -0.35em;
  width: auto;
  height: auto;
  background: var(--ink);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--mid) var(--ease);
  z-index: -1;
}
.footer-reach a.u-link, .footer-social a.u-link { transition: color var(--fast) var(--ease); }
.footer-reach a.u-link:hover, .footer-social a.u-link:hover { color: var(--bg); }
.footer-reach a.u-link:hover::after, .footer-social a.u-link:hover::after { transform: scaleX(1); }

/* ------------------------------------------------------------
   12c. CASE MENU + FIRST-LOAD CURTAIN
   ------------------------------------------------------------ */

.nav-cases {
  /* reset the button chrome only: `font: inherit` would blow away the
     label sizing that .nav-link sets. color can't be `inherit` here: this
     rule lands after .nav-link in the cascade at equal specificity, so
     `inherit` would win and pull in body's dark ink instead of the white
     .nav-link needs for its difference blend (§6). */
  cursor: pointer;
  background: none;
  border: 0;
  padding: 0;
  color: #fff;
}

/* A disclosure, not a page: numbered rows, right-aligned, a hairline between
   each, on an ink ground so it reads as a mode change.

   Opens and closes on the site's own curtain: the rectangle field from §12,
   covering to open and uncovering to close (main.js caseMenu()). It used to
   run a clip-path sweep of its own, which was the last thing here still
   moving that way once the page curtain stopped being one.

   No clip-path any more, and no animation: [hidden] is the closed state, the
   canvas is the open one, and is-open is left in charge of only the things
   the curtain doesn't touch -- the row stagger below, and pointer-events once
   the ground has arrived. */
.menu {
  position: fixed;
  inset: 0;
  z-index: 350;
  background: var(--inverse);
  color: var(--inverse-ink);
  display: grid;
  align-content: center;
  pointer-events: none;
}
.menu[hidden] { display: none; }
.menu.is-open { pointer-events: auto; }

/* Navigating away (a row, or a nav link still reachable above the menu) is a
   different exit than just closing. Closing reveals the page that's already
   there, so uncovering the backdrop is correct. Navigating hands
   off to a page that hasn't loaded yet: if the backdrop itself uncovered on
   that same timer, whatever it revealed would still be the OLD page, since
   the real navigation hasn't happened. So a navigating departure leaves the
   backdrop alone entirely (still fully covering: once the curtain has finished
   the menu is painting its own solid ground again, and nothing is asked to
   uncover it). This used to also wipe the rows away on top of that backdrop
   with a left-to-right clip-path sweep (wipe-uncover) — a second, directional
   curtain playing right after the tile curtain had already finished closing,
   which is exactly the redundant one-departure-too-many this was cut for. The
   rows now simply hold still under the already-solid backdrop for the rest of
   MENU_DEPART_MS (main.js) — nothing to see since the backdrop already covers
   them — until the real navigation cuts to the new document, which starts
   covered by its own .page-wipe before its first paint (html.motion, §12), so
   the handoff is black-to-black no matter how the timing lands. */

/* .menu-inner is a grid item of .menu (no other .grid on the site is nested
   inside another grid), and its own margin-inline: auto — needed to center
   it once it's at max-width — makes it a grid item with an auto margin in
   the inline axis. Per the Box Alignment spec that overrides justify-self's
   default stretch and sizes it to content instead, same as it would any
   flex/grid item: invisible while .menu-t was giant display type (wide
   enough on its own to fill the space) but exposed the moment the row list
   shrank to .cv-role's scale. width: 100% forces it back to the full
   12-column .grid width its own max-width/padding-inline already define. */
.menu-inner { width: 100%; row-gap: var(--s-8); }
.menu-list { grid-column: 1 / -1; list-style: none; padding: 0; margin: 0; }

/* Row shape and hover language borrowed straight from .cv-role on the info
   page (§ "about-detail"): a compact single line — index, title — rather
   than the old full-bleed display-type row, with the same solid fill
   wiping in from the left on hover instead of dimming the rest of the
   list. Colors are flipped from .cv-role's light-ground original (dark
   fill, light text) to suit the menu's already-dark ground: light fill,
   ink text. The cursor-tracking cover preview below is untouched by any
   of this — caseMenuPreview() in main.js watches .menu-row itself, not
   its typography, so it keeps riding the pointer exactly as before. */
/* Every item in the list — subhead or row, real case or empty placeholder —
   reveals through this one rule instead of three near-duplicates that each
   had to be told separately where they sat in the list. caseMenu() in
   main.js stamps --i (0, 1, 2, ...) on every direct <li> child once, in DOM
   order, so the stagger is read off the live list rather than hand-matched
   nth-child positions that had to be renumbered by hand whenever a row was
   added or removed above them — which is exactly the class of bug a
   subhead insertion kept re-triggering here. */
.menu-list > li {
  position: relative;
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 420ms var(--ease), transform 420ms var(--ease);
}
.menu.is-open .menu-list > li {
  opacity: 1;
  transform: none;
  transition-delay: calc(var(--i, 0) * 60ms);
}
/* Closing plays the same fall+fade in reverse, off --i-rev (caseMenu(),
   main.js) rather than --i itself, so the last row to arrive is the first
   to leave — the list folds back up the way it unfolded, instead of every
   row vanishing on the same frame the way a plain is-open removal reads
   (the base rule above still applies opacity: 0/translateY(14px), this only
   times how it gets there). A smaller stagger than the 60ms entrance: at
   up to a dozen rows, 60ms/row would leave the last row still fading most
   of a second after the click that closed it. ROWS_OUT_MS (main.js) is
   this rule's own worst case, so the curtain doesn't start revealing the
   real page until these are done. */
.menu:not(.is-open) .menu-list > li {
  transition-duration: 240ms;
  transition-delay: calc(var(--i-rev, 0) * 18ms);
}
/* Picking a case study leaves is-open on (the menu is still the ground the
   destination's own curtain arrives on top of, §12) and adds .menu-leaving
   instead — this needs its own, higher-specificity rule rather than falling
   out of :not(.is-open) above, and it needs one at all: until now nothing
   here ever kicked opacity back to 0, so the rows sat fully visible,
   frozen, for the whole MENU_DEPART_MS (main.js) until the real navigation
   cut them away with no animation at all. Same reverse stagger, same
   budget — it fits inside MENU_DEPART_MS (550ms) with room to spare. */
.menu.is-open.menu-leaving .menu-list > li {
  opacity: 0;
  transform: translateY(14px);
  transition-duration: 240ms;
  transition-delay: calc(var(--i-rev, 0) * 18ms);
}
/* Hairlines between rows, drawn as pseudo-elements instead of a plain
   border-top/-bottom on .menu-row/.menu-subhead the way they used to be —
   a real border paints as part of the row's own box, in the same
   stacking-order bucket as its text, which put it above .menu-preview (its
   z-index: -1 only clears .menu's own background, not in-flow content like
   this) and left the hairlines visibly slicing across the cursor-tracking
   preview image. z-index: -2 here sinks them one layer further, below the
   preview, while the row's actual text stays exactly where it was — a
   plain in-flow child, unaffected by either negative z-index. (This only
   works while .is-open's transform: none is in effect, §12d: a real
   translateY would give the <li> its own stacking context and trap this
   pseudo-element inside it instead of letting it reach .menu's.) */
.menu-list > li:not(:first-child)::before {
  content: "";
  position: absolute;
  inset-block-start: -1px;
  inset-inline: 0;
  height: 1px;
  background: rgba(245, 244, 242, 0.22);
  z-index: -2;
  pointer-events: none;
}
.menu-list > li:last-child::after {
  content: "";
  position: absolute;
  inset-block-end: -1px;
  inset-inline: 0;
  height: 1px;
  background: rgba(245, 244, 242, 0.22);
  z-index: -2;
  pointer-events: none;
}

.menu-row { position: relative; }
/* An experiment row's trigger is a <button>, not an <a> — there's nowhere to
   navigate to, only a modal to open — so it shares this rule rather than
   duplicating it. Reset to full width/no chrome first, same as .nav-toggle. */
.menu-row a,
.menu-row button {
  position: relative;
  display: grid;
  grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
  gap: var(--s-2) var(--gutter);
  align-items: baseline;
  padding-block: var(--s-4);
  width: 100%;
  color: var(--inverse-ink);
  transition: color var(--fast) var(--ease);
  background: none;
  border: 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.menu-row a::before,
.menu-row button::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--inverse-ink);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--mid) var(--ease);
  z-index: -1;
}
.menu-row a:hover::before,
.menu-row button:hover::before { transform: scaleX(1); }
.menu-row a:hover,
.menu-row a:hover .menu-n,
.menu-row a:hover .menu-t,
.menu-row button:hover,
.menu-row button:hover .menu-n,
.menu-row button:hover .menu-t { color: var(--inverse); }
/* The hover fill is a deliberate solid ink-on-paper flip (dark text over the
   light fill sliding in from ::before), not an invert against whatever's
   behind — so the blend mode and the number's dim opacity (both from the
   default state above) are cancelled here rather than left to diff against
   the fill. */
.menu-row a:hover .menu-n,
.menu-row a:hover .menu-t,
.menu-row button:hover .menu-n,
.menu-row button:hover .menu-t { mix-blend-mode: normal; opacity: 1; }
/* setOpen() in caseMenu() focuses the first row the instant the menu opens,
   including on a plain mouse click — Chromium's :focus-visible heuristic
   then treats that silent jump as worth flagging, which is correct, but
   pairing it with the *same* solid fill :hover uses read as row 001 stuck
   mid-hover with no cursor anywhere near it. A ring keeps the two signals
   (pointer here vs. keyboard/programmatic focus here) visually distinct. */
.menu-row a:focus-visible,
.menu-row button:focus-visible { outline: 1px solid var(--inverse-ink); outline-offset: -1px; }

/* Both the number and the title sit on top of the cursor-tracking cover
   preview (§ .menu-preview below) as well as the flat menu ground, and a
   fixed light color reads fine over the dark ground but disappears over a
   bright patch of photo. Same fix as .nav (§6) and .case-go: color: #fff
   plus mix-blend-mode: difference is a true per-pixel invert, so the text
   stays legible no matter what's behind it. Source has to be pure white,
   not var(--inverse-ink) — same reasoning as §6, anything less than full
   white leaves a residual color instead of a clean flip. The number's old
   "dim/secondary" look is now opacity (post-blend) rather than a duller
   source color, since dulling the source color is exactly what breaks the
   invert. Both are reset to a plain, unblended color on hover (below) so
   the deliberate solid ink-on-paper hover fill isn't diffed too. */
.menu-n {
  grid-column: 1 / span 2;
  grid-row: 1;
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  color: #fff;
  mix-blend-mode: difference;
  opacity: 0.62;
  font-variant-numeric: tabular-nums;
}
/* A non-linking heading above/between groups of rows, same hairline rhythm
   (now the shared .menu-list > li ::before/::after above, not a border of
   its own) and padding as a .menu-row, so it reads as a divider rather than
   a case or experiment of its own. Reveal (opacity/transform/delay) comes
   from the shared .menu-list > li rule too — this only owns its own look. */
.menu-subhead {
  padding-block: var(--s-4);
}
.menu-subhead .label { color: rgba(245, 244, 242, 0.62); }
/* "Now" (set by caseMenu() in main.js, swapped in for the row's own case
   page) reads at the same dim weight as every other row's number unless
   it's called out; full brightness is enough on its own, no new color. */
/* Full brightness, not a different color: still has to stay pure white to
   keep the invert above clean. */
.menu-row a[aria-current] .menu-n { opacity: 1; }
.menu-t {
  /* same size as .cv-org (the info page's own company name), not the
     larger display size this used to run at */
  grid-column: 2 / -1;
  /* explicit, not left to auto-placement: this now overlaps .menu-n's own
     column 2 (by design — the numbers are 3 characters in a 2-column
     span, nowhere near filling it), and an item without a row of its own
     gets pushed to row 2 rather than allowed to overlap row 1 */
  grid-row: 1;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--t-body);
  letter-spacing: -0.015em;
  line-height: 1.2;
  text-align: left;
  color: #fff;
  mix-blend-mode: difference;
}

/* The floating cover preview, only where there's a real pointer to follow,
   same reasoning as the homepage's own cursor-tracking "View case" label:
   on touch there's no hover state to hang it on and no cursor to sit
   beside. --mv-x/--mv-y are written by caseMenuPreview() in main.js on
   pointermove, coalesced into one rAF the same way. The box itself doesn't
   move on its own timer, only opacity transitions, because easing the
   translate would make it swim behind the cursor instead of sitting with
   it, same call as the case-list label. */
@media (hover: hover) and (pointer: fine) {
  .menu-preview {
    position: fixed;
    top: 0;
    left: 0;
    width: min(26vw, 26rem);
    /* Set per-hover by show() in caseMenuPreview() (main.js), off that
       item's own <img width height> — a case study's 4:3 cover and a
       portrait experiment shape the box differently instead of both being
       cropped to fit one fixed frame. 3 / 1.6 only stands in before the
       first hover, while nothing's shown yet. */
    aspect-ratio: var(--mv-ratio, 3 / 1.6);
    background: var(--bg-sunken);
    overflow: hidden;
    pointer-events: none;
    /* Behind .menu-inner's text, not on top of it: .menu (the nearest
       positioned ancestor with its own z-index) is what makes this land
       relative to the row text at all rather than relative to the whole
       page; a negative z-index still paints above .menu's own background,
       which is painted first regardless, so the box stays visible against
       the ink ground. */
    z-index: -1;
    opacity: 0;
    /* Centered on the cursor in both axes now, not nudged clear of it:
       the case-list label this was modeled on has to sit beside the cursor
       because it's text you'd otherwise cover; a preview image is the
       point of the hover, so it belongs right where you're looking. */
    transform: translate3d(calc(var(--mv-x, 0px) - 50%), calc(var(--mv-y, 0px) - 50%), 0);
    /* inset(), not the equivalent `none`: transitioning FROM none isn't
       reliably interpolable, and .menu.menu-leaving below needs to animate
       TO an inset() of its own — this just gives it a matching shape to
       start from, identical to none at rest (0 inset on every side). */
    clip-path: inset(0 0 0% 0);
    transition: opacity var(--fast) var(--ease), aspect-ratio var(--fast) var(--ease);
    will-change: transform;
  }
  .menu-preview.is-active { opacity: 1; }
  /* Picking a case study departs on this box too now, not just the row
     list (§12d) — asked for directly, in the same language as the
     rectangle curtain itself: top pinned, bottom rising to meet it
     (paint()'s uncover branch, cellCurtain, main.js), rather than the
     plain fade every other opacity change here uses. It used to wipe away
     on a clip-path sweep and that was cut as a redundant second curtain
     once the rows had their own wipe — this isn't that: the rows animate
     on opacity/transform now, not clip-path, so there's no second curtain
     to be redundant with, just a departure this box never actually had. */
  .menu.menu-leaving .menu-preview {
    clip-path: inset(0 0 100% 0);
    transition: clip-path 320ms var(--ease);
  }
  .menu-preview-item {
    position: absolute;
    inset: 0;
    opacity: 0;
    transition: opacity var(--fast) var(--ease);
  }
  .menu-preview-item.is-shown { opacity: 1; }
  .menu-preview .cover { position: absolute; inset: 0; width: 100%; height: 100%; }
}

/* An experiment row has nowhere to navigate to, so picking one opens the
   image itself, in place, over the menu — experimentModal() in main.js.
   Outside #case-menu in the DOM (see index.html) so it isn't touched by the
   menu's own "click the ground to close" handler or its Escape/Tab
   handling; it owns both while it's open instead. Higher than .nav's 360
   (§6) as well as .menu's 350, so nothing behind it is reachable until it's
   closed. */
.experiment-modal {
  position: fixed;
  inset: 0;
  z-index: 500;
  display: grid;
  place-items: center;
}
.experiment-modal[hidden] { display: none; }
/* No darkening — asked for directly: the menu stays visible behind the
   enlarged image rather than being hidden under a scrim, the way the rows
   themselves never dim for a hovered cover preview either (§12d). Still a
   full-viewport element, and still what a click outside the image lands
   on, so "click the ground to close" keeps working with nothing to see. */
.experiment-modal-backdrop {
  position: absolute;
  inset: 0;
  background: transparent;
}

/* The thing that actually grows. Sized to the image, not to a fixed panel
   with the image floating inside it — a tight wrapper is what makes "grows
   into a larger view" read as the same object getting bigger rather than a
   picture dropping into a frame. experimentModal() transforms this element
   (translate + scale) between the clicked row's rect and its own, so its
   size here has to already be its real, at-rest size before that transform
   is ever computed.

   var(--ease-spring) (§ "one lift model"'s cousin — a slight overshoot, not
   a straight ease-out) is what makes the arrival read as landing rather
   than just stopping: it grows a hair past its final size and eases back,
   the CSS-transition equivalent of the homepage cover flight settling past
   flat before it rests (flapArrive(), main.js). Only ever used for the open
   flight now — closing goes under the curtain (below) instead of shrinking
   back into its row, so nothing here has to animate the other direction. */
.experiment-modal-frame {
  position: relative;
  display: inline-block;
  margin: 0;
  max-width: min(60vw, 860px);
  max-height: 62vh;
  line-height: 0;
  /* duration matches OPEN_MS in experimentModal() (main.js) */
  transition: transform 480ms var(--ease-spring);
  will-change: transform;
}
/* The lift shadow rises and settles alongside the transform above, off the
   same --lift-shadow-rest/-up custom properties experimentModal() (main.js)
   reads straight from liftShadow() — the homepage cover flight's own "how
   far off the surface" cue, reused rather than re-tuned. .is-open is added
   the same frame the transform's transition is let back in (main.js), so
   both start together; a plain two-state transition can't rise and settle
   again on its own, which is why this is a one-shot @keyframes instead. */
@keyframes experiment-lift {
  0%, 100% { box-shadow: var(--lift-shadow-rest); }
  45% { box-shadow: var(--lift-shadow-up); }
}
.experiment-modal.is-open .experiment-modal-frame {
  animation: experiment-lift 480ms var(--ease) 1;
}
/* cellCurtain (main.js) paints its rectangles at z-index: -1 by default —
   correct for the menu and the page wipe, where the canvas is standing in
   for a background underneath rows that must stay on top of it, wrong here,
   where the canvas has to consume the <img> itself. Positive, so it stacks
   above the frame's one static, non-positioned child regardless of source
   order. */
.experiment-modal-frame .curtain-cells { z-index: 1; }
.experiment-modal-img {
  display: block;
  max-width: 100%;
  max-height: 62vh;
  width: auto;
  height: auto;
}
@media (prefers-reduced-motion: reduce) {
  .experiment-modal-frame { transition: none; }
  .experiment-modal.is-open .experiment-modal-frame { animation: none; }
}

/* Injected by JS, so a no-JS visitor never sees a panel that can't lift. */
.loader {
  position: fixed;
  inset: 0;
  z-index: 400;
  background: var(--inverse);
  color: var(--inverse-ink);
  display: grid;
  align-content: end;
  justify-items: end;
  padding: var(--margin);
  transform: translateY(0);
  transition: transform 820ms var(--ease);
}
.loader[data-in] .loader-word { opacity: 1; transform: none; }
.loader[data-lift] { transform: translateY(-100%); }
.loader-word {
  opacity: 0;
  transform: translateY(0.14em);
  transition: opacity 520ms var(--ease), transform 520ms var(--ease);
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  font-size: clamp(4rem, 2rem + 14vw, 16rem);
  letter-spacing: var(--ls-tight);
  line-height: 0.9;
}
/* Same slot the nav wordmark sits in (.nav-brand-text, §2), so when the
   panel lifts and hands off to the real nav underneath, the brand doesn't
   appear to jump. */
.loader-logo {
  position: absolute;
  inset-block-start: calc((var(--nav-h) - 1em) / 2);
  inset-inline-start: var(--margin);
  font-family: var(--font-text);
  font-weight: 500;
  font-size: var(--t-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  line-height: 1;
}

/* Nothing underneath should scroll while either panel is up. */
html[data-loading], html[data-loading] body,
html[data-menu-open], html[data-menu-open] body { overflow: hidden; }

/* The menu covers the full viewport, so the real scrollbar has to actually
   go — leave it in place and it renders on top of the curtain, which reads
   as broken. But losing it hands the page back that strip of width, and
   reclaiming it right as the curtain starts covering the page lands a
   visible jump. --sbw is the scrollbar's own width, measured in JS right
   before overflow: hidden takes it away (main.js, caseMenu §setOpen).
   html's auto-width box picks that width back up as padding, which is
   enough for anything laid out in normal flow (§case-list, §intro, etc.). */
html[data-menu-open] { padding-right: var(--sbw, 0px); }

/* Fixed, full-bleed elements don't answer to that padding — position: fixed
   sizes against the real viewport, not html's own box, so inset-inline: 0
   just re-stretches into the strip the scrollbar left behind regardless of
   what html is padded by. .nav stays above the curtain (z-index 360 vs.
   the menu's 350, so its links are still reachable) and .grid-lines is
   still visible mid-fade for the length of its opacity transition, so both
   need their own edge pulled in by the same --sbw to hold still. */
html[data-menu-open] .nav,
html[data-menu-open] .grid-lines {
  inset-inline-end: var(--sbw, 0px);
}

@media (prefers-reduced-motion: reduce) {
  .menu, .menu-list > li { transition: none; }
  /* .menu.is-open.menu-leaving .menu-list > li (above) outranks the plain
     .menu-list > li selector on specificity alone (4 classes vs 1), so it
     needs naming here too — otherwise its opacity: 0 would still win this
     cascade and rows would vanish outright on a leaving navigation instead
     of just holding still. */
  .menu-list > li,
  .menu.is-open.menu-leaving .menu-list > li { opacity: 1; transform: none; }
}

/* ------------------------------------------------------------
   12d. GRID OVERLAY
   ------------------------------------------------------------ */

/* The column guides, drawn faintly on the surface. Built from the same
   --cols / --gutter / --margin tokens the layout uses, so the lines are the
   real grid rather than a picture of one: if the grid changes, so do they. */
.grid-lines {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  max-width: var(--max-w);
  margin-inline: auto;
  padding-inline: var(--margin);
  display: grid;
  grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
  gap: var(--gutter);
}
.grid-lines span {
  position: relative;
  /* drawn as gradients so dash length and gap are controllable; a dotted
     border gives neither */
  background-image:
    repeating-linear-gradient(to bottom, var(--guide) 0 11px, transparent 11px 26px),
    repeating-linear-gradient(to bottom, var(--guide) 0 11px, transparent 11px 26px);
  background-size: 1px 100%, 1px 100%;
  background-position: left top, right top;
  background-repeat: repeat-y, repeat-y;
}
@media (prefers-reduced-motion: no-preference) {
  .grid-lines { transition: opacity var(--mid) var(--ease); }
}
/* the layout drops to 8 columns then 4, so the guides have to follow, or they
   stop describing the grid the content is actually on */
@media (max-width: 64rem) { .grid-lines span:nth-child(n + 9) { display: none; } }
@media (max-width: 40rem) { .grid-lines span:nth-child(n + 5) { display: none; } }

/* on the ink panels the guides would fight the type */
html[data-menu-open] .grid-lines,
html[data-loading] .grid-lines { opacity: 0; }

/* word-by-word reveal + label scramble cells */
.wr-wait { opacity: 0; }
/* .lead/.prose/etc. (§wordRise SEL) carry .reveal too, and that observer
   runs on its own schedule (main.js reveal(), -10% rootMargin) independent
   of wordRise's own (-12%) — so .reveal.in can land first and, being more
   specific than plain .wr-wait, win the cascade while the paragraph is
   still one un-split text node. The glitch that reads as: whole line of
   copy flashes in solid, then a beat later gets yanked back down into
   hidden words and rises back in. Matching that specificity here keeps
   .wr-wait's hide in charge until wordRise itself takes over. */
html.motion .reveal.in.wr-wait { opacity: 0; }
.wr { display: inline-block; }
.wr-wait .wr, :where(.lead, .prose, .takeaway p, .credits) .wr {
  opacity: 0;
  transform: translateY(0.42em);
  transition: opacity 520ms var(--ease), transform 520ms var(--ease);
}
/* Triggered per word (main.js groups words into lines and flips this per
   line as each one crosses into view on its own), not on a container class:
   a long paragraph would otherwise gate every line's reveal on the single
   moment the paragraph itself first entered, forcing later lines to wait out
   a fixed stagger even once they're already on screen. */
.wr.in { opacity: 1; transform: none; }
.wr-done { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .wr-wait { opacity: 1; }
  .wr { opacity: 1 !important; transform: none !important; }
}

/* ------------------------------------------------------------
   12e. ARRIVAL MOTION
   ------------------------------------------------------------ */

/* Every hidden state below is scoped to html.motion, which main.js sets in the
   head before the body parses. That gate is the whole safety story: if the
   script never runs (JS off, a parse error, the file blocked), nothing is ever
   hidden and the page degrades to plain static content instead of a blank
   screen. Do not lift these rules out of the gate.

   Two vocabularies, kept apart on purpose. Display type wipes up from a hard
   edge, which reads as typesetting. Everything else lifts and fades, which
   reads as arrival. Put both on one element and it reads as a wobble. */

/* --- display type: wipes up from a clipping edge --- */

/* The clip edge would otherwise cut descenders, and on tight display leading it
   catches ascenders too. Pad the box out and take the same amount back off the
   margin, so the mask gains room to clip in without moving anything. No display
   is set here: .cs-title .mask is already inline-block and the hero lines are
   already block, and overriding either one reflows the headline. */
html.motion .mask {
  overflow: hidden;
  padding-block: 0.1em 0.14em;
  margin-block: -0.1em -0.14em;
}
html.motion .mask > span {
  display: block;
  transform: translateY(105%);
  transition: transform var(--slow) var(--ease);
}
html.motion .mask.in > span { transform: none; }

/* --- everything else: lifts and fades --- */

html.motion .reveal {
  opacity: 0;
  transform: translateY(var(--rise));
  transition: opacity var(--slow) var(--ease), transform var(--slow) var(--ease);
}
html.motion .reveal.in { opacity: 1; transform: none; }

/* wordRise() is already lifting the individual words inside these, so the
   container only fades, otherwise the line rises twice at two speeds. */
html.motion .reveal:is(.lead, .prose) { transform: none; }

/* --- the cascade --- */

/* One step is --stagger; --d is how many steps in. Authored in the markup as
   .d1/.d2/.d3 on the hero, and derived from position for the case list, so the
   rows deal themselves out rather than landing as a block.

   Scoped to html.arriving, which main.js drops two seconds in. That gate is not
   decoration: the same rows are also scroll-reveal targets, and a row you have
   just scrolled to sitting on a 630ms delay reads as a broken page, not as
   choreography. Delays belong to the arrival only. */
/* The delay has to land on whatever actually carries the transition. For .reveal
   that is the element; for a mask it is the inner span, so setting it on .mask
   silently does nothing. --d is declared on .mask and inherits down to the span,
   which is the only reason the second selector can read it. */
html.motion.arriving .reveal,
html.motion.arriving .mask > span {
  transition-delay: calc(var(--d, 0) * var(--stagger));
}
html.motion.arriving .d1 { --d: 1; }
html.motion.arriving .d2 { --d: 2; }
html.motion.arriving .d3 { --d: 3; }
html.motion.arriving .d4 { --d: 4; }
html.motion.arriving .d5 { --d: 5; }
html.motion.arriving .case-list .case:nth-child(1) { --d: 3; }
html.motion.arriving .case-list .case:nth-child(2) { --d: 4; }
html.motion.arriving .case-list .case:nth-child(3) { --d: 5; }
html.motion.arriving .case-list .case:nth-child(4) { --d: 6; }

/* Case rows get a small stagger outside the arrival too, which the note above
   argues against for .reveal generally: a single row you've deliberately
   scrolled to shouldn't sit on a delay. But rows only ever reveal in a
   cluster (they're stacked close enough, and a fast flick or Page Down
   easily crosses more than one row's trigger in the same frame), never in
   true isolation, so a short cascade here reads as the list dealing itself
   out rather than as lag. Kept tighter than the arrival's own stagger
   (0–390ms vs. up to 540ms) so it stays snappy even for a single row. */
html.motion .case-list .case:nth-child(1) { --d: 0; }
html.motion .case-list .case:nth-child(2) { --d: 1; }
html.motion .case-list .case:nth-child(3) { --d: 2; }
html.motion .case-list .case:nth-child(4) { --d: 3; }
html.motion .case-list .case.reveal { transition-delay: calc(var(--d, 0) * 130ms); }

/* --- the header --- */

/* The header arrives the way a paragraph does. wordRise() lifts each word of
   body copy by 0.42em over 520ms; these are the same numbers, so a nav item and
   a word in the lede are doing the same thing at the same speed, they just
   start at different points in the cascade. It used to drop in from above over
   --slow, which read as a separate piece of chrome sliding into place.

   The animation is on the children, not on .nav: the header is position: fixed
   and a transform on it would establish a containing block for the menu. */
html.motion .nav > *, html.motion .nav-links > * {
  opacity: 0;
  transform: translateY(0.42em);
  transition: opacity 520ms var(--ease), transform 520ms var(--ease);
}
html.motion .nav.in > *, html.motion .nav.in .nav-links > * { opacity: 1; transform: none; }
/* left to right, so it reads as the page setting itself up */
/* A flight arrival keeps the header exactly where it was on the page the
   visitor just left. §12e's default hides every nav child and fades it back
   with a stagger, which across a cut reads as the header leaving and returning
   -- the one piece of chrome that should be proof the page never went away.

   Setting the final values is necessary and, on its own, was not sufficient --
   which is the whole story of this bug. The reasoning it replaces was: write the
   final values and the computed style never changes, so there is nothing to
   transition. That is only true if the class is present the first time the
   element's style is resolved. It is not: the rule lives on <html> and is added
   by caseFlight(), which runs partway down main.js, by which point something
   earlier has already forced a recalc with .nav > * at its default opacity 0.
   Adding the class then does exactly what adding a class normally does -- it
   changes a transitioned property, and the header fades in from nothing.

   Measured on .nav-brand (see below), it went 0 -> 1 over 430ms on every single
   flight arrival, for as long as this effect has existed.

   `transition: none` is what actually closes it, because it does not depend on
   when the class lands. Dropping .flight-in after the landing restores the
   520ms transition with opacity already at 1 and staying there, so nothing
   animates on the way out either. The cost is that a nav link hovered during
   the ~1.2s of a flight snaps rather than eases; that is a fair trade for the
   one piece of chrome whose whole job is to prove the page never went away.

   NOTE ON MEASURING THIS: the visible wordmark is .nav-brand-text, but the
   element that carries the entrance is its parent, .nav-brand -- `.nav > *`.
   The child's own opacity is 1 throughout. A check that samples .nav-brand-text
   will pass while the header is visibly fading, which is exactly what happened
   here for several rounds. */
html.motion.flight-in .nav > *,
html.motion.flight-in .nav-links > * { opacity: 1; transform: none; transition: none; }

html.motion.arriving .nav.in .nav-brand { transition-delay: 0ms; }
html.motion.arriving .nav.in .nav-links > *:nth-child(1) { transition-delay: var(--stagger); }
html.motion.arriving .nav.in .nav-links > *:nth-child(2) { transition-delay: calc(var(--stagger) * 2); }
html.motion.arriving .nav.in .nav-contact { transition-delay: calc(var(--stagger) * 3); }

/* --- the page around the sheet crosses the cut as a fade ---
   Asked for directly: on a case-card click the header stays put, the artwork
   flies, and *everything else* fades out on the way off the homepage and back
   in on the case study. Before this the rest of the page simply ceased to exist
   at the navigation boundary, which is a hard cut sitting right next to a
   transition whose entire claim is continuity.

   Two classes rather than one, and the split is load-bearing. The transition
   lives on .page-fade, which stays put; only .is-out is added and removed. Put
   the transition on the same class as the opacity and removing it takes the
   transition away in the same frame as the value, so the element snaps back to
   1 instead of fading to it.

   Which elements: pageWipe() and caseFlight() walk up from the sheet and mark
   every SIBLING along the way (see faders() in main.js). Marking ancestors
   instead would fade the sheet with them, and opacity on an ancestor cannot be
   undone by a descendant -- there is no opacity: 1 that escapes a parent's 0.

   --page-fade is written by whichever side is driving, because the two want
   different lengths: the departure has to be finished inside the lift, the
   arrival has the whole flight to play with. */
html.motion .page-fade { transition: opacity var(--page-fade, 520ms) var(--ease); }
html.motion .page-fade.is-out { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  html .page-fade { transition: none; }
  html .page-fade.is-out { opacity: 1; }
}

/* --- figures: wipe open from the left --- */

html.motion .img-wipe {
  clip-path: inset(0 100% 0 0);
  transition: clip-path var(--slow) var(--ease);
}
html.motion .img-wipe.in { clip-path: inset(0 0 0 0); }

/* ------------------------------------------------------------
   13. RESPONSIVE: 8 cols at tablet, 4 at phone
   ------------------------------------------------------------ */

@media (max-width: 64rem) {
  :root { --cols: 8; --nav-h: 4rem; }

  .col-main, .col-text, .col-half-l, .col-half-r, .col-side { grid-column: 1 / -1; }

  .nav-links { grid-column: 3 / span 6; justify-content: flex-end; gap: var(--s-5); }
  
  .work-body { grid-column: 2 / -1; }
  .work-thumb { grid-column: 2 / -1; margin-top: var(--s-5); }

  /* column 5 and column 8 both exist at 8 columns, but leave too little room
     for "Graves" or the tag to sit comfortably, so stack instead, same as a
     phone: tag, then each line of the name, full width, cue last */
  .hero-tag { grid-column: 1 / -1; grid-row: 1; align-self: start; }
  .hero-headline .l1 { grid-column: 1 / -1; grid-row: 2; }
  .hero-headline .l2 { grid-column: 1 / -1; grid-row: 3; }
  /* .hero-cue needs no override here: it's positioned by JS measurement
     (heroCue() in main.js) against Graves' actual rendered box, not by grid
     placement, so it already follows the stack at any breakpoint. */

  /* §4's Company/Role/Date/Summary column split assumes a 12-column grid
     (nth-child spans like 7/-1 for the right half); at 8 or 4 columns
     those line numbers no longer land where they're meant to, so this
     drops back to a plain full-width stack rather than a half-broken grid.
     Same nth-child selectors as §4, matched for specificity rather than
     reaching for !important. */
  .cs-meta > div:nth-child(1),
  .cs-meta > div:nth-child(2),
  .cs-meta > div:nth-child(3),
  .cs-meta > div:last-child { grid-column: 1 / -1; }

  /* The staircase is the composition, and a composition needs room to be one.
     Below this width there is none, so every piece takes the full measure and
     the page becomes a single column in source order — which is why the markup
     is ordered lede → head → how → aside → before rather than grouped by which
     side of the grid each piece lands on above. */
  .bio-lede, .portrait, .bio-block, .bio-aside {
    grid-column: 1 / -1;
    grid-row: auto;
    align-self: auto;
    margin-top: 0;
  }
  .info-top { row-gap: var(--s-7); }
  .portrait { position: static; margin-top: var(--s-7); }
  .portrait-draw { width: 66%; }
  .footer { row-gap: var(--s-5); }
  .footer-reach, .footer-social, .footer-info, .footer-base { grid-column: 1 / -1; justify-self: start; text-align: left; }
  .footer-social { flex-direction: row; flex-wrap: wrap; gap: var(--s-5); }
  .footer-info { flex-wrap: wrap; gap: var(--s-5); }
  .nav-links { grid-column: 3 / span 4; }
}

@media (max-width: 40rem) {
  :root { --cols: 4; }

  .nav { height: auto; padding-block: var(--s-3); row-gap: var(--s-2); }
  .nav-brand { grid-column: 1 / span 2; }
  .nav-links { grid-column: 1 / span 2; grid-row: 1; gap: var(--s-5); }
  .nav-contact { grid-column: 3 / -1; grid-row: 1; }
  
  /* a phone doesn't have the height to spend on a half-empty hero, so let the
     content set its own height rather than stretching to fill the viewport */
  .hero {
    min-height: auto;
    padding-block: calc(var(--nav-h) + var(--s-9)) var(--s-8);
  }
  .hero-lead { row-gap: var(--s-7); }
  /* a 4-column grid doesn't have a 5th or 8th line to open on, so stack
     instead: tag, then each line of the name, full width, cue last */
  .hero-tag { grid-column: 1 / -1; grid-row: 1; align-self: start; }
  .hero-headline .l1 { grid-column: 1 / -1; grid-row: 2; }
  .hero-headline .l2 { grid-column: 1 / -1; grid-row: 3; }
  /* .hero-cue needs no override here: it's positioned by JS measurement
     (heroCue() in main.js) against Graves' actual rendered box, not by grid
     placement, so it already follows the stack at any breakpoint. */

  /* stacked on mobile the number becomes a caption above the row, so it needs
     its own space rather than sitting on top of the company line */
  .work-idx { grid-column: 1 / -1; padding-top: 0; margin-bottom: var(--s-3); }
  .work-body, .work-thumb { grid-column: 1 / -1; }
  .work-thumb { margin-top: var(--s-5); }

  .figure-row, .figure-row--3 { grid-template-columns: 1fr; }
  .row { flex-wrap: wrap; gap: var(--s-1); }
  .row .when { white-space: normal; }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
  /* these have to out-specify the html.motion rules in §12e, or the hidden
     states survive and the page stays blank for anyone who asked for less
     motion, the exact opposite of the intent */
  html.motion .reveal { opacity: 1; transform: none; }
  html.motion .mask > span { transform: none; }
  html.motion .nav > *, html.motion .nav-links > * { opacity: 1; transform: none; }
  html.motion .img-wipe, html.motion .img-wipe.in { clip-path: none; }
  html.motion :is(.mask, .reveal) { transition-delay: 0ms; }
}

@media print {
  .nav, .progress { display: none; }
  body { background: #fff; }
}
