/* ==========================================================================
   Tokens
   ==========================================================================

   Colour strategy: restrained chrome, committed covers.

   One theme, dark. The light palette and the theme control are gone on
   purpose — the site is read one way, so the tokens describe one surface and
   every ratio below is a fact rather than a pair of facts to keep in step.

   The page itself is achromatic — a lifted near-black, never #000. All
   saturated colour on the site lives inside the project cover plates. The
   chrome has no opinion; the work does.

   One rule holds the system together: no cover plate ever uses the accent's
   hue band (115–150). The accent therefore always reads as interface — focus,
   state, availability — and never as content. The band moved with the accent
   when it went from orange to green; the nearest shipped hue is Autograph's
   plate at 96.

   Every text tier below was measured against the surface before it was written
   down. Ratios are noted inline; the floor is 4.5:1.
   ========================================================================== */

@font-face {
  font-family: 'Mona Sans';
  font-style: normal;
  font-display: swap;
  font-weight: 200 900;
  font-stretch: 75% 125%;
  src: url('/fonts/mona-sans-var.woff2') format('woff2-variations');
  /* Latin only — covers the en/em dashes and curly quotes used in the copy. */
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  color-scheme: dark;

  /* --- Surface ----------------------------------------------------------- */
  --bg: oklch(0.19 0.005 265);            /* #131416 — lifted, not #000 */
  --surface: oklch(0.235 0.006 265);      /* #1D1E21 — raised planes */
  /* The junk drawer band lifts rather than recesses: sinking a band 0.025
     below a 0.19 surface is a six-step RGB difference the eye cannot find. */
  --band: oklch(0.245 0.007 265);

  /* --- Ink --------------------------------------------------------------- */
  --ink: oklch(0.965 0.002 265);          /* #F3F3F5 — 16.7:1 on bg */
  --ink-2: oklch(0.77 0.006 265);         /* #B2B5B9 — clears 4.5:1 on the lightest speck */
  /* There is no third ink tier. A grain strong enough to see needs the whole
     lightness budget: at the shipped amplitude a third tier would have to sit
     within a few levels of --ink-2 to clear 4.5:1 against the darkest speck,
     which is not a tier at all. Small text gets its hierarchy from the width
     axis and the size scale instead, which is where this site takes it anyway. */

  --rule: oklch(1 0 0 / 0.11);
  --rule-strong: oklch(1 0 0 / 0.20);

  /* --- Accent ------------------------------------------------------------ */
  --accent: oklch(0.708 0.202 134);       /* #68BA03 —  7.6:1 on bg */
  --accent-quiet: oklch(0.708 0.202 134 / 0.16);

  /* The dock's current-section chip: a raised plane, not a tint. Not white —
     the label on it is --ink, which is near-white, so a white fill would erase
     it. A step above the glass instead, which is what "raised" means on a dark
     surface anyway. */
  --chip: oklch(0.36 0.008 265);

  /* The folder's back leaf — a step *up* from --chip, so the leaf furthest
     from you still separates from the band on tone alone. */
  --folder-back: oklch(0.30 0.007 265);

  /* The folder's front leaf. Thinner than --glass: the dock has to hold a text
     label over saturated project plates, this leaf carries a single glyph. */
  --folder-glass: oklch(0.36 0.008 265 / 0.62);

  /* --- Depth ------------------------------------------------------------- */
  /* A drop shadow alone is nearly invisible on a dark surface. Depth comes
     from a darker ambient shadow *plus* a 1px lit top edge — the raised plane
     catches the light instead of casting onto nothing. Still short by design:
     nothing blurs past 16px. */
  --shadow-1:
    0 1px 2px oklch(0 0 0 / 0.45),
    0 2px 5px -1px oklch(0 0 0 / 0.40);
  --shadow-2:
    0 1px 3px oklch(0 0 0 / 0.50),
    0 4px 10px -3px oklch(0 0 0 / 0.50),
    0 10px 16px -10px oklch(0 0 0 / 0.60);
  --ring:
    inset 0 1px 0 oklch(1 0 0 / 0.07),
    inset 0 0 0 1px oklch(1 0 0 / 0.06);

  /* The junk drawer's artwork is light. Left alone it becomes the brightest
     thing on the page and reads as more important than the case work, which is
     backwards. Held back a touch, restored on hover. */
  --media-dim: 0.9;

  /* --- Grain ------------------------------------------------------------- */
  /* A pre-rendered 190px tile, not a live SVG filter. It was a data-URI
     running feTurbulence on every paint, which iOS Safari rasterises to its
     own recipe — the texture came out coarse and blotchy on a phone — and
     which has to be re-filtered whenever a surface under it repaints, which is
     what made the sticky masthead judder while scrolling. The tile is the
     Chromium render of that same filter, frozen, so the design is unchanged
     and every browser now agrees on it. It still stitches seamlessly: the
     source used `stitchTiles` and the capture is exactly one tile. */
  --grain: url('/images/grain.webp');

  /* The dock's glass. Lifted above the page surface so it reads as floating
     rather than as a hole cut in it, and opaque enough that its labels hold
     contrast over the saturated project plates it passes across. */
  --glass: oklch(0.3 0.007 265 / 0.76);
  --glass-ring:
    inset 0 1px 0 oklch(1 0 0 / 0.1),
    inset 0 0 0 1px oklch(1 0 0 / 0.08);

  /* The hero mark sits beside the headline rather than behind it, so it can
     carry real weight without ever crossing text. Light-on-dark carries
     further at the same alpha, so this sits a little under 0.6.

     Tinted toward the accent rather than set to it: the accent's own chroma
     turns a field of character art into a solid block of green, which is the
     one thing the palette says chrome must not do. The alpha halves whatever
     chroma is set here, so 0.09 composited to roughly 0.045 and read as plain
     grey. It also sits lighter than the accent on purpose: at the accent's own
     0.708 the mark drops close to the surface it is drawn on and stops
     reading. */
  --mark-ink: oklch(0.85 0.16 134 / 0.5);
}

:root {
  /* --- Type -------------------------------------------------------------- */
  --font: 'Mona Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;

  /* One family, three widths. Hierarchy comes from the width axis rather than
     from a second typeface: extended for display, normal for reading, slightly
     condensed for labels. Most portfolios reach for a font with no width axis
     and then need two families to say the same thing. */
  --wdth-display: 115;
  --wdth-text: 100;
  --wdth-label: 94;

  --wght-display: 500;
  --wght-text: 400;
  --wght-label: 550;

  /* Modular scale, ratio ~1.28. Root font-size is fluid (see base.css), so the
     whole scale is fluid for free and the ratio never drifts. */
  --step--2: 0.75rem;
  --step--1: 0.8125rem;
  --step-0: 1rem;
  --step-1: 1.28rem;
  --step-2: 1.64rem;
  --step-3: 2.1rem;
  --step-4: 2.7rem;
  --step-5: 3.5rem;                       /* 56px → 74px. Never shouting. */

  --lh-tight: 1.04;
  --lh-snug: 1.22;
  --lh-body: 1.6;

  --track-display: -0.022em;              /* floor is -0.04em; this is plenty */
  --track-text: -0.005em;
  --track-label: 0.02em;

  /* --- Space ------------------------------------------------------------- */
  --space-3xs: 0.25rem;
  --space-2xs: 0.5rem;
  --space-xs: 0.75rem;
  --space-sm: 1rem;
  --space-md: 1.5rem;
  --space-lg: 2.25rem;
  --space-xl: 3.5rem;
  --space-2xl: 5.5rem;

  --gutter: clamp(1.25rem, 5vw, 4.5rem);
  --section: clamp(4.5rem, 9vw + 1.5rem, 9rem);
  /* Measured, not assumed: `ch` is the width of "0", and Mona Sans sets a wide
     zero relative to its lowercase. 66ch of this face renders ~88 characters a
     line. 54ch measures out at ~70, which is where it actually wants to be. */
  --measure: 54ch;
  /* Narrowed hard. The page reads as a column rather than a spread; a wide
     shell is most of what made the old layout feel sprawling rather than
     composed. */
  --maxw: 56rem;

  /* --- Icons --------------------------------------------------------------
     Pixel icons are drawn on a 24-unit grid, so a glyph only lands on whole
     device pixels at a whole multiple of 24 of them. 12px is exact on the 2x
     displays this is mostly read on and merely soft on 1x; 24px is exact
     everywhere. There is no third size, and 14 or 16 is not one — those put
     half a pixel on every edge and the grid stops reading as a grid.

     --icon-lg is the exploration shelf's tile glyph. 48 is 2x24, so it is
     exact on 1x and 2x alike — the same rule, not an exception to it. The
     tile it sits in is a fixed 88px rather than a rem, because an icon on a
     shelf is a fixed object and should not grow with the type scale. */
  --icon: 12px;
  /* The expand control on a case-study image. 24 is the grid's own size, so it
     is exact on every display — and 12 is too small to hit on a phone. */
  --icon-md: 24px;
  --icon-lg: 48px;
  --tile: 88px;
  /* The folder. Wider than the pieces it holds and landscape rather than
     square, because a folder that measures the same as its contents reads as
     one more item on the shelf. The glyph on its front stays 24 — the box may
     be any size, the pixel art may not. */
  /* Px, not rem, for the same reason --tile is: this is a fixed object on the
     page rather than something that should track the type scale — and the root
     font-size here is fluid, so a rem clamp compounds with the viewport twice
     and overshoots badly. 148 on a phone, where it has to fit a 9rem grid
     track; 192 once there is room, which is the third again it was missing on
     a wide screen. */
  --folder-w: clamp(148px, 8vw + 60px, 192px);

  /* --- Radius ------------------------------------------------------------ */
  --r-sm: 4px;
  --r-md: 8px;
  --r-lg: 12px;
  --r-pill: 999px;

  /* --- Motion ------------------------------------------------------------ */
  --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  /* Eased at both ends. The two above are out-curves: they leave at full speed
     and stop dead, which is right for something arriving and wrong for
     something settling. */
  --ease-soft: cubic-bezier(0.4, 0, 0.2, 1);
  --dur-1: 140ms;
  --dur-2: 280ms;
  --dur-3: 560ms;

  /* --- Z scale ----------------------------------------------------------- */
  --z-base: 0;
  --z-raised: 10;
  --z-sticky: 100;
  --z-backdrop: 400;
  --z-modal: 410;
}

/* ==========================================================================
   Base — reset, element defaults, focus
   ========================================================================== */

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

/* 16px on phones → 21px on a large desktop. Drives the entire rem scale, so
   the modular ratio holds at every viewport without a single breakpoint. */
html {
  font-size: clamp(16px, 0.42vw + 0.86rem, 21px);
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* The sticky header is 3.5rem; anchors land clear of it. */
  /* Enough that an anchored chapter clears the sticky masthead. */
  scroll-padding-top: 5rem;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

body {
  margin: 0;
  background-color: var(--bg);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--step-0);
  font-weight: var(--wght-text);
  font-variation-settings: 'wdth' var(--wdth-text);
  line-height: var(--lh-body);
  letter-spacing: var(--track-text);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* `clip`, not `hidden`. `hidden` makes the body a scroll container, which
     re-scopes every `position: sticky` inside it to the body's own scrollport
     — on iOS Safari that shows up as the masthead juddering against momentum
     scrolling. `clip` cuts the overflow without creating a scroll container,
     so the masthead sticks to the viewport as intended. */
  overflow-x: clip;
}

/* Light type on a dark surface reads a weight lighter than it is, so both the
   leading and the weight are nudged back up. Unconditional now that dark is
   the only theme. */
body {
  line-height: calc(var(--lh-body) + 0.06);
  font-weight: 420;
}

h1,
h2,
h3,
h4 {
  margin: 0;
  font-weight: var(--wght-display);
  font-variation-settings: 'wdth' var(--wdth-display);
  line-height: var(--lh-tight);
  letter-spacing: var(--track-display);
  text-wrap: balance;
}

h3,
h4 {
  line-height: var(--lh-snug);
  letter-spacing: -0.012em;
}

p {
  margin: 0;
  text-wrap: pretty;
}

ul,
ol {
  margin: 0;
  padding: 0;
  list-style: none;
}

img,
svg {
  display: block;
  max-width: 100%;
}

img {
  height: auto;
}

a {
  color: inherit;
  text-decoration: none;
}

button {
  font: inherit;
  letter-spacing: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}

::selection {
  background: var(--accent-quiet);
  color: var(--ink);
}

:where(a, button, [tabindex], summary):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: var(--r-sm);
}

/* --- Utilities ----------------------------------------------------------- */

/* Years, indices, and any figure that needs to align in a column. */
.tabular {
  font-variant-numeric: tabular-nums;
}

.measure {
  max-width: var(--measure);
}

/* The condensed cut of the family. Used for meta and eyebrow-free labels. */
.label {
  font-size: var(--step--1);
  font-weight: var(--wght-label);
  font-variation-settings: 'wdth' var(--wdth-label);
  letter-spacing: var(--track-label);
  color: var(--ink-2);
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  top: var(--space-2xs);
  left: var(--space-2xs);
  z-index: var(--z-sticky);
  padding: var(--space-2xs) var(--space-sm);
  background: var(--surface);
  color: var(--ink);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-2);
  transform: translateY(-200%);
}

.skip-link:focus-visible {
  transform: none;
}

.shell {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

/* ==========================================================================
   Site
   ========================================================================== */

/* --- Masthead ------------------------------------------------------------ */

.masthead {
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
  background-color: var(--bg);
}

/* The rule appears only once the page has moved. No glass, no blur — a solid
   surface and a hairline is enough to separate the header from the content. */
.masthead::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  height: 1px;
  background: var(--rule);
  opacity: 0;
  transition: opacity var(--dur-2) var(--ease-out-quart);
}

.masthead.is-stuck::after {
  opacity: 1;
}

.masthead__inner {
  display: flex;
  align-items: center;
  gap: var(--space-md);
  min-height: 3.5rem;
}

.wordmark {
  display: flex;
  /* Centred, not baseline: the mark is a graphic and has no baseline to sit on. */
  align-items: center;
  gap: 0.5rem;
  margin-inline-end: auto;
  /* --wordmark-mark is the one number that scales the mark. */
  --wordmark-mark: 1.7em;
}

/* Ink, not a brand colour. The page is achromatic and the four project plates
   carry all the saturation; a coloured mark in the masthead put chrome into
   that budget and made the wordmark compete with the work below it. Inheriting
   currentColor also means the mark tracks the theme like everything else here
   instead of being the one thing that ignores it. Sized from height alone —
   the source viewBox keeps its own 1.2:1 aspect. */
.wordmark__mark {
  height: var(--wordmark-mark);
  width: auto;
  flex: none;
  fill: currentColor;
}

.wordmark__name {
  font-weight: 600;
  font-variation-settings: 'wdth' 104;
  letter-spacing: -0.015em;
}

.masthead__resume {
  position: relative;
  font-size: var(--step--1);
  font-weight: 500;
  color: var(--ink-2);
  transition: color var(--dur-1) var(--ease-out-quart);
}

.masthead__resume::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -0.32em;
  height: 1.5px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-2) var(--ease-out-expo);
}

.masthead__resume:hover {
  color: var(--ink);
}

.masthead__resume:hover::after {
  transform: scaleX(1);
}

/* --- Pixel icons ---------------------------------------------------------
   HackerNoon's Pixel Icon Library, inlined by src/icons.mjs. They share the
   hero mark's language — both are drawn on a grid and read as bitmap rather
   than as line art — which is the whole reason they are allowed here. Their
   brief stops at navigation and metadata; no case-study body takes one.

   Size comes from --icon and nothing else overrides it casually: see the token
   for why 12 and 24 are the only two values that keep the grid crisp. */

.icon {
  display: inline-block;
  flex: none;
  width: var(--icon);
  height: var(--icon);
  /* These are drawn to the full 24-unit box, so they optically sit high beside
     lowercase text. Dropped to sit on the same line as the x-height. */
  vertical-align: -0.1em;
}

/* --- Intro --------------------------------------------------------------- */

/* The section below brings its own --section of top padding, so the intro only
   contributes the difference. Stacking both leaves a gap with nothing in it. */
.intro {
  padding-block: clamp(3.5rem, 8vw, 7rem) var(--space-xl);
}

/* The mark sits in its own column beside the headline rather than behind the
   text. Placement is explicit, so the source order can stay the reading order
   and the single-column fallback needs no reordering. */
.intro__inner {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  column-gap: clamp(1.5rem, 4vw, 4rem);
  align-items: center;
}

.intro__lead {
  grid-column: 1;
  grid-row: 1;
}

.intro__body {
  grid-column: 1;
  grid-row: 2;
}

.intro__workplace {
  grid-column: 1;
  grid-row: 3;
}

/* --- Hero mark ------------------------------------------------------------
   Character art needs a monospace face to align at all — this is the one place
   on the site where mono is structural rather than costume. The system stack
   costs no request; app.js measures the resulting cell ratio and resamples the
   baked mask to it, so the proportions hold across faces. */
.mark {
  grid-column: 2;
  /* Spans the text rows but pinned to the top of the first, so it reads as
     sitting beside the headline rather than floating against the paragraphs. */
  grid-row: 1 / span 3;
  align-self: start;
  margin: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: clamp(5px, 0.63vw, 8.5px);
  line-height: 1;
  letter-spacing: 0;
  white-space: pre;
  color: var(--mark-ink);
  pointer-events: none;
  user-select: none;
}

/* One column below this, and the mark leads: it sits above the headline rather
   than beside it, so the fold opens on the logo. Rows are placed explicitly
   rather than reordered in the source, which keeps the reading order intact —
   safe here only because the mark is aria-hidden and not focusable. */
@media (width < 52rem) {
  .intro__inner {
    grid-template-columns: minmax(0, 1fr);
  }

  .intro__lead,
  .intro__body,
  .intro__workplace,
  .mark {
    grid-column: 1;
  }

  .mark { grid-row: 1; }
  .intro__lead { grid-row: 2; }
  .intro__body { grid-row: 3; }
  .intro__workplace { grid-row: 4; }

  .mark {
    justify-self: start;
    margin-block: 0 var(--space-lg);
    font-size: clamp(5px, 1.75vw, 9px);
  }
}

/* Where he works. This used to sit above the headline as a status line with a
   pulsing accent dot — the idiom for "available", which is the wrong thing to
   say about an employer. The dot is gone with it: a pulse is a notice, and this
   is a fact.

   It reads as a badge instead — a raised plate carrying a quiet prefix and the
   employer's name at full ink. The name is what someone is scanning for, so
   the prefix takes the label tier and gets out of its way. */
/* No plate. The hero is entirely type — a headline and two paragraphs, all
   starting on one left edge — and a pill was the only boxed object in it, with
   its contents pushed 35px in from that edge by its own padding. It read as a
   widget dropped into an editorial column. Stripped back it is what it always
   was: a line of copy with a pin in front of it, starting where every other
   line starts. */
.intro__workplace {
  display: flex;
  align-items: baseline;
  /* Body size, not the label tier `.label` otherwise sets. The line still
     takes the label width axis and tracking, so the hierarchy is carried by
     width rather than by size, which is the rule everywhere else here. Wins on
     source order: base.css `.label` is concatenated ahead of this file. */
  font-size: var(--step-0);
  /* Spaced individually, not by one flex gap. A single gap set the pin and the
     word break to the same distance, which made "Currently at | Best Egg" sit
     at 1.8 word spaces — wide enough to read as two things rather than as one
     sentence. */
  gap: 0;
  margin-top: var(--space-lg);
  justify-self: start;
}

/* The pin is the whole reason the chip reads as a place rather than a state.
   12px, an exact half of the 24-unit grid it is drawn on. */
/* Nudged onto the text baseline now that there is no plate centring it. A
   glyph is not a word, so it keeps more air than the words do. */
.intro__workplace-pin {
  margin-right: 0.4rem;
  color: var(--ink-2);
  transform: translateY(1px);
}

/* One size for the line — the label tier the prefix already sits on. The name
   separates on ink and weight instead, which is enough at this size and keeps
   the chip from becoming two typographic voices in one plate. */
.intro__workplace-name {
  /* One word space, in em so it tracks the type rather than the root. The name
     separates on ink and weight; it does not need extra room as well. */
  margin-left: 0.3em;
  color: var(--ink);
  font-weight: 650;
  text-decoration: none;
  transition: color var(--dur-1) var(--ease-out-quart);
}

.intro__workplace-name:hover,
.intro__workplace-name:focus-visible {
  color: var(--accent);
}

/* The rem scale alone would put this at 56px on a 390px screen, where it wraps
   to five ragged lines. The vw term lets it come down on narrow viewports
   without giving up the size where there is room for it. */
/* The lead breaks where the copy says it breaks. The line is stored with a
   real newline rather than a `<br>`, because the dev editor makes this node
   `contenteditable="plaintext-only"` and round-trips it through `textContent`
   — markup put here would be stripped on the first edit, where a newline
   survives and stays something Cody can move. `pre-line` honours it and still
   wraps normally on a narrow screen. The measure has to clear the longest
   authored line, or the break just adds a third row. */
.intro__lead {
  max-width: 30ch;
  white-space: pre-line;
  /* `pretty` is what keeps "& tinkerer." whole when the second authored line
     has to wrap on a phone: it refuses to strand a short last line, so the
     break lands before the ampersand rather than after it. `balance` was tried
     first and does nothing here, because the authored newline already splits
     this into two blocks for it to balance separately.

     It replaces a non-breaking space that used to sit in the JSON as a raw
     U+00A0 byte, invisible in an editor and destroyed by anything that
     rewrote the file. Where `pretty` is unsupported the line simply wraps
     greedily again, which is the old behaviour and costs nothing. Typography
     belongs in the stylesheet, not as a hidden character in the copy. */
  text-wrap: pretty;
  font-size: clamp(1.6rem, 4.2vw, var(--step-3));
  margin-bottom: var(--space-lg);
}

.intro__body {
  display: grid;
  gap: var(--space-sm);
  color: var(--ink-2);
  font-size: var(--step-0);
  line-height: 1.52;
}

/* --- Paper -----------------------------------------------------------------
   The grain is a background *layer* on the surfaces themselves rather than an
   overlay element: an overlay would either sit under the banded section and
   lose it, or sit over the content and take the crispness off the type and the
   project plates. As a layer it reaches every surface and touches nothing that
   sits on top of them. Scroll attachment, not fixed — random noise has no
   alignment cue, so nothing gives away the phase change at a section boundary,
   and fixed backgrounds repaint badly on mobile.

   Composited normally: the speck layers already carry their own sign, so no
   blend mode is involved and the result is the same on any surface. */

body,
.masthead,
.section--band {
  background-image: var(--grain);
  background-size: 190px 190px;
}

/* --- Sections ------------------------------------------------------------ */

.section {
  padding-block: var(--section);
}

.section--band {
  background-color: var(--band);
  /* Hairlines at both edges so the boundary is explicit even where the fill
     difference is slight. */
  border-block: 1px solid var(--rule);
}

.section__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-sm) var(--space-lg);
  padding-bottom: var(--space-md);
  margin-bottom: var(--space-xl);
  border-bottom: 1px solid var(--rule);
}

.section__title {
  font-size: var(--step-1);
}

.section__note {
  color: var(--ink-2);
  font-size: var(--step--1);
  line-height: 1.55;
  max-width: 46ch;
}

/* --- Work index ---------------------------------------------------------- */

/* One project per row.

   This went through an alternating wide/half rhythm and then two equal
   columns. The covers were what suffered in both: a device mockup rendered
   430px wide is a picture of an app rather than the app itself. At full
   measure the same plate is twice the size, and the index reads as a few
   statements rather than a grid of thumbnails.

   The cost is page length, and the card body pays it back — the tags leave the
   flow and sit opposite the title, so the text under a wide plate is three
   lines instead of five. */
.work-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-2xl);
}

.work__link {
  display: grid;
  gap: var(--space-md);
}

.work__plate {
  display: block;
  overflow: hidden;
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-2);
  transition: box-shadow var(--dur-2) var(--ease-out-quart),
    transform var(--dur-2) var(--ease-out-expo);
}

.work__plate img {
  width: 100%;
  /* 16:9, because that is what the covers are. At 16:10 a 16:9 source loses
     a slice off each side, and on a cover composed around a device that slice
     is the edge of the frame. A 16:10 source in this box crops about 5% off
     the top and bottom instead, which make-cover.mjs already allows for by
     holding the device inside the middle 76%. */
  aspect-ratio: 16 / 9;
  object-fit: cover;
  transition: transform var(--dur-3) var(--ease-out-expo);
}

.work__body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  column-gap: var(--space-xl);
  row-gap: var(--space-3xs);
  align-content: start;
}

.work__meta,
.work__title,
.work__summary {
  grid-column: 1;
}

@media (width < 46rem) {
  .work__body {
    grid-template-columns: minmax(0, 1fr);
  }
}

.work__meta {
  display: flex;
  align-items: center;
  gap: 0.55rem;
}

.work__dot {
  width: 3px;
  height: 3px;
  border-radius: var(--r-pill);
  background: currentColor;
  opacity: 0.6;
}

/* Still the sub-head tier. Under a plate this wide it needs more presence than
   it did in a two-up grid, and the width axis is where that comes from — a
   fifth size in the scale is not. */
.work__title {
  font-size: var(--step-1);
  font-variation-settings: 'wdth' var(--wdth-display);
  letter-spacing: var(--track-display);
  margin-top: 0.1rem;
}

.work__summary {
  color: var(--ink-2);
  max-width: 52ch;
}

/* The disciplines, as outlines rather than fills. A filled chip would be the
   fifth saturated thing in a card that already carries a full-bleed cover; a
   hairline says the same word for none of the colour budget. */
.work__tags {
  grid-column: 2;
  grid-row: 1 / span 3;
  align-self: start;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--space-3xs);
  margin-top: 0.2rem;
}

@media (width < 46rem) {
  .work__tags {
    grid-column: 1;
    grid-row: auto;
    justify-content: flex-start;
    margin-top: var(--space-2xs);
  }
}

.work__tag {
  border: 1px solid var(--rule-strong);
  border-radius: var(--r-pill);
  padding: 0.22em 0.62em;
  color: var(--ink-2);
  transition: border-color var(--dur-2) var(--ease-out-quart),
    color var(--dur-2) var(--ease-out-quart);
}

/* The affordance is an arrow that arrives on hover, not a "read more" line
   repeated under every project. It occupies its space at all times so nothing
   reflows when it appears. */
.work__arrow {
  display: inline-block;
  margin-inline-start: 0.3em;
  font-variation-settings: 'wdth' 100;
  color: var(--accent);
  opacity: 0;
  transform: translateX(-0.25em);
  transition: opacity var(--dur-2) var(--ease-out-quart),
    transform var(--dur-2) var(--ease-out-expo);
}

.work__link:hover .work__arrow,
.work__link:focus-visible .work__arrow {
  opacity: 1;
  transform: none;
}

.work__link:hover .work__plate {
  transform: translateY(-3px);
  box-shadow: var(--shadow-2), 0 14px 22px -14px oklch(0 0 0 / 0.22);
}

.work__link:hover .work__plate img {
  transform: scale(1.018);
}

.work__link:hover .work__title {
  color: var(--accent);
}

.work__link:hover .work__tag {
  border-color: var(--rule);
}

/* --- Explorations --------------------------------------------------------

   A shelf of icons rather than a second grid of cards. The section holds two
   different kinds of thing — one-off designs that exist only as an image, and
   small apps that exist somewhere you can go and use them — and a card grid
   flattened that difference into six identical rectangles.

   The tile face carries it instead. An app gets a pixel glyph on a field of
   its own hue, the way a launcher names a program you have not opened; a
   design gets its own artwork, because the image *is* the artefact. Same grid,
   same tile box, two faces, no second component. */

/* --- Shelf folders --------------------------------------------------------

   The shelf holds two kinds of thing, and laying all of them out at once said
   they were one kind. Two folders say it first: an app is somewhere else and
   opening one is leaving; a design is only ever a picture and opening one is
   opening it here. The tile faces still carry that difference inside — the
   folder just stops the shelf from asking you to notice it unaided.

   The folder is a container, so it is chrome and it is achromatic. `--surface`
   field, ink glyph, and no hue of its own: the colour on this section belongs
   to the work, and it arrives when a folder opens. The stack behind the tile
   is two empty plates rather than a glyph — there is no folder mark in the
   icon set, and borrowing one from a second library would put two drawing
   languages on one shelf.

   Everything here is gated on `js`. With scripting off the folders are not on
   the page at all and both panels render open under their own headings, which
   is exactly the shelf as it was before folders existed — a control that
   cannot open anything is worse than no control. */

.shelf__folders {
  display: none;
  gap: var(--space-lg) var(--space-md);
  justify-items: center;
}

.js .shelf__folders {
  display: grid;
  /* 9rem, the same track the piece grid uses and for the same reason: two is
     the whole point of the folders, and a pair that falls to one per line on a
     phone buries the contents a full screen down. */
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
}

.folder__btn {
  display: grid;
  justify-items: center;
  gap: 0.2rem;
  width: 100%;
  max-width: 17rem;
  text-align: center;
  align-content: start;
}

.folder__shape {
  position: relative;
  width: var(--folder-w);
  /* One variable, not two: the height follows the width so the proportion
     cannot drift as the folder grows. */
  aspect-ratio: 148 / 118;
  margin-bottom: var(--space-2xs);
  transition: transform var(--dur-3) var(--ease-soft);
}

/* The back leaf and its tab, cut as one silhouette. One element, because a tab
   built as a second box leaves a seam where the two overlap and the edge
   treatment has to cross it. clip-path takes the corners off, which is what a
   manila folder's back looks like anyway — the rounding lives on the front
   leaf, which is the one you actually see. */
.folder__back {
  position: absolute;
  inset: 0;
  background: var(--folder-back);
  clip-path: polygon(0 12%, 0 0, 40% 0, 47% 12%, 100% 12%, 100% 100%, 0 100%);
}

.folder__papers {
  position: absolute;
  inset: 0;
}

/* The contents, peeking. Each page carries the hue of one of the pieces inside
   at the value its own tile uses, so a shut folder still says what is in it —
   this is the work's colour arriving, not the container acquiring one.

   Fanned from the bottom edge, so the rotation splays the tops apart and
   leaves the bottoms stacked where the front leaf will cover them. */
.folder__paper {
  position: absolute;
  left: 50%;
  bottom: 4%;
  display: grid;
  /* Anchored to the top, because the top quarter is the only band that clears
     the front leaf — a preview centred in the page is a preview behind glass. */
  place-items: start center;
  overflow: hidden;
  /* The hue stays visible as a margin around the preview. Filled edge to edge
     the pages lost the colour coding that says which pieces are inside, and a
     folder of pale screenshots became the brightest object on the page. */
  padding: 3px;
  width: 58%;
  /* Tall enough to clear the front leaf by a real margin. At the first
     proportion the pages showed about eight pixels each, which reads as a
     coloured seam rather than as paper. */
  height: 84%;
  border-radius: var(--r-sm);
  background: oklch(0.3 0.045 var(--hue));
  box-shadow: var(--ring);
  transform-origin: bottom center;
  /* Slow and eased at both ends. `--ease-out-expo` over `--dur-2` starts at
     full tilt and stops dead, which on three fanning pages reads as a pop. */
  transition: transform var(--dur-3) var(--ease-soft),
    opacity var(--dur-2) var(--ease-out-quart);
}

/* A design's artwork fills its page. The top quarter is all that clears the
   front leaf, so the crop is anchored there — that band is the only part read
   at full clarity, and everything below it is what the glass is blurring. */
.folder__paper-art {
  width: 100%;
  height: 100%;
  border-radius: 1px;
  object-fit: cover;
  object-position: top center;
  /* Same reason the piece tiles are held back: this artwork is light, and left
     alone a stack of it outshines the case work above. */
  filter: brightness(var(--media-dim));
}

/* An app's page is its icon, full bleed — same reasoning as its tile. */
.folder__paper--icon {
  padding: 0;
  background: none;
}

.folder__paper:nth-child(1) {
  transform: translateX(-50%) rotate(-8deg);
}

.folder__paper:nth-child(2) {
  transform: translateX(-50%) translateY(-4px);
}

.folder__paper:nth-child(3) {
  transform: translateX(-50%) rotate(8deg);
}

/* The front leaf. It is the plane nearest you, so it takes the rounding, the
   ring and the shadow; the back leaf gets none of them and separates on tone
   alone. Its top edge is where the pages stop being visible, which is the
   whole reason the shape reads as a folder rather than as a pile. */
.folder__front {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  top: 38%;
  border-radius: var(--r-sm);
  /* Glass, and the one place on the folder where that is doing real work: what
     is behind this leaf is the pages, so blurring them is the folder showing
     its contents rather than a panel smoothing a background. The sheen is a
     single soft gradient off the top edge — enough to say the surface has a
     face, not enough to become a highlight. */
  background: linear-gradient(to bottom, oklch(1 0 0 / 0.1), transparent 42%),
    var(--folder-glass);
  box-shadow: var(--glass-ring), var(--shadow-1);
  /* 7px, not the dock's 18. A heavy blur flattens three pages into one wash of
     colour, which loses the thing the glass was for. */
  backdrop-filter: blur(7px) saturate(150%);
  -webkit-backdrop-filter: blur(7px) saturate(150%);
  transition: box-shadow var(--dur-2) var(--ease-out-quart);
}

/* Without backdrop-filter there is no refraction to see, so the leaf stops
   pretending and goes solid — a translucent fill over unblurred pages would
   read as a printing error rather than as glass. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .folder__front {
    background: var(--chip);
    box-shadow: var(--ring), var(--shadow-1);
  }
}

.folder__name {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-size: var(--step-1);
  font-weight: 600;
  font-variation-settings: 'wdth' 104;
  letter-spacing: -0.012em;
  transition: color var(--dur-2) var(--ease-out-quart);
}

/* The glyph points where the panel is: down while it is shut, up once it is
   open and there is something above it to shut again. */
.folder__chevron {
  color: var(--ink-2);
  transform: rotate(180deg);
  transition: transform var(--dur-2) var(--ease-out-expo);
}

.folder__tagline {
  color: var(--ink-2);
}

.folder__btn:hover .folder__shape,
.folder__btn:focus-visible .folder__shape {
  transform: translateY(-2px);
}

.folder__btn:hover .folder__front,
.folder__btn:focus-visible .folder__front {
  box-shadow: var(--glass-ring), var(--shadow-2);
}

/* Hover splays the fan a little further, the way a thumb through a folder
   would. Nothing moves far — the pages stay inside the shape, and the whole
   point is that it reads as paper settling rather than as something popping. */
.folder__btn:hover .folder__paper:nth-child(1),
.folder__btn:focus-visible .folder__paper:nth-child(1) {
  transform: translateX(-50%) translateY(-2px) rotate(-11deg);
}

.folder__btn:hover .folder__paper:nth-child(2),
.folder__btn:focus-visible .folder__paper:nth-child(2) {
  transform: translateX(-50%) translateY(-7px);
}

.folder__btn:hover .folder__paper:nth-child(3),
.folder__btn:focus-visible .folder__paper:nth-child(3) {
  transform: translateX(-50%) translateY(-2px) rotate(11deg);
}

.folder__btn:hover .folder__name,
.folder__btn:focus-visible .folder__name {
  color: var(--accent);
}

/* Open: the pages drop square into the folder and fade, because the contents
   are now laid out on the page below and drawing them twice is drawing them
   once too often. */
.folder__btn[aria-expanded='true'] .folder__paper {
  opacity: 0;
  transform: translateX(-50%) translateY(6px) rotate(0deg);
}

.folder__btn[aria-expanded='true'] .folder__chevron {
  transform: none;
}

.folder__btn[aria-expanded='true'] .folder__front {
  box-shadow: var(--glass-ring), var(--shadow-2);
}

/* The panel. Open in the markup, closed by the runtime — see above. */
.js .shelf__panel {
  display: none;
}

.js .shelf__panel[data-open] {
  display: block;
}

/* Open one and it takes the section. The folder row goes with the other
   folder — leaving the row up would put the thing you just left beside the
   thing you just entered, which is how the previous version read as the flat
   shelf with headings bolted onto it. */
.js .shelf.is-open .shelf__folders {
  display: none;
}

.shelf__panel + .shelf__panel {
  margin-top: var(--space-xl);
}

/* The bar names where you are and gets you back out. Without scripting it is
   just the heading: there is nowhere to go back to, because nothing was ever
   closed. */
.shelf__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2xs) var(--space-md);
  margin-bottom: var(--space-lg);
  padding-bottom: var(--space-xs);
  border-bottom: 1px solid var(--rule);
}

.shelf__heading {
  font-size: var(--step-1);
  font-weight: 600;
  font-variation-settings: 'wdth' 104;
  letter-spacing: -0.012em;
}

.shelf__count {
  margin-inline-end: auto;
  color: var(--ink-2);
}

.shelf__close {
  display: none;
  /* Auto on both sides: the count pushes it right while the bar is one line,
     and this keeps it right rather than left-aligned once the bar wraps on a
     phone. */
  margin-inline-start: auto;
  align-items: center;
  gap: var(--space-2xs);
  padding: 0.34rem 0.7rem 0.34rem 0.5rem;
  border-radius: var(--r-pill);
  background: var(--chip);
  color: var(--ink-2);
  box-shadow: var(--ring), var(--shadow-1);
  transition: color var(--dur-2) var(--ease-out-quart),
    box-shadow var(--dur-2) var(--ease-out-quart);
}

.js .shelf__close {
  display: inline-flex;
}

/* The same 24px glyph the lightbox collapses with, and for the same reason —
   this is the one gesture on the page that means "put it back the way it
   was". At 24 it is a whole grid unit per device pixel. */
.shelf__close .icon {
  width: var(--icon-md);
  height: var(--icon-md);
}

.shelf__close:hover,
.shelf__close:focus-visible {
  color: var(--ink);
  box-shadow: var(--ring), var(--shadow-2);
}

/* Contents arrive staggered, so opening a folder reads as a drawer being
   pulled rather than a block appearing. `backwards`, not `both`: nothing may
   be left invisible by an animation that did not run. */
@media (prefers-reduced-motion: no-preference) {
  .js .shelf__panel[data-open] .ex {
    animation: enter 520ms var(--ease-out-expo) calc(var(--i, 0) * 45ms) backwards;
  }
}

.ex-grid {
  display: grid;
  /* 9rem, not 11: 11 packs a nicer caption but needs 376px to fit two columns,
     which is wider than a phone, and a shelf that falls to one icon per row is
     not a shelf. The track stretches past the minimum anyway — the caption's
     real width is the max-width on the link below. */
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--space-lg) var(--space-md);
  justify-items: center;
}

/* An app is a launcher tile and stays one: a glyph on a field at `--tile`,
   the size a program icon is. A design is the artefact itself, and it was
   being served at the same 88px with a 9px matte around it — the frame ended
   up costing more of the tile than the picture did. Two up and full bleed
   instead: the image is the thing, so it gets the room.

   `min(20rem, 100%)` rather than a media query — two columns wherever the
   shell can hold them, one on a phone, where two of these would be back to
   postage stamps. */
.ex-grid--design {
  grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
  gap: var(--space-xl) var(--space-md);
}

.ex-grid--design .ex__link {
  max-width: none;
  justify-items: start;
  text-align: start;
}

/* No fixed square, no matte, no ring. The picture's own edge is the edge; the
   shadow is what lifts it off the band. A hairline here reads as a mount, and
   a mount around a screenshot at this size is the thing that was suffocating
   it. */
.ex-grid--design .ex__tile {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  box-shadow: var(--shadow-2);
}

.ex-grid--design .ex__tile::after {
  content: none;
}

.ex-grid--design .ex__tile--art {
  padding: 0;
  background: none;
}

.ex-grid--design .ex__tile--art img {
  border-radius: inherit;
  box-shadow: none;
}

.ex__link {
  display: grid;
  justify-items: center;
  gap: 0.2rem;
  width: 100%;
  max-width: 14rem;
  text-align: center;
  align-content: start;
}

.ex__tile {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--tile);
  height: var(--tile);
  margin-bottom: var(--space-2xs);
  overflow: hidden;
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-1);
  transition: box-shadow var(--dur-2) var(--ease-out-quart),
    transform var(--dur-2) var(--ease-out-expo);
}

/* An inset shadow would paint under the artwork that fills the tile, so the
   hairline edge goes on an overlay that inherits the same radius. */
.ex__tile::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: var(--ring);
  pointer-events: none;
}

/* The item's own hue, at the chroma the artwork already uses — this is the
   work's colour arriving on the page, not the chrome acquiring one. No
   exploration sits in the 20–50 band, so no tile can be mistaken for the
   accent.

   Measured across the six shipped hues, the glyph runs 5.09–6.50:1 against its
   own field in light and 7.56–8.08:1 in dark. The floor for a non-text graphic
   is 3:1, so there is room for a hue this does not anticipate.

   The field itself sits 1.05–1.21:1 against the band, which is nothing — the
   tile's edge is the ring and the shadow, exactly as it is on the dock chip.
   Do not go looking for the separation in the fill. */
/* The app's own icon, edge to edge. An icon arrives with its own field — the
   orange behind RetroShell's mark, the black behind Crumb's — so the tile adds
   nothing behind it. That is the difference from a design tile, where the hue
   mat is what stops a pale screenshot floating on the band. */
.ex__tile--icon > img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The design tile takes the same hue field, with the artwork matted inside it
   rather than bled to the edges. Full-bleed was the first attempt and it made
   the two faces look like two components: a pale study crop next to a
   saturated glyph reads as a different kind of object, not a different kind of
   work. Matted, both faces are a coloured card — one holding a glyph, one
   holding a picture — and the difference stays the thing it should be. */
.ex__tile--art {
  padding: 9px;
  background: oklch(0.3 0.045 var(--hue));
}

.ex__tile--art img {
  width: 100%;
  height: 100%;
  border-radius: var(--r-sm);
  box-shadow: var(--ring);
  object-fit: cover;
  filter: brightness(var(--media-dim));
  transition: filter var(--dur-2) var(--ease-out-quart);
}

.ex__link:hover .ex__tile--art img,
.ex__link:focus-visible .ex__tile--art img {
  filter: none;
}

/* Leaves the tile: the one mark that says this tile is a doorway rather than
   the thing itself. Designs get none — nothing about them is elsewhere. */
.ex__badge {
  position: absolute;
  right: 0;
  bottom: 0;
  display: grid;
  place-items: center;
  padding: 0.3rem;
  border-start-start-radius: var(--r-md);
  background: var(--bg);
  color: var(--ink-2);
}

.ex__name {
  font-weight: 600;
  font-variation-settings: 'wdth' 104;
  letter-spacing: -0.012em;
  transition: color var(--dur-2) var(--ease-out-quart);
}

.ex__kind {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  color: var(--ink-2);
}

.ex__note {
  margin-top: var(--space-3xs);
  color: var(--ink-2);
  font-size: var(--step--1);
  line-height: 1.5;
  text-wrap: pretty;
}

.ex__link:hover .ex__tile,
.ex__link:focus-visible .ex__tile {
  transform: translateY(-3px);
  box-shadow: var(--shadow-2);
}

.ex__link:hover .ex__name,
.ex__link:focus-visible .ex__name {
  color: var(--accent);
}

/* A motion figure sits in the same frame a still does and matches its box, so
   the page does not reflow when one replaces the other. */
.figure__motion {
  display: block;
  width: 100%;
  height: auto;
  border-radius: inherit;
}

/* --- Impact ---------------------------------------------------------------

   Chapter zero on a case study: the takeaway, then the numbers, before the
   argument that earned them. It rides the chapter grid, so its gutter label
   lines up with Problem and Approach below and it reads as the first chapter
   rather than as a panel bolted on above them. */

.impact__points {
  display: grid;
  gap: var(--space-sm);
  margin: 0;
  padding: 0;
  list-style: none;
  /* Full ink, unlike the chapter prose it sits above. These are the lines for
     someone who is not going to read the rest. */
  color: var(--ink);
}

.impact__points li {
  position: relative;
  padding-inline-start: 1.4rem;
  line-height: 1.5;
}

/* A square, not a disc. Everything else on this page that marks something is
   drawn on a grid — the hero mark, the icons — and a round bullet is the one
   shape that says it came from somewhere else. */
.impact__points li::before {
  content: '';
  position: absolute;
  inset-inline-start: 0.1rem;
  /* Optically centred on the first line rather than on the box. */
  top: 0.62em;
  width: 5px;
  height: 5px;
  background: var(--accent);
}

.impact__metrics {
  /* No explicit row: with the points present this lands beneath them, and
     without them it backfills the row the points would have taken. */
  grid-column: 2;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--space-md) var(--space-lg);
  margin: 0;
  padding-top: var(--space-md);
  border-top: 1px solid var(--rule);
}

.impact__metric {
  display: grid;
  gap: 0.15rem;
  align-content: start;
}

.impact__metric-label {
  margin: 0;
  color: var(--ink-2);
}

/* The sub-head size, not a size of its own. It reads as a figure because of
   the width axis, the weight and the label above it; adding a fifth tier to
   make a number look big is how a four-tier scale quietly becomes six. */
.impact__value {
  margin: 0;
  font-size: var(--step-1);
  font-weight: 600;
  font-variation-settings: 'wdth' var(--wdth-display);
  letter-spacing: var(--track-display);
  line-height: 1.15;
  color: var(--ink);
}

@media (width < 46rem) {
  .impact__metrics {
    grid-column: 1;
  }
}

/* --- Contact ------------------------------------------------------------- */

.contact__inner {
  display: grid;
  gap: var(--space-md);
  justify-items: start;
}

.contact__prompt {
  color: var(--ink-2);
  font-size: var(--step-0);
  line-height: 1.5;
}

.contact__email {
  position: relative;
  max-width: 100%;
  overflow-wrap: break-word;
  /* A longer address than the placeholder must not push the page sideways. */
  font-size: clamp(1.3rem, 4vw, var(--step-2));
  font-weight: var(--wght-display);
  font-variation-settings: 'wdth' var(--wdth-display);
  letter-spacing: var(--track-display);
  margin-top: var(--space-2xs);
}

.contact__email::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -0.08em;
  height: 2px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-2) var(--ease-out-expo);
}

.contact__email:hover {
  color: var(--accent);
}

.contact__email:hover::after {
  transform: scaleX(1);
}

/* --- Text scramble --------------------------------------------------------
   The hover scramble swaps glyphs of differing widths, so these must not be
   allowed to re-wrap mid-effect. app.js locks the width; this stops a wider
   substitute pushing the label onto a second line inside that locked box. */

.masthead__resume,
.footer__links a,
.dock__item,
.pager__title {
  white-space: nowrap;
}

/* --- Dock ------------------------------------------------------------------
   A pane of glass held over scrolling content — the one case the effect is
   actually for, and the only navigation there is below 40rem.

   The blur does real work rather than decoration: everything behind it is
   grained paper, and smoothing that texture is what separates the dock from
   the page. The fill stays opaque enough (0.74) that labels hold contrast over
   the saturated project plates too, not just over the surface.

   It shows one section at a time. The full list is a menu that expands above
   it, which is what lets the same control serve the home page and a case
   study, where the sections are that project's chapters. */

.dock {
  position: fixed;
  z-index: var(--z-sticky);
  bottom: clamp(0.9rem, 2.4vw, 1.75rem);
  left: 50%;
  display: flex;
  flex-direction: column;
  /* Not stretch: the bar and the menu size to their own content, so the bar
     does not inherit the menu's width and the pair stay centred on each other
     as the current label changes length. */
  align-items: center;
  gap: var(--space-2xs);

  opacity: 0;
  visibility: hidden;
  translate: -50% 14px;
  transition: opacity var(--dur-2) var(--ease-out-quart),
    translate var(--dur-2) var(--ease-out-expo),
    visibility 0s linear var(--dur-2);
}

.dock.is-visible {
  opacity: 1;
  visibility: visible;
  translate: -50% 0;
  transition-delay: 0s, 0s, 0s;
}

@media (prefers-reduced-motion: reduce) {
  .dock {
    translate: -50% 0;
  }
}

.dock__bar,
.dock__menu {
  background: var(--glass);
  box-shadow: var(--shadow-2), var(--glass-ring);
  backdrop-filter: blur(18px) saturate(180%);
  -webkit-backdrop-filter: blur(18px) saturate(180%);
}

/* Without backdrop-filter the fill is all that separates it from the page, so
   it stops pretending to be glass and becomes solid. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .dock__bar,
  .dock__menu {
    background: var(--surface);
  }
}

.dock__bar {
  display: flex;
  align-items: center;
  gap: var(--space-3xs);
  padding: 0.3rem;
  border-radius: var(--r-pill);
}

/* The current section. This is the raised chip — same depth language as the
   rest of the site: a short drop shadow and an inner edge that is a hairline
   in light and a lit top edge in dark. */
.dock__current {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  height: 2rem;
  padding-inline: 0.8rem 0.6rem;
  border-radius: var(--r-pill);
  font-size: var(--step--1);
  font-weight: 500;
  color: var(--ink);
  white-space: nowrap;
  background: var(--chip);
  box-shadow: var(--shadow-1), var(--ring);
  transition: box-shadow var(--dur-2) var(--ease-out-quart),
    scale var(--dur-1) var(--ease-out-quart);
}

/* The label is the one part of the dock that changes while you are looking at
   it, and it was the one part that moved without being animated: the chip is
   content-sized and the dock is centred, so "Work" becoming "Explorations"
   resized the bar from both edges at once, in a single frame.

   Two fixes, both cheap. The width is transitioned — app.js measures the
   incoming word and sets it in pixels, because a width cannot animate from or
   to `auto`. And the words are stacked in one grid cell so the outgoing one
   can leave upward while the incoming one arrives from below, which is what
   makes it read as one label changing rather than two labels swapping.

   Both words stay in normal flow. Absolute positioning would have collapsed
   the label to zero width with scripting off, since every child would have
   been out of flow and nothing would have been left to size it. */
.dock__label {
  display: grid;
  overflow: hidden;
  transition: width var(--dur-2) var(--ease-out-expo);
}

.dock__word {
  grid-area: 1 / 1;
  /* Not the grid area's width. A grid item stretches to its column by default,
     which would make every measurement of the incoming word return the width
     the label already has — the new label would never be able to grow. At
     max-content each word is always its own size, the wider one overflows, and
     the label's `overflow: hidden` clips it while the width catches up. */
  width: max-content;
  white-space: nowrap;
  opacity: 0;
  /* Enters from below, leaves upward — the direction of travel is what says
     "replaced by" rather than "flickered". */
  translate: 0 0.45em;
  transition: opacity var(--dur-1) var(--ease-out-quart),
    translate var(--dur-2) var(--ease-out-expo);
}

.dock__word.is-in {
  opacity: 1;
  translate: 0 0;
}

.dock__word.is-out {
  opacity: 0;
  translate: 0 -0.45em;
}

/* The label still changes, it just stops travelling to do it. */
@media (prefers-reduced-motion: reduce) {
  .dock__label,
  .dock__word {
    transition: none;
  }

  .dock__word {
    translate: none;
  }
}

.dock__current:hover {
  box-shadow: var(--shadow-2), var(--ring);
}

.dock__current:active {
  scale: 0.96;
}

/* Points up when closed, because the menu it opens is above the bar; the same
   rotate then turns it into a dismiss arrow when open. The old chevron pointed
   down at a menu that never appears below. */
.dock__chevron {
  color: var(--ink-2);
  transition: rotate var(--dur-2) var(--ease-out-expo);
}

.dock__current[aria-expanded='true'] .dock__chevron {
  rotate: 180deg;
}

.dock__top {
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  border-radius: var(--r-pill);
  color: var(--bg);
  background: var(--ink);
  transition: translate var(--dur-2) var(--ease-out-expo),
    background-color var(--dur-2) var(--ease-out-quart),
    opacity var(--dur-1) var(--ease-out-quart);
}

.dock__top:hover {
  translate: 0 -2px;
  opacity: 0.86;
}

.dock__top:active {
  translate: 0 0;
}

/* Reaching the end is the moment "back to top" stops being a footnote. --accent
   is the token for exactly this — interface state, never content — and it is
   the only saturation the chrome is allowed. The glyph keeps --bg in both
   themes rather than being pinned the way the green forced: measured 5.44:1 in
   light and 7.60:1 in dark, both well clear of the 3:1 a non-text graphic has
   to hold, so one rule covers both instead of two. */
.dock.is-at-end .dock__top {
  background: var(--accent);
}

/* --- Dock menu ------------------------------------------------------------ */

.dock__menu {
  padding: 0.3rem;
  /* A rounded rectangle, not a pill — the bar is one row and can take the full
     pill, but a four-row panel at that radius is an oval. */
  border-radius: 16px;
  transform-origin: bottom center;
}

.dock__menu[hidden] {
  display: none;
}

@media (prefers-reduced-motion: no-preference) {
  .dock__menu:not([hidden]) {
    animation: dock-menu 260ms var(--ease-out-expo);
  }
}

@keyframes dock-menu {
  from {
    opacity: 0;
    translate: 0 8px;
    scale: 0.96;
  }
}

.dock__list {
  display: grid;
  gap: 2px;
}

.dock__item {
  display: block;
  padding: 0.42rem 0.8rem;
  border-radius: var(--r-pill);
  font-size: var(--step--1);
  font-weight: 500;
  color: var(--ink-2);
  white-space: nowrap;
  transition: color var(--dur-1) var(--ease-out-quart),
    background-color var(--dur-1) var(--ease-out-quart);
}

.dock__item:hover {
  color: var(--ink);
  background: var(--rule);
}

/* The one you are already in — choosing it just closes the menu. */
.dock__item[aria-current='true'] {
  color: var(--ink);
  background: var(--chip);
  box-shadow: var(--shadow-1), var(--ring);
}

/* --- Footer -------------------------------------------------------------- */

.footer {
  border-top: 1px solid var(--rule);
  padding-block: var(--space-lg);
}

/* Only when the dock can actually appear, so a no-JS page keeps its spacing. */
.js .footer {
  padding-bottom: calc(var(--space-lg) + 3.25rem);
}

.footer__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-sm) var(--space-lg);
}

.footer__mark {
  font-size: var(--step--1);
  color: var(--ink-2);
}

.footer__links {
  display: flex;
  gap: var(--space-md);
  margin-inline-start: auto;
}

.footer__links a {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  font-size: var(--step--1);
  color: var(--ink-2);
  transition: color var(--dur-1) var(--ease-out-quart);
}

/* The scramble writes textContent, so it needs an element of its own here or it
   would eat the icon beside it. Inline-block because app.js locks the width for
   the duration and min-width does nothing to an inline box. */
.footer__link-label {
  display: inline-block;
}

.footer__links a:hover {
  color: var(--accent);
}

.footer__meta {
  flex: none;
}

/* Below this the three groups cannot share a line. Left to itself, flex drops
   them onto three rows each aligned differently — the mark flush left, the
   links indented by their auto margin, the date flush right. Stacked and
   centred reads as a deliberate footer instead. */
@media (width < 40rem) {
  .footer__inner {
    flex-direction: column;
    align-items: center;
    text-align: center;
    gap: var(--space-sm);
  }

  .footer__links {
    margin-inline-start: 0;
    flex-wrap: wrap;
    justify-content: center;
  }
}

/* --- Case study ---------------------------------------------------------- */

.case__head {
  padding-block: clamp(2.5rem, 6vw, 5rem) var(--space-xl);
}

/* The project name sits here rather than in the headline: the headline is what
   the project *was*, which is the more useful thing to read first. The name
   still carries full ink so it is not lost. */
.case__meta {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  margin-bottom: var(--space-sm);
  color: var(--ink-2);
}

.case__name {
  font-size: var(--step--1);
  font-weight: 600;
  font-variation-settings: 'wdth' 104;
  letter-spacing: var(--track-label);
  color: var(--ink);
}

.case__title {
  font-size: clamp(1.6rem, 4.2vw, var(--step-3));
  margin-bottom: var(--space-md);
}

.case__lede {
  font-size: var(--step-0);
  line-height: 1.5;
  color: var(--ink-2);
}

.facts {
  display: grid;
  /* 9rem, not 11: at the narrowed shell the wider track fits only three and
     leaves the fourth stranded on a line of its own. */
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--space-md) var(--space-lg);
  margin-block: var(--space-xl) 0;
  padding-top: var(--space-md);
  border-top: 1px solid var(--rule);
}

.facts__row {
  display: grid;
  gap: 0.2rem;
  align-content: start;
}

.facts__label {
  margin: 0;
}

.facts__value {
  margin: 0;
  font-size: var(--step--1);
  line-height: 1.45;
}

/* Same width as the figures below it, so the case study opens at the size it
   goes on at rather than stepping down halfway through. */
.case__cover {
  padding-inline: var(--gutter);
  max-width: var(--maxw);
  margin-inline: auto;
}

/* The cover keeps its own, larger radius and its deeper shadow — it is the
   opening image, not one of the chapter figures. The frame carries both now,
   so the clip and the expand control share the same curve. */
.figure__frame--cover {
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-2);
}

.case__cover img {
  width: 100%;
  /* Same ratio as the work plate, so the view transition between them is a
     move rather than a reshape. */
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

.case__body {
  padding-block: var(--section);
  display: grid;
  gap: var(--section);
}

/* A chapter with no prose is a picture continuing the one before it, so it sits
   at figure spacing rather than chapter spacing. They were all separated by the
   full `--section` gap — 102px against the 39px between two figures inside one
   chapter — so consecutive images fell 102 or 39 apart depending only on which
   record they happened to live in, which is nothing the reader can see. */
.chapter:not(.impact):not(:has(.chapter__text)) {
  margin-top: calc(var(--space-lg) - var(--section));
}

/* Label gutter beside the prose. The chapter names itself — Problem, Approach,
   Craft, Outcome — so the argument's shape is scannable without reading it. */
.chapter {
  display: grid;
  grid-template-columns: 7rem minmax(0, 1fr);
  column-gap: var(--space-lg);
  row-gap: var(--space-lg);
  align-items: start;
}

.chapter__label {
  grid-column: 1;
  grid-row: 1;
  /* Optically aligned to the heading's first line rather than its box. */
  margin-top: 0.3em;
}

.chapter__text {
  grid-column: 2;
  grid-row: 1;
  display: grid;
  gap: var(--space-sm);
  max-width: var(--measure);
  color: var(--ink-2);
}

/* Figures take the shell, and stop there.

   They spanned the text column first, which was too narrow, and then broke
   past the shell to 74rem, which was too wide — not because of the size but
   because of the edges. A page with the label at one margin, the prose
   indented to a second, and a bright screenshot hanging off a third has three
   left edges and reads as ragged, and the brighter the image the louder that
   gets. Spanning both tracks puts the figure's edge on the label's edge, which
   is an alignment the page already has, and leaves the prose as the only thing
   that is indented. */
/* Label and prose are pinned to row 1, so figures auto-place from row 2 down.
   Pinning them to row 2 as well stacked a second figure on top of the first
   instead of below it — a chapter may carry more than one picture. */
.chapter .figure {
  grid-column: 1 / -1;
}

/* No room for a gutter; the label leads the chapter instead. */
@media (width < 46rem) {
  .chapter {
    grid-template-columns: minmax(0, 1fr);
    row-gap: var(--space-md);
  }

  .chapter__label,
  .chapter__text,
  .chapter .figure {
    grid-column: 1;
    grid-row: auto;
  }

  .impact__metrics {
    grid-row: auto;
  }

  .chapter__label {
    margin-top: 0;
  }
}

.chapter__title {
  font-size: var(--step-1);
  color: var(--ink);
  margin-bottom: var(--space-3xs);
}

.figure {
  margin: 0;
  display: grid;
  gap: var(--space-xs);
}

/* The radius and the edge move to the frame, so the expand control can sit
   inside the image's corner and be clipped by the same curve. */
.figure__frame {
  position: relative;
  display: block;
  border-radius: var(--r-md);
  box-shadow: var(--shadow-1), var(--ring);
  overflow: hidden;
}

.figure img {
  display: block;
  width: 100%;
}

/* Quiet, but always there. A control that only appears on hover does not exist
   on a touch screen, and this one has to sit over screenshots that are mostly
   near-white, so it takes the raised-chip treatment the dock uses rather than
   a tint that would vanish on a pale plate. */
/* Gated on `js`, like every other control that cannot work without it. The
   image is already on the page at full width with scripting off; what would be
   missing is the lightbox, and a button that does nothing is worse than no
   button. `cursor: zoom-in` is gated for the same reason. */
.figure__expand {
  display: none;
}

.js .figure__frame {
  cursor: zoom-in;
}

/* Bottom right, which is where the image ends rather than where it begins —
   it sits after the picture in reading order and it is under the thumb on a
   phone. Square, because the glyph is square; the pill radius belongs to
   things with words in them.

   Quiet but always there. A control that only appears on hover does not exist
   on a touch screen, and this one sits over screenshots that are mostly
   near-white, so it takes the raised-chip treatment the dock uses rather than
   a tint that would disappear on a pale plate. */
.js .figure__expand {
  display: grid;
  place-items: center;
  position: absolute;
  bottom: var(--space-2xs);
  right: var(--space-2xs);
  padding: var(--space-2xs);
  border-radius: var(--r-md);
  color: var(--ink);
  background: var(--glass);
  box-shadow: var(--shadow-1), var(--glass-ring);
  backdrop-filter: blur(14px) saturate(180%);
  -webkit-backdrop-filter: blur(14px) saturate(180%);
  opacity: 0.9;
  transition: opacity var(--dur-1) var(--ease-out-quart),
    box-shadow var(--dur-2) var(--ease-out-quart),
    scale var(--dur-1) var(--ease-out-quart);
}

/* Expanding and collapsing are the same control in two states and they sit in
   the same corner, so they take the same fill. Both go solid without
   backdrop-filter: a translucent plate over an unblurred screenshot is worse
   than the opaque one it replaced. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .js .figure__expand,
  .lb__collapse {
    background: var(--chip);
    box-shadow: var(--shadow-1), var(--ring);
  }
}

.figure__expand .icon {
  width: var(--icon-md);
  height: var(--icon-md);
}

.js .figure__frame:hover .figure__expand,
.js .figure__expand:focus-visible {
  opacity: 1;
  box-shadow: var(--shadow-2), var(--ring);
}

.figure__expand:active {
  scale: 0.96;
}

.figure__caption {
  font-size: var(--step--1);
  line-height: 1.5;
  color: var(--ink-2);
  max-width: 58ch;
}

/* --- Pager --------------------------------------------------------------- */

.pager {
  display: flex;
  justify-content: space-between;
  gap: var(--space-md);
  padding-block: var(--space-xl);
  border-top: 1px solid var(--rule);
}

.pager__link {
  display: grid;
  gap: 0.15rem;
}

.pager__link--next {
  text-align: right;
  margin-inline-start: auto;
}

.pager__title {
  font-size: var(--step-1);
  font-weight: var(--wght-display);
  font-variation-settings: 'wdth' 108;
  letter-spacing: -0.018em;
  transition: color var(--dur-1) var(--ease-out-quart);
}

.pager__link:hover .pager__title {
  color: var(--accent);
}

/* --- Lightbox ------------------------------------------------------------ */

/* A native <dialog> rather than a hand-rolled overlay: focus trapping, Esc,
   inert background, and focus restoration on close all come for free, and the
   top layer means no stacking-context or overflow surprises. */
.lb {
  /* Sized against the *dynamic* viewport and centred by the dialog itself.
     `height: 100%` on a modal dialog resolves against a containing block iOS
     Safari reports inconsistently while its toolbars move, which left the
     frame collapsing to almost nothing — the backdrop dimmed and the picture
     never appeared. `dvh` and explicit centring take the guesswork out. */
  width: 100vw;
  max-width: 100vw;
  height: 100dvh;
  max-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  padding: clamp(1rem, 4vw, 3rem);
  border: 0;
  background: transparent;
  color: oklch(0.97 0 0);
  overflow: hidden;
}

.lb:not([open]) {
  display: none;
}

.lb[open] {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  justify-items: center;
  align-items: center;
  gap: var(--space-sm);
}

.lb::backdrop {
  background: oklch(0.14 0.004 265 / 0.88);
}

/* Shrink-wraps the image so the collapse control can sit in the picture's own
   bottom-right corner rather than the viewport's. Centred by the dialog's
   `justify-items`, which makes a grid item fit its content. */
.lb__frame {
  position: relative;
  display: grid;
  max-width: min(1600px, 100%);
  max-height: 100%;
  min-height: 0;
  /* Without this the frame can resolve to zero height inside a flex parent on
     Safari, taking the picture with it. */
  min-width: 0;
  margin: auto;
}

/* The mirror of the control on the page: same corner, same chip, same size,
   the glyph turned inward. Expanding and collapsing should feel like one
   control in two states rather than two unrelated buttons. */
.lb__collapse {
  position: absolute;
  bottom: var(--space-2xs);
  right: var(--space-2xs);
  display: grid;
  place-items: center;
  padding: var(--space-2xs);
  border-radius: var(--r-md);
  color: var(--ink);
  background: var(--glass);
  box-shadow: var(--shadow-1), var(--glass-ring);
  backdrop-filter: blur(14px) saturate(180%);
  -webkit-backdrop-filter: blur(14px) saturate(180%);
  opacity: 0.9;
  transition: opacity var(--dur-1) var(--ease-out-quart),
    box-shadow var(--dur-2) var(--ease-out-quart),
    scale var(--dur-1) var(--ease-out-quart);
}

.lb__collapse .icon {
  width: var(--icon-md);
  height: var(--icon-md);
}

.lb__collapse:hover,
.lb__collapse:focus-visible {
  opacity: 1;
  box-shadow: var(--shadow-2), var(--ring);
}

.lb__collapse:active {
  scale: 0.96;
}

/* The enlarged video sits in the same box the enlarged image does, so the
   collapse control lands on its corner either way. It keeps its own controls,
   which is the one place on the site where a video is something to operate. */
.lb__video {
  display: block;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  border-radius: var(--r-md);
  box-shadow: 0 2px 6px oklch(0 0 0 / 0.4), 0 16px 30px -16px oklch(0 0 0 / 0.6);
}

.lb__video[hidden],
.lb__img[hidden] {
  display: none;
}

/* The collapse control stays in the corner it expanded from — that pairing is
   the whole reason it reads as one control in two states — but a video puts a
   native control bar in exactly that corner. Lifted above the bar rather than
   moved to another corner, so the relationship survives and the scrub track
   stays reachable. */
.lb__frame.is-video .lb__collapse {
  bottom: calc(var(--space-2xs) + 3.25rem);
}

.lb__img {
  /* The cap moved to .lb__frame, which has to be the same size as the image
     for the collapse control to land on its corner. */
  display: block;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  object-fit: contain;
  border-radius: var(--r-md);
  box-shadow: 0 2px 6px oklch(0 0 0 / 0.4), 0 16px 30px -16px oklch(0 0 0 / 0.6);
}

.lb__bar {
  display: flex;
  align-items: center;
  gap: var(--space-md);
  display: flex;
  align-items: center;
  gap: var(--space-md);
  color: oklch(0.97 0 0);
}

/* Paging, for a piece that has more than one view. It sits in the bar beside
   the title rather than over the picture, because arrows floating on artwork
   are the thing the pinned collapse control already occupies that corner to
   avoid. Hidden entirely when there is only one view. */
.lb__paging {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2xs);
  margin-inline-start: auto;
  color: var(--ink-2);
}

.lb__paging[hidden] {
  display: none;
}

.lb__step {
  display: grid;
  place-items: center;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: var(--r-pill);
  background: var(--chip);
  box-shadow: var(--ring);
  color: var(--ink);
  font-size: var(--step-0);
  line-height: 1;
  transition: color var(--dur-1) var(--ease-out-quart),
    box-shadow var(--dur-1) var(--ease-out-quart);
}

/* `--ink`, not `--accent`. These sit on a `--chip` plate, which is the
   lightest surface the accent ever lands on, and there it measures 4.46:1 —
   just under the 4.5 floor. The sibling close control already uses ink, so
   this is also the more consistent of the two. */
.lb__step:hover,
.lb__step:focus-visible {
  color: var(--ink);
  box-shadow: var(--ring), var(--shadow-1);
}

.lb__count {
  min-width: 3.25ch;
  text-align: center;
  font-size: var(--step--1);
}

.lb__title {
  font-size: var(--step--1);
  font-weight: var(--wght-label);
  font-variation-settings: 'wdth' var(--wdth-label);
  letter-spacing: var(--track-label);
}

@media (prefers-reduced-motion: no-preference) {
  .lb[open]::backdrop {
    animation: lb-in var(--dur-2) var(--ease-out-quart);
  }

  .lb__img {
    animation: lb-img var(--dur-3) var(--ease-out-expo);
  }
}

@keyframes lb-in {
  from { opacity: 0; }
}

@keyframes lb-img {
  from { opacity: 0; transform: scale(0.97); }
}

/* --- Entrance --------------------------------------------------------------
   The first fold arrives in one short sequence rather than each piece fading in
   on its own: status, then headline, then the paragraphs, each a beat behind
   the last. The mark is left out — it draws itself in from app.js, which is a
   better entrance than a fade and is timed to land with the copy.

   `backwards` rather than `both`, deliberately: the from-state applies during
   the delay, but once the animation is over the element returns to its own
   styles. Nothing can be left stranded invisible if an animation never runs. */

@media (prefers-reduced-motion: no-preference) {
  .js .intro__lead {
    animation: enter 860ms var(--ease-out-expo) 70ms backwards;
  }

  .js .intro__workplace {
    animation: enter 640ms var(--ease-out-expo) 260ms backwards;
  }

  .js .intro__body {
    animation: enter 860ms var(--ease-out-expo) 190ms backwards;
  }

  /* The case study opens on the same cadence. */
  .js .case__meta {
    animation: enter 620ms var(--ease-out-expo) backwards;
  }

  .js .case__title {
    animation: enter 860ms var(--ease-out-expo) 70ms backwards;
  }

  .js .case__lede {
    animation: enter 860ms var(--ease-out-expo) 170ms backwards;
  }

  .js .facts {
    animation: enter 860ms var(--ease-out-expo) 260ms backwards;
  }
}

@keyframes enter {
  from {
    opacity: 0;
    transform: translateY(14px);
  }
}

/* --- Page transitions ------------------------------------------------------
   Cross-document, so following a project and coming back is one continuous
   move rather than a white flash. Each cover carries a name matching the one
   on its case study, so the plate you clicked is the thing that expands into
   the hero image instead of a new page fading in over it.

   Unsupported browsers simply navigate. */

@view-transition {
  navigation: auto;
}

::view-transition-old(root) {
  animation: vt-out 220ms var(--ease-out-quart) both;
}

::view-transition-new(root) {
  animation: vt-in 380ms var(--ease-out-expo) both;
}

@keyframes vt-out {
  to { opacity: 0; }
}

@keyframes vt-in {
  from { opacity: 0; transform: translateY(8px); }
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

/* --- Reveal --------------------------------------------------------------
   The hidden state is applied only once JavaScript has confirmed it is running
   and that motion is welcome. Without JS — or in a headless renderer — every
   section is visible by default and nothing is gated behind a transition. */

@media (prefers-reduced-motion: no-preference) {
  .js [data-reveal] {
    opacity: 0;
    transform: translateY(14px);
  }

  .js [data-reveal].is-in {
    opacity: 1;
    transform: none;
    transition: opacity var(--dur-3) var(--ease-out-quart),
      transform var(--dur-3) var(--ease-out-expo);
    transition-delay: var(--reveal-delay, 0ms);
  }
}
