/* ════════════════════════════════════════════════════════════════
   Isabelle · Portfolio 2026
   Spraypaint / handcrafted register.
   Type: Archivo Narrow (real web font, same on every device) everywhere;
   Averia / Manrope / Azeret Mono appear only inside case content.
   Card shapes (bg + border) are spray-distressed together
   so the fill never bleeds through the edge.
   ════════════════════════════════════════════════════════════════ */

/* ── Tokens ─────────────────────────────────────────────────── */
:root {
  /* Surface — pure white */
  --surface-base:    #ffffff;
  --surface-deep:    #f5f5f5;
  --surface-tint:    #ebebeb;

  /* Glass tokens — unchanged from original */
  --glass-fill:      color-mix(in oklab, white 32%, transparent);
  --glass-fill-hover: color-mix(in oklab, white 32%, transparent);
  --glass-stroke:    color-mix(in oklab, white 80%, transparent);
  --glass-stroke-2:  color-mix(in oklab, var(--ink-1) 14%, transparent);
  --glass-highlight: color-mix(in oklab, white 90%, transparent);
  --glass-shadow:    0 16px 36px -20px color-mix(in oklab, var(--ink-1) 22%, transparent),
                     0 4px 10px  -6px  color-mix(in oklab, var(--ink-1) 10%, transparent);

  /* Card frame inside glass — kept opaque for image readability */
  --surface-frame:   oklch(91% 0 0);
  /* Backwards-compat alias used by selection, cursor inverse, etc. */
  --surface-card:    oklch(97% 0 0);

  /* Screenshot paper + ink: the white and the slate text of the product
     screenshots in case studies. Fixed across themes (a screenshot is
     white at night too), used only where live values are patched onto
     a screenshot so the patch matches the pixels around it. */
  --shot-paper:      #ffffff;
  --shot-ink:        #2a333d;
  --shot-ink-strong: #000000;
  --shot-fill:       #f6f6f6;   /* placeholder tiles */
  --shot-line:       #d9d9d9;   /* radios, card hairlines */
  --shot-ring:       #cfd2f7;   /* an enabled stepper button's ring */
  --shot-glyph:      #1a1d21;   /* its + / − glyph */
  /* The plain grey ground a piece is filmed on when it has no painted
     ground of its own (Neverland, in the Playground popup). Grey, not the
     sheet's white, so a white card reads as a card. The media
     (images/projects/playground/) bake it in: scripts/playground-media.mjs. */
  --shot-stage:      #ededed;

  /* Paper objects (the borrow card, borrow.html): a material exception
     documented in DESIGN.md. The object's own stock and inks, scoped to
     .bc only; never chrome, never type outside the object. */
  --paper-card:       #fcfaf5;   /* index card stock, a warm white */
  --paper-pocket:     #eee5d3;   /* the pocket, off-white beige */
  --paper-pocket-in:  #dcd0b9;   /* the pocket's inside, in shade */
  --paper-ink:        #1c1b19;   /* print, typewriter, stamp pad */
  --paper-graphite:   #5f5d58;   /* pencil */
  --paper-rule:       #8fc1e3;   /* the card's printed rules: library-card sky blue, not the accent */
  --paper-blue:       #0033ff;   /* the accent, pinned: pen ink on paper */
  --paper-light:      #ffffff;   /* light falling on the object */
  --paper-shadow:     #000000;   /* the object's shadows */

  /* Plastic objects (the bread clip, bread-clip.html): the same material
     exception as paper, for a moulded off-white clip. Scoped to .clip
     only. It sits in the same object library as the borrow card, so it
     shares that card's light, shadow and print ink; only the stock is
     its own, and every theme below follows the paper for free. */
  --plastic-face:     #f4f0e6;   /* the flat of the clip, off-white, a shade under the index card */
  --plastic-edge:     #dcd4c3;   /* its moulded edge, catching less light */
  --plastic-ink:      var(--paper-ink);
  --plastic-light:    var(--paper-light);
  --plastic-shadow:   var(--paper-shadow);
  /* The bread clip's own stock (Oct 2026, from a photo of a real one): a
     clear sky blue with its lettering printed a dark blue. Azure-leaning,
     red under green, so it never reads violet; only .clip wears it (the
     cooler and the playground keep the cream plastic above). */
  --clip-face:        #86b3d3;
  --clip-edge:        #6894b6;
  --clip-ink:         #1b4560;

  /* Cooler objects (the water cooler, cooler.html, Oct 2026): the same
     material exception again. The cabinet is the bread clip's moulded
     plastic and the cup is the index card's stock, so most of it aliases
     those (in .cooler); only the bottle, its water and the cold tap's
     cap are their own: muted, greyed azures (r < g), never the accent,
     with dark and e-ink values. Scoped to .cooler only. */
  --cooler-water:     #dae3e6;
  --cooler-jug:       #a3bec5;   /* the bottle's tinted plastic: a muted, greyed cyan, azure (r < g), never the accent */
  --cooler-cold:      #5a7c9b;   /* the cold tap's cap: a dusty azure, quieter than the accent */

  /* Label objects (the hero's peel-off sticker, Oct 2026): the same
     material exception again, for label stock. Scoped to .sticker only.
     Printed on the index card's stock, so it shares the card's face,
     ink, light and shadow; only the glued back is its own, a shade
     duller. Every theme follows the paper for free. */
  --sticker-face:     var(--paper-card);
  --sticker-back:     #f3f1ec;   /* the glued side, matte and a shade greyer */
  --sticker-ink:      var(--paper-ink);
  --sticker-light:    var(--paper-light);
  --sticker-shadow:   var(--paper-shadow);

  /* Cloth objects (the curtains, curtain.html, Oct 2026): the same
     material exception again, for ivory cotton voile and lace. Scoped to
     .curtain only; js/curtain.js reads them into its shader. After two
     references: a shop window's tied-back curtains and a lace balloon
     valance over a printed panel. The voile is a warm white, the lace a
     shade creamier, the folds a soft warm grey, and the bouquet print a
     muted grey a few steps under the cloth (the reference's pink would
     be a second accent). Ivory, not the paper pocket's beige. */
  --curtain-cloth:    #fdfbf6;
  --curtain-fold:     #ddd7cc;
  --curtain-lace:     #faf6ec;
  --curtain-print:    #cbc5ba;
  --curtain-shadow:   var(--paper-shadow);

  /* The street (the footer, js/street.js): the scene's own five tones,
     so it themes by token like everything else instead of a filter.
     Paper is the footer's ground; the rest are its inks. Scoped to
     .street-footer; never chrome, never type (DESIGN.md "The street"). */
  --street-paper:    var(--surface-deep);
  --street-shade:    #e4e4e4;   /* glass by day, the kerb face, the porch */
  --street-light:    #ffffff;   /* clouds, glints, the awning's light stripes */
  --street-soft:     #b4b4b4;   /* the far hills, textures, quiet detail */
  --street-ink:      #262626;   /* every line */
  --street-glow:     #ffffff;   /* light after dark: lamps, windows, the moon */

  /* Clouds (js/clouds.js, Oct 2026): the page sky's soft cumulus and
     the footer's. Lit tops and shaded bellies; the shade is a pale
     blue-grey (r < g), so on white paper only the bellies show. */
  --cloud-lit:   #ffffff;
  --cloud-shade: #c7d5e8;

  /* Ink — pure black hierarchy */
  --ink-1:           #000000;
  --ink-2:           #111111;
  --ink-3:           #555555;
  --ink-4:           #666666;   /* AA 4.5:1 on white, --surface-deep and --surface-tint */
  --ink-inv:         #ffffff;
  /* Text selection — a grey ink wash, not inverse video, with the type
     reprinted in --accent-blue; the dark block lifts it (25%) so it
     still reads on black. */
  --selection-wash:  color-mix(in oklab, var(--ink-1) 15%, transparent);

  /* Accent — one retro electric blue, azure-leaning so it never drifts
     purple. Rule of thumb for any blue here: red channel <= green channel,
     or it starts reading violet. #0000ff (pure primary) and periwinkles
     like #4444ff read purple, so they are banned. */
  --accent-blue:     #0033ff;   /* electric retro blue, reads unmistakably blue */
  --accent-blue-soft: #3366ff;  /* lighter azure for hover / secondary */

  /* Highlighter inks — the reader highlighter's user-pickable palette
     (js/highlight.js). Part of the SAME sanctioned exception as the
     azure wash (DESIGN.md): translucent marker washes and the tool's
     own ink depictions only, never UI chrome, never surface fills.
     Azure (the accent itself) stays the default; the dark block lifts
     each of these the same way it lifts --accent-blue. */
  --hl-sun:  #ffd400;   /* marker yellow */
  --hl-rose: #ff4d94;   /* marker pink */
  --hl-mint: #16b364;   /* marker green */

  /* Spacing — the gap scale. New components pick from these; existing
     spacing migrates opportunistically (see DESIGN.md audit). */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;
  --ink-stamp:     #000000;

  /* Type — split on purpose (both were identical until now; DESIGN.md
     flagged the redundancy as a cleanup). --font-primary: Archivo Narrow,
     the narrow grotesque for labels, headings, eyebrows — condensed
     letterforms read fine at a glance. --font-body: IBM Plex Sans, a
     normal-width grotesque for actual reading passages — a condensed face
     fights long-form legibility. Both load as real web fonts (see the
     Google Fonts link in <head>), so every visitor sees the same pair
     regardless of device. */
  /* Arial Narrow first (ships with macOS, iOS, Windows). Android and Linux
     don't have it and it can't be redistributed, so they fall back to the
     Archivo Narrow web font, the nearest narrow grotesque. */
  --font-primary: "Arial Narrow", "Archivo Narrow", Arial, sans-serif;
  --font-body:    "IBM Plex Sans", Arial, sans-serif;
  /* The machine voice, and the one exception to the two-typeface rule.
     Azeret Mono was already loaded and already carrying it in twenty
     places by hand (the cat's speech, hex swatch codes, the chat input
     and its placeholder, figure captions): things a MACHINE printed
     rather than a person wrote. This only names what was there, so the
     bio receipt composes a token instead of pasting the stack again. It
     is not a third display face; nothing read as prose or as a label
     should reach for it. */
  --font-mono:    "Azeret Mono", "JetBrains Mono", monospace;

  /* Type scale */
  --t-manifesto:  clamp(17px, 1.6vw, 22px);
  --t-micro:      10px;   /* floor: tooltips and tiny system chips only */
  --t-eyebrow:    11px;   /* uppercase tracked labels */
  --t-meta:       11px;
  --t-small:      12px;   /* card descriptions, captions, secondary lines */
  --t-body:       14px;   /* reading text */
  --t-home:       13px;   /* the homepage's reading lines: the top line, bio, work, info values, card descriptions (Oct 2026) */
  --t-card-title: 16px;
  --t-display-sm: clamp(120px, 22vh, 260px);
  --t-hero:       clamp(25px, 4vw, 70px);    /* the homepage's one display sentence; bigger, Oct 2026 (feedback) */
  --t-hero-obj:   clamp(24px, 3.8vw, 67px);    /* the em the hero's objects are sized in, held when the type grows */

  /* Motion */
  --ease-out-expo:  cubic-bezier(0.16, 1, 0.3, 1);
  --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
  --ease-out-soft:  cubic-bezier(0.22, 1, 0.36, 1);
  --ease-drawer:    cubic-bezier(0.32, 0.72, 0, 1); /* drawers and panels only */

  /* Radius — page CONTENT stays 0 (the zero-radius law, DESIGN.md).
     CONTROLS are the exception: small pressable chrome carries
     --radius-control so a button reads as a button wherever it sits.
     --radius-dock is the dock bar's outer corner, concentric with it
     on purpose (outer = inner + the bar's 3px padding), so nested
     corners share a center instead of pinching. Content — cards,
     images, panels, inputs — still gets 0. */
  --radius-dock:    6px;
  --radius-control: 3px;
  --radius-soft:    6px;   /* component library cards and plates only; see DESIGN.md */
  --radius-content: 4px;   /* content surfaces: image frames, plates, cards, project thumbnails, both case systems; see DESIGN.md */
  --radius-thumb:   2px;   /* tiny thumbnails only: the contact rows' captures; see DESIGN.md */
  --brand-circles:  #413CAD;  /* Circles' own brand violet: its case chart only, see DESIGN.md */

  /* Spacing */
  --pad-page-y: 64px;
  --pad-page-x: 120px;
  --side-nav-w: 0px;

}

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

html {
  background: var(--surface-base);
  color: var(--ink-1);
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: clip;
  overscroll-behavior: none;
}

body {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  position: relative;
  overflow-x: clip;
  background: var(--surface-base);
}

/* Ambient depth — the paper greys toward --surface-deep as the street
   footer nears. A gradient painted ON the page (body is as tall as the
   document), not a colour animated per scroll frame: it scrolls with
   the content on the compositor, so a fast flick can't outrun it and
   nothing repaints the page each frame. It spans the last 900px plus
   one viewport above the footer, eased along a smoothstep curve, and
   lands exactly on --surface-deep where the footer's own fill begins.
   --footer-h is measured by js/footer.js on load/resize only. Tokens,
   so dark and e-ink follow with no per-theme numbers. */
body:has(#street-footer) {
  --depth-end: calc(100% - var(--footer-h, 0px));
  --depth-span: calc(900px + 100vh);
  background: linear-gradient(to bottom,
    var(--surface-base) calc(var(--depth-end) - var(--depth-span)),
    color-mix(in srgb, var(--surface-deep) 16%, var(--surface-base)) calc(var(--depth-end) - var(--depth-span) * 0.75),
    color-mix(in srgb, var(--surface-deep) 50%, var(--surface-base)) calc(var(--depth-end) - var(--depth-span) * 0.5),
    color-mix(in srgb, var(--surface-deep) 84%, var(--surface-base)) calc(var(--depth-end) - var(--depth-span) * 0.25),
    var(--surface-deep) var(--depth-end));
}

a { color: inherit; text-decoration: none; }
ul, ol { list-style: none; }

/* Grey wash, blue ink: selected text reprints in the accent. */
::selection {
  background: var(--selection-wash);
  color: var(--accent-blue);
}

:focus-visible {
  outline: 2px solid var(--ink-stamp);
  outline-offset: 3px;
  border-radius: 0;
}

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

/* ── Page-load keyframes ────────────────────────────────────────── */
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes enter-up {
  from { opacity: 0; translate: 0 18px; }
  to   { opacity: 1; translate: 0 0; }
}

/* Hero manifesto — ink-morph entrance. The gooey reveal in
   compositor-friendly CSS: blur melts the glyphs and contrast()
   re-thresholds the greys — the same metaball math as an SVG alpha
   threshold, but GPU-composited (the SVG version re-rasterized its
   filter chain every frame and read as lag). contrast() needs solid
   paper behind the glyphs to work on, hence the background-color
   keyframes (content-box clipped on .rail__currently); it hands back
   to transparent as the ink dries. Lives on the h1, not the text
   span — the span's url(#distress-strong) makes its own filter list
   non-interpolable. */
@keyframes hero-ink-in {
  /* Pure materialize, using STILL's goo recipe (blur spreads each glyph's
     alpha, the constant high contrast re-thresholds it into solid shapes —
     the CSS twin of STILL's feGaussianBlur -> feColorMatrix(18 -9)). The
     text fades in already melted into gooey ink blobs, then the blur ONLY
     decreases, crisping into type. Monotonic, so it reads as one appear
     with no melt-away-and-return. Plays on LINEAR timing. */
  0% {
    opacity: 0;
    filter: blur(3.8px) contrast(6);
    background-color: var(--surface-base);
  }
  /* the melted blobs fade in */
  14% {
    opacity: 1;
    filter: blur(3.6px) contrast(6);
    background-color: var(--surface-base);
  }
  /* crisp out of the goo: blur falls monotonically, threshold relaxes */
  45% {
    filter: blur(2.1px) contrast(4.3);
    background-color: var(--surface-base);
  }
  72% {
    filter: blur(0.9px) contrast(2.3);
    background-color: var(--surface-base);
  }
  90% {
    filter: blur(0.35px) contrast(1.5);
    background-color: var(--surface-base);
  }
  100% {
    opacity: 1;
    filter: blur(0) contrast(1);
    background-color: transparent;
  }
}

/* Staggered entry — rail elements sweep up one by one, sound button
   drops in from the top. Cards animate via IntersectionObserver.
   Never animate transform on <body>: it creates a fixed-position
   containing block and breaks the sticky sidebar + sound button. */
@media (prefers-reduced-motion: no-preference) {
  .site-footer { animation: fade-in 520ms var(--ease-out-expo) 260ms both; }

  /* Cards join the rail's entrance: the same enter-up rise as the Overview
     and Info sections, staggered in the curated card order so the whole
     page assembles on one rhythm. enter-up animates `translate` + opacity,
     so it never collides with the cards' transform press feedback or the
     sprayed shapes. */
  .card[data-card="1"] { animation: enter-up 580ms var(--ease-out-expo) 180ms both; } /* Circles   */
  .card[data-card="2"] { animation: enter-up 580ms var(--ease-out-expo) 240ms both; } /* Oustre    */
  .card[data-card="6"] { animation: enter-up 580ms var(--ease-out-expo) 300ms both; } /* Still     */
  .card[data-card="7"] { animation: enter-up 580ms var(--ease-out-expo) 360ms both; } /* Experiments */
  .card[data-card="3"] { animation: enter-up 580ms var(--ease-out-expo) 420ms both; } /* Ok-bye    */
  .card[data-card="4"] { animation: enter-up 580ms var(--ease-out-expo) 480ms both; } /* TarotNook */

  .rail__currently          { animation: hero-ink-in 1400ms linear both; }
  .rail__section:nth-of-type(1) { animation: enter-up 580ms var(--ease-out-expo) 240ms both; }
  .rail__section:nth-of-type(2) { animation: enter-up 580ms var(--ease-out-expo) 300ms both; }
  .rail__section:nth-of-type(3) { animation: enter-up 580ms var(--ease-out-expo) 360ms both; }
  .rail__section:nth-of-type(4) { animation: enter-up 580ms var(--ease-out-expo) 420ms both; }
  /* The cat rests on the Overview card, so it rises and fades in on the same
     timing as that card (rail__section 240ms). enter-up uses the `translate`
     property + opacity; the cat's JS only writes `transform` + `top`, so the
     two never collide. Without this the cat sits fully visible while its card
     is still animating up, and reads as floating on nothing. */
}

/* Touch devices (iOS especially) render the goo reveal's temporary white
   background + heavy contrast filter on a separate GPU layer, which shows
   up as a glowing box around the hero and doesn't flatten into screenshots.
   Swap the whole filter/background morph for a plain opacity fade there —
   no filter, no painted background, so nothing can glow. */
@media (prefers-reduced-motion: no-preference) and (pointer: coarse) {
  .rail__currently { animation: fade-in 700ms var(--ease-out-quart) both; }
}

/* Returning from a case study (html.is-return, flagged pre-paint in
   <head>): no entrance choreography. It would replay INSIDE the page
   fade — everything moving twice reads as a glitch — and the page should
   feel like the same place the visitor left, not a fresh arrival. Cards
   are forced visible too, so the page fades back in already settled. */
html.is-return .site-footer,
html.is-return .projects,
html.is-return .card,
html.is-return .rail__currently,
html.is-return .rail__section { animation: none; }

/* ── Background atmosphere ──────────────────────────────────
   Subtle vignette at the bottom edge only — no colour tint. */
body::before {
  content: "";
  position: fixed;
  inset: -120px;
  z-index: 0;
  pointer-events: none;
  background: none;
}

.svg-defs {
  position: absolute;
  width: 0;
  height: 0;
  pointer-events: none;
}

/* ════════════════════════════════════════════════════════════════
   MAIN LAYOUT
   ──────────────────────────────────────────────────────────────── */
.main {
  position: relative;
  z-index: 1;
  flex: 1;
  margin-left: var(--side-nav-w);
  padding: var(--pad-page-y) var(--pad-page-x);
  display: grid;
  grid-template-columns: 1fr;
  gap: 20px;
}

@media (min-width: 1024px) {
  .main {
    /* Single column — intro/info on top, projects in a full-width section below. */
    grid-template-columns: 1fr;
    gap: clamp(16px, 2vw, 28px);
    align-items: start;
  }
}

/* ── Left rail ─────────────────────────────────────────────── */
.rail {
  display: flex;
  flex-direction: column;
  gap: 28px;
}

/* Two columns: col1 = Exp/Edu/Info, col2 = Overview/Stack */
.rail__grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 28px;
}

.rail__cell--col1,
.rail__cell--col2 {
  display: flex;
  flex-direction: column;
  gap: 28px;
}

@media (min-width: 900px) {
  .rail__grid {
    grid-template-columns: repeat(2, 1fr);
    column-gap: 16px;
    align-items: start;
  }
  .rail__cell--col1 { gap: 0; }
  .rail__cell--col2 { gap: 28px; }
}

.rail__currently {
  display: flex;
  flex-direction: column;
  gap: 0;
  line-height: 1.3;
  max-width: 520px;
  align-self: center;
  text-align: center;
  align-items: center;
  padding-top: 72px;
  padding-bottom: 72px;
  /* the ink-morph entrance paints a temporary paper background; clip
     it to the text zone so it never blanks the clouds in the padding */
  background-clip: content-box;
}
/* Mobile: the fixed sound chip (bottom edge at 68px) floats over the top
   padding, so the visible gap read 28px above vs 118px below. These
   values set both visible gaps to 72px: 24 page pad + 116 - 68 above,
   26 + 28 rail gap + the eyebrow's 18px line box below. */
@media (max-width: 719px) {
  .rail__currently {
    padding-top: 116px;
    padding-bottom: 26px;
  }
}

.rail__currently-eyebrow {   /* eyebrow recipe; size delta re-asserted after the group rule */
}

.rail__currently-text {
  font-family: var(--font-primary);
  font-weight: 500;
  font-size: 16px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-2);
  /* Balances the wrap across both lines instead of leaving a single
     orphan word stranded on the second line (the default greedy wrap
     packed "...HOW HUMANS" onto line one and left "THINK." alone on
     line two). Unsupported browsers just keep the old greedy wrap. */
  text-wrap: balance;
  /* No resting filter: the handwriting/distress texture was removed on
     request. The wet-ink hover system that used to live here (the
     letter-melt-on-hover, js/inkbleed.js) was retired July 2026 for
     feeling too busy — this line is now plain type at rest AND on
     hover. */
}

/* ── Hero typewriter (js/hero-type.js) ──────────────────────────
   "A designer who ___": the phrase is selected, then typed over, the
   way a person rewrites a word in a text field. The ghost and the live
   line share one grid cell; the ghost holds the longest phrase
   invisibly, so the line is laid out once and the prefix never slides
   as the phrase grows. */

/* The selection matches the site's ::selection (the same --selection-wash
   grey ink wash), with a --radius-control corner since it's drawn, not
   native, and a little padding around the type. It sits BEHIND
   the type and sweeps in from the end of the phrase like shift + option
   + left, but never fades out: a key replaces a selection outright.
   line-height 1.2 keeps the block just taller than the caps instead of
   the full 1.5 line box. */

/* The caret: a 2px ink hairline. A macOS-length blink with short fades, solid while keys move, gone
   while a selection is up, as in a real text field. */

/* Each key lands with a short ink-in, so it reads as struck, not popped. */

/* Reduced motion: no typing and no caret. Whole phrases crossfade in
   one grid cell instead, slower than UI feedback on purpose, since
   nothing was clicked. */

/* An e-ink panel repaints in steps and can't show a blur, so keys just land. */

.page-locale {
  position: absolute;
  top: 40px;
  left: 0;
  right: 0;
  height: 28px;   /* matches .sound-btn's height so both sit on the same vertical center */
  margin: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  /* same recipe as .eyebrow ("Info") */
  font-family: var(--font-primary);
  font-size: var(--t-eyebrow);
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-4);
  pointer-events: none;
}
.page-locale__short { display: none; }
@media (max-width: 719px) {
  .page-locale__full { display: none; }
  .page-locale__short { display: inline; }
}
.rail__currently-text kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-primary);
  font-size: 11px;
  /* 700 is the ceiling — Archivo Narrow is loaded at 400-700, so 800 was
     rendering as a synthesized fake bold rather than a real weight. */
  font-weight: 700;
  letter-spacing: 0.08em;
  padding: 2px 6px 1px;
  border-radius: 0;
  background: var(--ink-1);
  color: var(--ink-inv);
  line-height: 1;
  vertical-align: middle;
  position: relative;
  top: -1px;
  /* the parent's resting distress filter was removed; nothing to inherit */
}

.rail__section {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.rail__eyebrow {   /* recipe from the grouped .eyebrow rule */
}

/* ── Education — left-rule typographic entry ── */

/* ── Tag list ──────────────────────────────────────────────── */
.taglist {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.taglist li {
  font-family: var(--font-primary);
  font-weight: 500;          /* the one title style (Oct 2026) */
  font-size: var(--t-eyebrow);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 4px 8px 3px;
  border: 1.2px solid color-mix(in oklab, var(--ink-1) 35%, transparent);
  border-radius: var(--radius-control);
  color: var(--ink-2);
  background: transparent;
  line-height: 1;
  transition:
    color 220ms var(--ease-out-quart),
    border-color 220ms var(--ease-out-quart),
    transform 320ms var(--ease-out-expo);
}

@media (hover: hover) and (pointer: fine) {
  .taglist li:hover {
    color: var(--ink-1);
    border-color: color-mix(in oklab, var(--ink-1) 65%, transparent);
    transform: translateY(-1px);
  }
}

.taglist--quiet li {
  border-color: color-mix(in oklab, var(--ink-3) 20%, transparent);
  color: var(--ink-3);
}

/* ════════════════════════════════════════════════════════════════
   PROJECT GRID
   ──────────────────────────────────────────────────────────────── */
/* Grid — three columns on desktop, two on tablet, one on mobile.
   align-items: start gives each card its natural height so varied
   aspect ratios create the bento-irregular look without phantom row gaps. */
.projects {
  display: grid;
  grid-template-columns: 1fr;
  gap: 8px;
  padding-top: 8px;
  align-items: start;
}

@media (min-width: 720px) {
  .projects { grid-template-columns: repeat(2, 1fr); }
}

@media (min-width: 1024px) {
  .projects { grid-template-columns: repeat(3, 1fr); }
}

/* Each col is a real flex column so cards stack at their natural heights
   (masonry effect). Mobile overrides this back to display:contents. */
.projects-col {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.card {
  margin-bottom: 0; /* gap handles spacing */
}

/* Irregular thumbnail heights — tall cards anchor each outer column. */
.card[data-card="1"] .card__frame { aspect-ratio: 4 / 5;   } /* CIRCLES   — tall  */
.card[data-card="2"] .card__frame { aspect-ratio: 16 / 10; } /* OUSTRE    — landscape */
.card[data-card="3"] .card__frame { aspect-ratio: 16 / 9;  } /* ok-bye    — landscape */
.card[data-card="4"] .card__frame { aspect-ratio: 3 / 2;   } /* tarotnook — the source gif is a 2250x900 (2.5:1) mockup on a blue-grey field; object-fit:cover always crops that field's WIDTH here since every ratio below 2.5:1 does (height never crops), so fatter (squarer) always means less margin. 4:3 cropped almost exactly to the mockup's own edges (no margin) — that is the practical floor. 3:2 sits close to it while still keeping a thin margin. */
.card[data-card="7"] .card__frame { aspect-ratio: 5 / 4;   } /* EXPERIMENTS — the demo reel's own 5:4 (reel.mp4), so each piece shows whole */
.card[data-card="6"] .card__frame { aspect-ratio: 1 / 1.1; } /* STILL     — portrait, slightly shorter */

/* ════════════════════════════════════════════════════════════════
   CARD — tilted, spray-distressed shape, crisp content
   The .card__shape is a single filtered element holding both fill
   and border. Filter applies to the union, so the warm-bone fill
   ends exactly where the espresso outline ends. No bleed through.
   ──────────────────────────────────────────────────────────────── */
.card {
  cursor: pointer;
  position: relative;
  /* the hover/focus wash is a content surface: same corner as the
     thumbnail frame inside it (see "Content corners") */
  border-radius: var(--radius-content);
  display: block;
  /* Padding gives the grey hover wash a margin around the content
     (matches aldonchen's .content-block { padding: 8px }). */
  padding: 8px;
  color: var(--ink-1);
  isolation: isolate;
  /* background-color carries the aldonchen-style grey hover highlight;
     transform carries the press feedback. The scroll-reveal entry (and
     its translate/scale/opacity transitions) was removed July 2026 —
     cards are simply present. */
  transition:
    background-color 180ms var(--ease-out-quart),
    transform 140ms var(--ease-out-quart);
}

/* Press feedback — cards are the primary clickable element. */
.card:active {
  transform: scale(0.99);
}

/* Pointer finger for all clickable / interactive elements */
a,
button,
[role="button"],
label[for],
input[type="checkbox"],
input[type="radio"],
select {
  cursor: pointer;
}

/* Glass card shape.
   - Translucent fill (sits over the body's blue atmosphere + grain)
   - Backdrop-filter blur picks up whatever is behind the card
   - SVG filter (spray-N) gives the edge a subtle handcrafted wobble.
     Scale is tiny (~2px) and turbulence is single-octave low-freq, so
     the outline reads as "real wavy glass" not "drawn by hand."
     Backdrop-blur survives the displacement and reads correctly.
   - Two-layer stroke: light hairline highlight + ink hairline.
   - Soft diffused outer shadow so cards "float" off the cream. */
.card__shape {
  position: absolute;
  inset: 0;
  background: transparent;
  border: none;
  border-radius: 0;
  pointer-events: none;
  z-index: 0;
}

.card:hover,
.card:focus-within {
  background: transparent;
}

.card:hover .card__shape,
.card:focus-within .card__shape {
  background: transparent;
}

.card__inner {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  /* No gap — the image (frame) butts directly against the body padding */
  gap: 0;
}

.card__top { position: relative; }

/* Image frame fills the card from the brown stroke inward. Top corners
   curve to match the card's interior radius (16 - 1.5 = 14.5px). Bottom
   corners are square so the frame meets body content with a clean rule. */
.card__frame {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 10;
  isolation: isolate;
  border-radius: 0;
  overflow: hidden;
  transition: transform 260ms var(--ease-out-soft);
}

/* The locked card: held back (no-op click via the audit-lock in
   js/casestudy.js). On hover a soft glassy scrim fades in first, then a
   LOCK rises into it a beat later as its own floating chip — a glyph
   says "held back" faster than a label spelled it out (the cursor tip
   still reads "Coming soon" for the words). Reuses the card's own
   glass-and-blur vocabulary (see .card__shape above) rather than
   introducing a new material. */
.card__soon {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  background: color-mix(in oklab, var(--surface-base) 58%, transparent);
  backdrop-filter: blur(1.5px);
  -webkit-backdrop-filter: blur(1.5px);
  opacity: 0;
  transition: opacity 240ms var(--ease-out-quart);
  pointer-events: none;
}
.card__soon-label {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--ink-1);
  padding: 8px;
  background: var(--surface-base);
  border: 1px solid color-mix(in oklab, var(--ink-1) 30%, transparent);
  border-radius: var(--radius-control);
  box-shadow: 0 8px 18px -10px rgba(0, 0, 0, 0.4);
  opacity: 0;
  translate: 0 6px;
  scale: 0.97;
  transition:
    opacity 220ms var(--ease-out-quart) 40ms,
    translate 220ms var(--ease-out-soft) 40ms,
    scale 220ms var(--ease-out-soft) 40ms;
}
@media (hover: hover) and (pointer: fine) {
  .card--soon:hover .card__soon { opacity: 1; }
  .card--soon:hover .card__soon-label { opacity: 1; translate: 0 0; scale: 1; }
  /* A pointer here would promise a click that the audit-lock
     intentionally swallows; not-allowed says so honestly. */
  .card--soon:hover { cursor: not-allowed; }
}
.card--soon:focus-within .card__soon { opacity: 1; }
.card--soon:focus-within .card__soon-label { opacity: 1; translate: 0 0; scale: 1; }
/* Same honesty for touch: every other card confirms a tap with a
   0.99 press-scale; this one should not, since nothing follows it. */
.card--soon:active { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .card__soon-label {
    transition: opacity 160ms linear !important;
    translate: 0 0 !important;
    scale: 1 !important;
  }
}

.card__frame::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px solid color-mix(in oklab, var(--ink-1) 28%, transparent);
  border-radius: 0;
  background: transparent;
  z-index: 3;
  pointer-events: none;
}

.card[data-frame="1"] .card__frame::after { filter: url(#spray-frame-1); }
.card[data-frame="2"] .card__frame::after { filter: url(#spray-frame-2); }
.card[data-frame="3"] .card__frame::after { filter: url(#spray-frame-3); }

.card__frame-shape {
  position: absolute;
  inset: 0;
  /* Light, the image skeleton's own tone: on reload the card reads as
     one pale plate the picture fades onto, not dark grey then light. */
  background: var(--surface-frame);
  border-radius: 0;
  z-index: 0;
}

.card__thumb {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: 1;
  display: block;
  transform-origin: center center;
  transition: transform 260ms var(--ease-out-soft), filter 260ms var(--ease-out-quart), opacity 260ms var(--ease-out-quart);
}

.card__num {
  position: absolute;
  top: 12px;
  right: 14px;
  z-index: 2;
  font-family: var(--font-primary);
  font-weight: 600;
  font-size: var(--t-meta);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-2);
  padding: 0;}

.card__body {
  display: flex;
  flex-direction: column;
  gap: 7px;
  /* Body content carries the only padding now — frame butts up to the
     stroke above; body sits inside its own breathing room below. */
  padding: 12px 0px 0px;
}

.card__heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.card__title {
  flex: 1 1 auto;
  min-width: 0;
  font-family: var(--font-primary);
  font-weight: 500;   /* one title weight with the rail and case labels, Oct 2026 */
  font-size: var(--t-card-title);
  line-height: 1.05;
  color: var(--ink-2);
  letter-spacing: 0.04em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  /* Clean at rest, like the hero — the distress texture retired with it,
     and so did the wet-ink hover that used to mount here. */
  transition: color 160ms var(--ease-out-quart);
}

.card__tags {
  display: flex;
  align-items: center;
  gap: 5px;
  flex-shrink: 0;
}

/* Dark "EXPERIENCE" chip */
.chip-tag {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-primary);
  font-weight: 500;          /* the one title style (Oct 2026) */
  font-size: var(--t-eyebrow);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 4px 8px 3px;
  border: 1.2px solid color-mix(in oklab, var(--ink-1) 35%, transparent);
  border-radius: var(--radius-control);
  color: var(--ink-2);
  background: transparent;
  line-height: 1;
  white-space: nowrap;
}

.card__description {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: var(--t-home);   /* with the homepage's other reading lines */
  line-height: 1.55;
  color: var(--ink-3);
  text-wrap: pretty;
  max-width: 60ch;
}
/* Friday's line is two short lines: pretty refused "connection." alone and
   broke "real-world" at its hyphen, leaving a gap. Plain wrapping fills the line. */
.card[data-card="5"] .card__description { text-wrap: wrap; }

/* ── The dotted/dashed rule — restored from original, now with
      a spray feel. Hidden by default, faded in on card hover.    ── */
.card__rule {
  display: none;
}

/* Dashed rule used in msg-card — always visible */
.hand-rule {
  display: none;
}

/* ── Meta details — collapsed by default, expanded on hover ── */
.card__meta {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  margin: 0;
  transition:
    grid-template-rows 380ms var(--ease-out-expo),
    opacity 240ms var(--ease-out-quart);
}

.card__meta-inner {
  min-height: 0;
  overflow: hidden;
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: 5px;
  padding-top: 8px;
}

.card__meta-row {
  display: contents;
}

.card__meta dt {
  display: none;
}

.card__meta dd {
  display: inline-block;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 500;          /* the one title style (Oct 2026) */
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-1);
  opacity: 0.55;
  background: color-mix(in oklab, var(--ink-1) 7%, transparent);
  border-radius: 0;
  padding: 2px 7px 3px;
  margin: 0;
  line-height: 1.5;
}

/* Hover state — lift, reveal rule + meta.
   Lift rides `transform` so it stays snappy and independent of the
   slower `translate`-based scroll-reveal entry. Keyboard focus lifts
   unconditionally; pointer hover is gated so the lift never sticks
   after a tap on touch devices. */
/* aldonchen-style hover: a soft grey wash on the whole card + the
   title turns blue. No lift, no tilt — the interaction stays calm. */
@media (hover: hover) and (pointer: fine) {
  .card:hover {
    background-color: rgba(0, 0, 0, 0.05);
  }
  .card:hover .card__title {
    color: var(--accent-blue);
  }
}

.card:focus-within {
  background-color: rgba(0, 0, 0, 0.05);
}

.card:focus-within .card__title {
  color: var(--accent-blue);
}

.card:hover .card__rule,
.card:focus-within .card__rule {
  opacity: 1;
  transform: translateY(0);
  transition-delay: 80ms;
}

.card:hover .card__meta,
.card:focus-within .card__meta {
  grid-template-rows: 1fr;
  opacity: 1;
}

/* Audit lock — TEMPORARY, per DESIGN.md's "in progress" work: the four
   full-page cases keep their full clickable design (hover wash, blue
   title, meta reveal) so the homepage reads as finished, but the click
   itself is a no-op (see js/casestudy.js). The visual affordance stays
   intentionally intact; only navigation is held back while each case
   page is reviewed one at a time. Nothing to style here on purpose. */

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
  .card,
  .card__shape,
  .card__rule,
  .card__meta { transition: none !important; }

  .card:hover,
  .card:focus-within { transform: rotate(0deg); translate: 0 0; }

  .card__meta {
    grid-template-rows: 1fr;
    opacity: 1;
  }
  .card__rule { opacity: 1; transform: none; }
}

/* ════════════════════════════════════════════════════════════════
   MESSAGE WIDGET — fixed bottom-right collapsible popup
   ════════════════════════════════════════════════════════════════ */
/* ── Message widget ─────────────────────────────────────────────
   Tab/drawer anchored to the bottom-right. Bar = collapsed handle,
   panel = drawer that expands upward. Both share the same glass
   recipe as the card popup (.cs-panel).
   ─────────────────────────────────────────────────────────────── */
/* Force custom cursor across the entire widget — fixed elements
   don't reliably inherit from body in all browsers, and inline
   spans inside buttons can override back to cursor:auto */
/* Text fields keep the I-beam so users know they're editable */
.msg-form__input,
.msg-form__textarea {
  cursor: text;
}

/* Re-enabled July 2026 with the voice + visual-component upgrade.
   To pull it from the page again, add `display: none;` back here. */
.msg-widget { z-index: 300; }
/* The widget ships in the page HTML at its old bottom-right spot and
   only becomes the dock's chat segment once js/toolbar.js (deferred)
   moves it into .tbar. Until then it must never paint, or a reload
   flashes the retired corner design. Every page that has it also
   mounts the dock, so it has no life outside .tbar. */
.msg-widget:not(.tbar .msg-widget) { display: none; }

/* Frosted ink-on-paper — translucent milk glass over the page, the same
   family as the about panel. The page ghosts through the blur; a hairline
   and an inset light keep it reading as a slip of paper, not chrome. */
.msg-widget__inner {
  position: relative;
  background: oklch(99% 0.001 90 / 0.8);
  /* 24px reads the same as the old 52px on this mostly-flat page, at a
     fraction of the GPU cost — this layer re-blurs on every scroll
     frame because it's fixed over moving content. */
  backdrop-filter: blur(24px) saturate(1.3);
  -webkit-backdrop-filter: blur(24px) saturate(1.3);
}
.msg-widget__dock, .msg-widget__body { position: relative; }

/* Night mode: dark milk glass instead of white; the grain flips to
   screen-blend so it reads as texture, not soot. Everything else in the
   widget follows the flipped ink/surface tokens automatically. */
html[data-theme="dark"] .msg-widget__inner {
  background: oklch(16% 0.004 255 / 0.78);
}

/* ── Panel ───────────────────────────────────────────────────── */
/*
  Always visible — floats above the bottom edge.
  Starts compact (header only); .is-open reveals the chat body.
  Draggable: JS converts right/bottom to left/top on first drag.
*/
.msg-widget__panel {
  position: fixed;
  bottom: 20px;
  right: var(--pad-page-x);
  width: min(360px, calc(100vw - 32px));
  z-index: 301;
  border-radius: 0;
  will-change: transform;
  /* Tinted shadow — floats above page */
  filter: drop-shadow(0 8px 28px color-mix(in oklab, var(--ink-1) 13%, transparent))
          drop-shadow(0 2px 6px  color-mix(in oklab, var(--ink-1) 6%,  transparent));
}

/* Header grab cursor — ID beats the (0,3,0) site-wide rule */
#msg-widget-header { cursor: grab; }

/* Dragging — grabbing cursor on everything inside */
#msg-widget-panel.is-dragging,
#msg-widget-panel.is-dragging * {
  cursor: grabbing !important;
}

/* ── Inner — grid for height animation ──────────────────────── */
.msg-widget__inner {
  display: grid;
  grid-template-rows: 0fr auto; /* compact: the dock bubble only */
  border-radius: 0;
  border: none; /* fluid: the frost's shadow is the only edge */
  overflow: hidden; /* clip the collapsing body */
  /* Safety cap: prevent expansion from exceeding viewport height */
  max-height: calc(100dvh - 48px);
  transition: grid-template-rows 380ms var(--ease-out-expo);
}

/* Expanded — conversation unfolds above the bubble */
.msg-widget__panel.is-open .msg-widget__inner {
  grid-template-rows: 1fr auto;
}

/* ── Body — the collapsible grid row ────────────────────────── */
/*
  NO padding here — padding on this element would leave a visible stub
  in the 0fr collapsed state because padding-box height ≠ 0.
  Padding lives on __body-content (the child) instead.
*/
.msg-widget__body {
  min-height: 0;   /* required: lets grid force this row to 0 height */
  overflow: hidden;
}

.msg-widget__body-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
  /* Top padding used to come from .msg-widget__topbar, which existed only
     to hold the close button (the curiosity meter it once sat beside was
     already removed). Kept here directly now that the topbar is gone —
     the × was redundant with the chevron dock toggle right below it (both
     called the exact same collapse; see the removed listener comment in
     js/chatbot.js), and "close" was the wrong verb for a widget that only
     ever folds back to its own dock, never actually leaves the page. */
  padding: 8px 14px 12px;
}

/* ── The dock — a chat bubble that IS the collapsed widget ───── */
/* Reads instantly as "type here": › prefix, placeholder, blinking block
   cursor. Doubles as the drag handle; expands on hover, click, or focus. */
.msg-widget__dock {
  display: flex;
  align-items: center;
  gap: 8px;
  background: transparent;
  padding: 10px 8px 11px 14px;
  min-height: 40px;
  cursor: grab;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.msg-widget__dock .chat-form {
  flex: 1;
  min-width: 0;
  padding: 0;
  margin: 0;
}
/* Collapsed hover: the bubble leans in, the invitation darkens */
.msg-widget__panel:not(.is-open):hover .chat-placeholder { color: var(--ink-2); }
.msg-widget__panel:not(.is-open):hover .msg-widget__dock .chat-form__prefix { color: var(--ink-2); }
.msg-widget__panel { transition: transform 300ms var(--ease-out-expo); }
.msg-widget__panel:not(.is-open):hover { transform: translateY(-2px); }

/* The › prefix doubles as the status light: ink pulse while thinking */
.msg-widget.is-thinking .chat-form__prefix {
  color: var(--ink-1);
  animation: chat-blink 0.9s steps(1) infinite;
}

/* ── Expand / collapse button ────────────────────────────────── */
.msg-widget__expand-btn {
  display: none; /* desktop: hover/click and the ✕ handle it; mobile shows it */
  align-items: center;
  justify-content: center;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 24px;
  flex-shrink: 0;
  background: none;
  border: none;
  padding: 0;
  color: var(--ink-3);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: color 120ms var(--ease-out-quart);
}

.msg-widget__expand-btn:hover  { color: var(--ink-1); }
.msg-widget__expand-btn:active { opacity: 0.7; }

/* Chevron: points UP (↑) in compact = "click to expand" */
.msg-widget__expand-icon {
  display: block;
  transform: rotate(0deg); /* path draws ∧ — already pointing up */
  transition: transform 320ms var(--ease-out-expo);
}

/* Chevron rotates to ↓ when expanded = "click to collapse" */
.msg-widget__panel.is-open .msg-widget__expand-icon {
  transform: rotate(180deg);
}

/* ── FYI note ─────────────────────────────────────────────────── */
.msg-card__fyi {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.6;
  color: var(--ink-3);
}

/* .msg-card__fyi::after removed — replaced by .hand-rule element (see JS) */

.msg-card__fyi .chip-tag {
  display: block;
  width: fit-content;
  margin-bottom: 10px;
}

.msg-card__fyi-badge {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-primary);
  font-weight: 700;
  font-size: var(--t-meta);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  padding: 2px 6px 1px;
  border-radius: 0;
  background: var(--ink-1);
  color: var(--ink-inv);
  line-height: 1;
  margin-right: 6px;
  position: relative;
  top: -1px;}

.msg-card__fyi-aside {
  display: block;
  margin-top: 10px;
  font-style: italic;
  opacity: 0.6;
}

/* ── Form ─────────────────────────────────────────────────────── */
.msg-form {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* .msg-form__row removed — email field dropped, name is now full-width */

.msg-form__input,
.msg-form__textarea {
  width: 100%;
  font-family: var(--font-body);
  font-size: var(--t-small);
  color: var(--ink-1);
  /* Filled, popup-style field — token-based so it holds up at night */
  background: color-mix(in oklab, var(--ink-1) 4%, transparent);
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  border-radius: 0;
  padding: 9px 12px;
  outline: none;
  resize: none;
  transition:
    border-color 180ms var(--ease-out-quart),
    background   180ms var(--ease-out-quart);
}

.msg-form__input::placeholder,
.msg-form__textarea::placeholder {
  color: color-mix(in oklab, var(--ink-3) 40%, transparent);
}

.msg-form__input:focus,
.msg-form__textarea:focus {
  background: color-mix(in oklab, white 88%, transparent);
  border-color: color-mix(in oklab, var(--ink-1) 28%, transparent);
}

.msg-form__textarea {
  height: 110px;
  line-height: 1.55;
}

.msg-form__footer {
  display: flex;
  align-items: center;
  gap: 12px;
  padding-top: 4px;
}

.msg-form__submit {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-family: var(--font-primary);
  font-weight: 500;
  font-size: var(--t-small);
  letter-spacing: -0.07em;
  text-transform: uppercase;
  padding: 2px 11px 4px 8px;
  border: none;
  background: #fff;
  color: #2d241c;
  cursor: pointer;
  filter: blur(0.4px);
  box-shadow:
    0 1px 2px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 3px 8px -1px color-mix(in oklab, var(--ink-1) 11%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
  transition:
    transform  340ms var(--ease-out-soft),
    box-shadow 340ms var(--ease-out-soft);
}

.msg-form__submit:hover {
  transform: rotate(-2deg);
  box-shadow:
    0 3px 8px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 10px 24px -4px color-mix(in oklab, var(--ink-1) 14%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
}

.msg-form__submit:active {
  transform: rotate(-1deg);
  transition-duration: 80ms;
}

.msg-form__thanks {
  font-family: var(--font-body);
  font-size: var(--t-meta);
  font-style: italic;
  color: var(--ink-3);
  opacity: 0.75;
}

/* ════════════════════════════════════════════════════════════════
   CONTACT SECTION
   ════════════════════════════════════════════════════════════════ */
/* ── Contact list ───────────────────────────────────────────── */
.contact-list {
  display: flex;
  flex-direction: column;
}

.contact-row {
  display: flex;
  align-items: center;
  padding: 10px 2px;
  border: none;
  background: transparent;
  gap: 12px;
  position: relative;
  transition: color 220ms var(--ease-out-quart), transform 160ms var(--ease-out-quart);
}
.contact-row:active { transform: scale(0.99); }

.contact-row + .contact-row {
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 11%, transparent);
}

.contact-row:hover {
  background: transparent;
}

.contact-row__label {   /* composes .eyebrow; only layout + hover transition here */
  flex-shrink: 0;
  transition: color 220ms var(--ease-out-quart);
}

@media (hover: hover) and (pointer: fine) {
  .contact-row:hover .contact-row__label {
    color: var(--accent-blue);
  }
}

.contact-row__value {
  font-family: var(--font-body);
  font-size: var(--t-small);
  color: var(--ink-2);
  flex: 1;
  text-align: right;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: color 220ms var(--ease-out-quart);
}

@media (hover: hover) and (pointer: fine) {
  .contact-row:hover .contact-row__value {
    color: var(--accent-blue);
  }
}

.contact-row__arrow {
  flex-shrink: 0;
  opacity: 0.35;
  color: var(--ink-1);
  transition: opacity 220ms var(--ease-out-quart), transform 320ms var(--ease-out-expo), color 220ms var(--ease-out-quart);
}

@media (hover: hover) and (pointer: fine) {
  .contact-row:hover .contact-row__arrow {
    opacity: 1;
    color: var(--accent-blue);
    transform: translate(2px, -2px);
  }
}

/* Thumbnail — left of each info row */
.contact-row__thumb-wrap {
  flex-shrink: 0;
  width: 56px;
  /* The captures are cropped to their own edge; the pale mat they used
     to bake in (245 grey) is drawn here instead, as padding on
     --surface-deep, so it can carry a real corner (--radius-thumb) and
     the shadow sits on its outside. 1px, a hairline: at 2px the mat
     read as a thick white stroke around a 56px thumbnail. */
  padding: 1px;
  background: var(--surface-deep);
  box-shadow: 0 1px 4px color-mix(in oklab, var(--ink-1) 12%, transparent);
  transition:
    transform 320ms var(--ease-out-soft),
    box-shadow 320ms var(--ease-out-soft);
}

@media (hover: hover) and (pointer: fine) {
  .contact-row:nth-child(1):hover .contact-row__thumb-wrap {
    transform: scale(1.2) rotate(4deg);
    box-shadow: 0 4px 12px color-mix(in oklab, var(--ink-1) 18%, transparent);
  }

  .contact-row:nth-child(2):hover .contact-row__thumb-wrap {
    transform: scale(1.2) rotate(-5deg);
    box-shadow: 0 4px 12px color-mix(in oklab, var(--ink-1) 18%, transparent);
  }

  .contact-row:nth-child(3):hover .contact-row__thumb-wrap {
    transform: scale(1.2) rotate(2deg);
    box-shadow: 0 4px 12px color-mix(in oklab, var(--ink-1) 18%, transparent);
  }
}

.contact-row__thumb {
  width: 100%;
  height: auto;
  display: block;
}

/* ── Experience / Education — divider-line row list ── */

/* .resume-row stacks two full-width lines: the head (title + date) and
   the sub line. Grid, not flex, for that outer stack — a single column,
   so sub always gets the row's full width with nothing to negotiate. */

/* Separator between the visible row and the expanded more-rows */

/* The expand wrapper itself is the section boundary */
.resume-expand {
  padding-bottom: 2px;
}

/* The head: title and date on one line for a short title ("Circles ⊹
   Product Designer", the common case) — but flex-wrap, not nowrap, so a
   long title ("University of California, Los Angeles (UCLA)") is never
   squeezed into a narrower column just to keep the date beside it. Once
   title's own natural (unwrapped) width plus the date's leaves no room,
   the WHOLE DATE wraps to a line of its own (flex only ever wraps an
   entire item, never mid-item), and title — now alone on its line —
   gets the full row width to wrap freely. That is the fix: previously
   title and date were grid siblings pinned to the SAME line forever, so
   a long title had no choice but to wrap inside a column already
   narrowed by the date, orphaning a short last line ("(UCLA)") in a lot
   of dead space the date could not use either, since it was pinned to
   the first line only.
   flex-basis MUST be auto, not a fixed px floor. A fixed basis is what
   the wrap decision measures against regardless of the actual text, so
   a value picked to "cover long titles" makes short ones wrap too early
   (tried 220px here: it overflowed "Circles ⊹ Product Designer", which
   only needs about 170px, while UNDER-covering the UCLA title, which
   needs about 300, so the wrong row wrapped in both directions). auto
   sizes the wrap test on the real max-content width of each title, so
   short titles stay paired with the date and only genuinely long ones
   push it down. min-width: 0 keeps title free to shrink starting from
   that real size, rather than a browser-assumed content minimum. */

/* One recipe, two composers, which is the pattern the .eyebrow group
   already uses in this file. "Notes" has to read as the same kind of
   thing as "prev @ Circles", and the surest way for it to keep reading
   that way is for there to be a single declaration of what that type is.
   Only .resume-row__title sits in a flex row, so the flex properties
   stay on it rather than joining the shared recipe.

   The recipe itself IS .eyebrow's (font-weight 600, 10px, 0.14em,
   ink-4) — the label style INFO's own EMAIL / LINKEDIN / RESUME already
   use — composed by hand rather than adding the class, since this rule
   also carries the flex layout .eyebrow has no opinion on. Previously
   .case-section__heading's recipe (11px/700/0.16em/ink-1); moved to
   match the row it was actually asked to match this time. */

/* ── Experience expand / collapse — grid-height animation ────── */
.resume-more-wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms var(--ease-out-quart);
}

.resume-expand:not(.is-collapsed) .resume-more-wrap {
  grid-template-rows: 1fr;
}

.resume-more-inner {
  min-height: 0;
  overflow: hidden;
}

.resume-toggle {
  display: block;
  margin-top: 10px;
  font-family: var(--font-primary);
  font-weight: 600;
  font-size: var(--t-meta);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  transition: color 160ms var(--ease-out-quart), transform 100ms var(--ease-out-quart);
}

@media (hover: hover) and (pointer: fine) {
  .resume-toggle:hover { color: var(--accent-blue); }
}
.resume-toggle:active {
  color: var(--accent-blue);
  opacity: 0.7;
  transform: scale(0.97);
}

/* ── Overview component — aldonchen-style collapsible bio box ──────
   A soft grey panel holding the bio; collapsed by default with a
   fade-out at the bottom and an [+] EXPAND FULL TEXT toggle. */
.overview-box {
  background: var(--surface-deep);
  padding: 16px;
}

.overview-text {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 10px;
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.5;
  color: var(--ink-2);
}

/* Back to 6.4em: the card holds the height it has always had. EDUCATION
   is meant to show through the fade rather than clear it, and the room
   for that comes from deleting the hairline between the two sections
   (see .overview-career__section below), not from a taller card. */
.overview-text.is-collapsed {
  max-height: 6.4em;
  overflow: hidden;
}

/* Fade-out veil over the clamped text — only while collapsed.
   1.6em, down from 2.8em. At 36px the veil began at 46.8px, well above
   the 67.6px where the EXPERIENCE line ends, so it washed out text it was
   only meant to sit under and left nothing legible behind it. At 20.8px
   it begins at 62.4px, so EXPERIENCE reads clean and the top of EDUCATION
   shows through the thin end of the gradient. */
.overview-fade {
  display: none;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1.6em;
  background: linear-gradient(to bottom, transparent, var(--surface-deep));
  pointer-events: none;
}

.overview-text.is-collapsed .overview-fade {
  display: block;
}

.overview-toggle {
  margin-top: 12px;
  font-family: var(--font-primary);
  font-weight: 600;
  font-size: var(--t-meta);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  background: none;
  border: none;
  padding: 0;
  transition: color 160ms var(--ease-out-quart);
}

@media (hover: hover) and (pointer: fine) {
  .overview-toggle:hover {
    color: var(--accent-blue);
  }
}

.overview-toggle:active {
  color: var(--accent-blue);
  opacity: 0.7;
}

/* "Unprofessionally:" is the bio's tonal hinge — the pivot from the
   professional line to the personal one — so it earns a mark: a
   HAND-DRAWN wobbly underline in the accent ink, the same ballpoint
   language as the letter's signature (an SVG squiggle authored as art,
   like the sprayed frames — a straight rule read as UI, not doodle).
   preserveAspectRatio="none" stretches the wobble to whatever width the
   word renders at. Inside the letter it DRAWS ITSELF like a pen stroke
   (dashoffset, linear = constant pen velocity, the documented
   constant-rate exception), gated on the SAME is-signed class as the
   signature so both start moving on the same frame — two pen strokes
   landing at once, not a queued sequence. The homepage teaser sits
   pre-drawn (solid, static, never inside .bio-letter so none of the
   rules below touch it). dasharray "1 2", not "1": round linecaps
   overshoot each dash boundary by half the stroke width and bleed dots
   through at full offset (same bug the signature hit). */

/* Inside the letter (theater clones AND the settled sheet) it starts
   undrawn. Both need the hidden dasharray, not just the sheet: the
   theater's front-face panels clone the same markup, and used to
   inherit the bare (solid, always-visible) rule above, so the whole
   underline flashed on screen the instant a fold panel windowed it,
   then vanished when is-settled swapped the theater out for the sheet
   and the sheet's own copy restarted from hidden. Only the SHEET gets
   the transition, so the draw-in still happens exactly once, after the
   letter is actually open. */

/* ── Bio letter — the bio's full text opens as an unfolding letter ──
   Built by js/bio-letter.js from a clone of #overview-text. Anatomy:
   an ink scrim (the case modal's exact recipe), a centered paper box,
   and a two-act stage: .bio-letter__theater holds three fold panels
   (.blf--1 static, --2 and --3 hinged on rotateX, each windowing its
   third of the page), then .bio-letter__sheet, the plain flowing copy
   the theater swaps to once settled. Zero radius, tokens only. Class
   stages set by JS: .is-mounted (scrim + folded arrival), .is-f2 /
   .is-f3 (the folds), .is-settled (swap), .is-closing (fast exit).
   The plate itself is a PLAIN static box again (no hover peek — that
   experiment is reverted); only the toggle button opens the letter,
   and it is the only part of the plate that reacts to hover.

   The dialog is NOT a hidden singleton. js/bio-letter.js builds it on
   every open and REMOVES IT FROM THE DOM in cleanup, because parking
   this subtree off screen behind the base visibility: hidden below left
   iOS Safari repainting its stale compositor tiles as a translucent
   double of the bio — see cleanup in that file. The base
   visibility: hidden here now covers only the frame between the mount
   and is-mounted; do not "optimise" the teardown back into a hide. */

/* 520ms, matching the return glide's own total (520ms transform; the
   220ms-delay + 300ms opacity fade also lands at 520) — not the 420
   this most recently read, which finished the scrim 100ms BEFORE the
   letter itself was done fading: the page relit while a translucent
   letter was still visibly dissolving in front of it, which broke the
   "everything settles together" feeling the exit is going for. Same
   end time as RETURN_AT + 520 in bio-letter.js (see cleanup()'s
   timer) — scrim, letter, and the plate's return all resolve on the
   same beat. */

/* Reduced-motion exit only: the full-motion close PINS box opacity at 1
   inline (see close()) and vanishes the box in one frame at landing —
   this fade is the fallback for the no-glide path. */

/* The landing MORPH (August 2026, third pass — the requirement that
   settled it: THE TEXT IS NEVER ABSENT). The packet stays a solid,
   fully WRITTEN sheet for the entire return glide, mirroring the open
   (which lifts off with its text visible). Two rejected versions:
   fading the whole box turned the letter into a translucent ghost
   smearing over the page mid-flight, and fading the writing off to
   land a blank card guaranteed a text-less beat between the letter's
   text and the teaser's — precisely the vanish-then-appear the merge
   exists to kill. Instead, on the LANDED frame (transitionend in
   bio-letter.js), the still-written box crossfades out IN PLACE while
   the teaser's content fades in beneath it: for ~200ms the letter's
   wrap and the teaser's wrap coexist softly on the same grey card,
   which reads as one text relaxing into the other. Only the ✕ chip
   dissolves during the flight — it is chrome, not writing. */

/* The morph face (built per close in bio-letter.js): a clone of the
   bio plate riding the flying box, pre-scaled so the box's landing
   transform cancels it to exactly the real plate's geometry. Fades in
   over the letter's text DURING the glide — the card's content
   visibly reflows from letter to teaser while it travels, which is
   the morph; at the landed frame it is pixel-identical to the plate,
   so the final swap shows nothing. Sits above the stage and the ✕
   (z-index 4 over the chip's 3); its own --surface-deep fill comes
   from the cloned .overview-box class, so as it fades in it also
   covers the letter's text with card rather than mixing the two
   texts translucently. The 120ms delay holds the letter's own face
   for the first beat of the flight, so the reflow reads as happening
   BECAUSE the card is leaving. */

/* The ✕ sits INSIDE the letter's top-right padding corner, like a mark
   on the sheet itself, not a satellite floating over the scrim. Above
   the fold panels' stacking contexts. */

/* While the letter is out, the plate it lifted from stands empty —
   and EMPTY means content-less, not gone (August 2026). Hiding the
   whole box made the returning packet land over bare page with the
   plate popping back beneath it, and worse: the plate faded back in
   DURING the landing glide, so the packet's letterhead cross-faded
   over the teaser's own text while both were semi-transparent — two
   different-scale renderings of the same bio double-exposed, which
   read as ghosting. Now the grey surface stays put for the whole
   letter session (the letter visibly lifts out of and dissolves back
   into an empty plate; text only ever fades over plain paper), and
   the teaser's content returns via cleanup() once the packet is
   fully gone. */
.overview-box > * { transition: opacity 220ms var(--ease-out-quart); }
.overview-box.is-lettering { pointer-events: none; }
.overview-box.is-lettering > * { opacity: 0; }
/* ── The sign-off: a letter ends like one ──
   "thanks for reading," + the name, CENTERED under the letter body —
   the stamp is pulled out of that flow entirely and pinned to the
   page's own bottom-right corner (position: absolute against
   .bio-letter__page, which is why that rule carries position:
   relative), the way a real stamp sits in a fixed spot regardless of
   how the signature above it is laid out. Perforated grey stamp paper
   (SVG mask, art, not radius), the street footer's pixel raven printed
   in the accent ink, the year in the corner. The stamp sits a few
   degrees off square the way a real stamp lands; petting it makes the
   raven flap — the letter's own small easter egg, pointer-only,
   dropped under reduced motion. */
/* 26px, once. It was tuned for a sign block that opened straight into
   the thank-you, back when nothing above it looked like a receipt yet.
   makeSign() now always opens with rule(sign) as its first line, and
   that margin stacked ON TOP of the rule's own 12px plus the page's
   10px flex gap, measuring 48px from the end of NOTES to the rule —
   three times the 16px every other section break on this card uses.
   Zeroed: the flex gap and the rule's own margin are what carry this
   transition now. */

/* The letter's reading passage, matched to the career block above it.
   It was inheriting 13px at --ink-1 against the career rows' 11px at
   --ink-3, so the same paragraph read both larger and much darker than
   everything it followed and took over the page. Same family (it is a
   reading passage, so --font-body either way), now the same size and the
   same grey, which puts it in one register with the rows instead of
   competing with them.

   Line-height stays looser than the rows' 1.3: those are single clipped
   lines, this is a wrapping paragraph and 1.3 would set it too tight to
   read comfortably. */

/* ── The letter prints as a receipt ────────────────────────────────────
   Scoped entirely under .bio-letter__page, so the same markup keeps its
   plate styling on the card and only takes the receipt's face inside the
   letter. Nothing here touches the fold: no transforms, no transitions,
   no geometry the theater measures. The page's height is read live at
   open (js/bio-letter.js measure(), off sheet.scrollHeight), so the
   header and footer below simply make the paper longer and the three
   fold panels re-window themselves around it. */

/* Mono for what the machine prints at the FOOT of the receipt only:
   totals and the small print. The reused bio content above it (career
   rows, About me, the paragraphs) stays in the card's own two faces —
   it has to read as the identical content the card already showed, not
   as a receipt reprinting it. The one thing borrowed from the receipt
   into that content is the dot leader between a title and its date
   (below), which is layout, not type. */

/* The printed rules. Repeating-gradient dashes rather than border-dashed:
   a real border's dash phase is decided by the browser and the run gets
   visibly clipped at one end, while a gradient tiles from a known origin
   and every rule on the page breaks at the same rhythm. */

/* "LABEL: value", the label carrying its own colon (line() appends it),
   set close together rather than spread to the row's edges — the plain
   "field: value" a card-terminal slip actually prints, not a priced list
   pretending these are line items. A dot leader lived here once; removed
   on request, since it read as a receipt borrowing a PRICE LIST's device
   for fields that were never prices. */

/* "Total:" reads as a section label now — the exact recipe NOTES,
   PREV @ CIRCLES and EDUCATION @ UCLA already share (moved with them
   when that shared recipe changed to .eyebrow's, and again to ink-1 for
   the same reason: quiet ink-4 didn't stand out as a header) — rather
   than the receipt's mono voice the fields below it keep. Scoped to
   .rcpt-line__k specifically so the VALUE is untouched by it. */

/* The value keeps none of that: only the positioning that makes it
   sit at the row's far edge (the same margin-left:auto device
   .resume-row__date uses on the plain card). No font-size, color or
   weight override left here means it inherits exactly what Card # and
   Auth code's own values do — same voice as the fields below it, not a
   size and color unique to this one row. */

/* Career rows print with a dot leader between the title and its date, a
   till-line touch on the card's own type rather than a swap of it.
   LAYOUT only: no font-family, size or case changes here.

   Unscoped on request: this lived under .bio-letter__page only, so the
   plain teaser card showed "prev @ Circles" and its date with bare
   whitespace between them while the letter, showing the SAME row, drew
   the leader — one row rendering two different ways depending only on
   which of its two homes happened to be open. Dropping the letter-only
   scope is what makes the two agree; nothing else about this rule
   changes; it already sat AFTER .resume-row__title's own definition, so
   removing the extra selector segment does not change which rule wins.

   title:flex 0 0 auto is what makes the leader possible — the base row
   rule grows the title to fill the line (min-width:0 in
   .resume-row__title's own rule), which leaves no space for anything
   between it and the date. Hugging the title's content is the one
   layout change the leader actually needs. */

/* The foot: totals, thank-you, signature, barcode, small print. */

/* The card block used to hand off to this at whatever gap its own
   trailing rule happened to leave (about 12px); that rule is gone on
   request, and with nothing between them the two blocks were flush
   against each other — 0px, confirmed measuring the boxes. 16px matches
   the card's own rhythm (EXPERIENCE to EDUCATION, EDUCATION to NOTES are
   both exactly this), rather than inventing a gap tuned only for here. */
/* 12px, down from 16px, as part of the same height-trimming pass —
   scoped to the letter alone, and matched below by the same drop on
   EXPERIENCE-to-EDUCATION and EDUCATION-to-NOTES, so the three section
   breaks stay tied to one number even though the number moved. */

/* The two section breaks above the sign block, overridden to the same
   12px rather than left at the plain card's 16px: those base rules
   (.overview-career__section + .overview-career__section's margin,
   .overview-about's margin-top) are shared with the homepage card,
   which has room to spare and was never asked to shrink. Scoping the
   override here is what lets the letter compress without touching it. */

/* The Notes paragraphs read at the plate's shared --t-body size, which
   is oversized next to the letter's own EXPERIENCE/EDUCATION rows;
   scoped to the letter alone (not .overview-about, shared with the
   homepage card, which has room to spare) to match .resume-row__sub's
   caption recipe exactly. */

/* Height and top margin both trimmed (30->24, 14->10) as part of the
   same pass shrinking the letter's total height: still tall enough to
   read as a barcode, decorative either way (see makeBarcode() — it
   encodes nothing scannable). */

/* The signature: a real hand-drawn stroke set, accent ink, ballpoint
   style (js/bio-letter.js SIG_STROKES + makeSignature()). In the
   settled sheet each stroke WRITES ITSELF once the theater finishes
   (dashoffset 1 → 0), in the order it was actually drawn, each timed
   off its own --sig-dur/--sig-delay (set inline per <path>, since 8
   strokes can't share one fixed duration the way a single path could).
   .bl-sig only ever exists inside .bio-letter (theater clones + the
   sheet), so the hidden state lives on the bare rule: EVERY copy,
   including the theater's, starts fully undrawn. It used to be scoped
   to .bio-letter__sheet only, "pre-drawing" the theater's copies on
   purpose so the bottom fold never windowed a half-finished signature
   — but the actual effect was the complete signature (phone number
   included) flashing into view mid-fold, then vanishing and restarting
   from scratch the moment is-settled swapped the theater for the
   sheet. Hidden-everywhere + reveal-once-in-the-sheet reads as one
   clean draw, not a flash-then-redo. Reduced motion shows it drawn.
   Width follows SIG_VIEWBOX's aspect ratio (height:auto), at the same
   px-per-viewBox-unit scale every crop of this signature has kept: 153px
   over the 181.2-wide box that held the name and the (now removed)
   phone number, so when SIG_VIEWBOX tightened to 102 units wide around
   the name alone (Sept 2026, js/bio-letter.js), width followed it down
   to 153 * 102/181.2 ≈ 86px — the same ~0.844px/unit, so the pen weight
   and letter size read identical to every earlier version, not bigger
   just because the box around them got smaller. Whenever SIG_VIEWBOX's
   width changes again, rescale this the same way rather than eyeballing
   a new px value. */

/* Gated on is-signed, NOT is-settled: is-settled is also what reveals
   .bio-letter__sheet (display:none -> block), and a property change
   bundled into that same recalc has no prior painted frame to animate
   from — it just snaps. is-signed lands two frames later (see open()
   in bio-letter.js), once the hidden-ink state has actually painted. */

/* Refolding (close): the theater swaps back IN for the fold-up, and by
   then the letter has been signed — its clones must show the ink
   already drawn, instantly, or the signature and underline would blink
   out the moment the signed sheet becomes the (hidden-ink) theater. */

/* The stage is sized to the folded letter's final height; once settled
   it relaxes to auto so the sheet can own its own (scrollable) height. */

/* ── Act one: the fold theater ── */

/* is-swap-done, NOT is-settled: the theater used to vanish the same
   frame the sheet appeared — a hard cut in which the three stacked
   panel shadows and any remaining fold wash popped into the sheet's
   single clean shadow. The theater now stays beneath the sheet while
   the sheet cross-fades in over it (see the sheet rules) and only
   leaves once the fade has finished. */

/* MINUS 180, not plus: the sign is the whole difference between the
   fold opening across the FRONT and opening from the BACK. At +180
   each panel's bottom edge arced toward the camera as it unfolded,
   sweeping across the letter and flashing its paper-back mid-flight —
   which read as panels "peeking through" rather than a letter
   unfolding. At -180 the panel sweeps the back half-space instead:
   it stays hidden behind the sheet until it passes edge-on, then its
   FRONT face pivots down into view from behind, like a drawbridge
   closing toward you. Nothing ever crosses in front of the page.
   The resting matrix of rotateX(-180) is IDENTICAL to +180 (only the
   travel direction differs), so the flat packet, the paint order, and
   the z offsets below are untouched by the sign.

   DIFFERENT z offsets per hinge, not one shared value: panel 3 nests
   inside panel 2, so its fold is 180+180 = 360deg — facing forward
   again — and equal offsets cancel to net zero, landing it exactly
   coplanar with panel 1 where it painted ON TOP (the packet showed
   the sign-off third instead of the letterhead). Unequal offsets
   can't cancel: panel 1 at 0, panel 3 at -1px, panel 2 at -2px.
   WHOLE pixels, not the 0.6/0.3 this started at — sub-pixel z gaps
   sat inside the depth buffer's noise floor on high-DPI screens, so
   the stacked thirds shimmered through each other mid-refold. At
   1100px perspective a 2px offset scales the hidden panel by ~0.2%,
   which is invisible. */

/* Refolding (close): the base 560ms expo above is tuned for the
   arrival — expo front-loads its speed, so run BACKWARDS the fold-up
   slams off the line and reads as jarring.

   Keyframes played in REVERSE, not transitions: a closing back-fold
   is only visible from flat to edge-on — the first HALF of its
   rotation — and every easing token front-loads its speed, so as a
   transition each fold blinked through its visible half in ~60ms and
   spent the rest moving where nobody could see it: blink, dead air,
   blink. Reversing the UNFOLD keyframes reflects the token (slow
   start, fast end), which lands the slow part on the visible
   approach (~85% of the run) and the fast part in the hidden tail.
   The delays overlap the stages into one continuous gesture: fold 2
   starts breathing while fold 3 is still mid-approach, is already
   visibly tipping when fold 3 passes edge-on, and the return glide
   (bio-letter.js) picks up right as fold 2 vanishes — motion never
   fully stops between the click and the landing, which is what kept
   the staged version reading as abrupt pops. fill: both holds each
   pose outside the run; the animations override the class-driven
   transforms while is-refolding is on, so the staged is-f2/is-f3
   removals in bio-letter.js keep driving the shade and shadow gates
   without also fighting the panels. */

/* --surface-deep, not --surface-base: the letter is the SAME paper as
   the bio plate it lifts out of (.overview-box), so the morph reads as
   one object changing shape rather than a white card replacing a grey
   one. The token matches the plate in all three themes on its own. */

/* The revealed face carries a wash that lifts as its fold opens — the
   light reaching into the letter. 640ms to stay in step with the
   hinge transition above (the close overrides both — see
   .is-refolding). */

/* Each window translates a full page clone so its third lines up. */

/* ── Act two: the settled sheet ── */

/* The swap is a CROSS-FADE, not a cut (August 2026): the sheet mounts
   invisible OVER the still-visible theater (is-settling), fades in
   once is-settled lands a paint-frame later (the same-recalc rule:
   display and opacity may never change together), and only when the
   fade has finished does is-swap-done drop the theater. The fade
   BEGINS INSIDE the last fold's deceleration tail (bio-letter.js
   starts is-settling at 1250, while fold 3's transition is in its
   final degrees): the flap's last motion dissolves under the
   arriving sheet instead of the letter halting dead, then quietly
   swapping, then signing — the dead-air-on-both-sides sequencing
   that read as an abrupt stop. The two letters are geometry-
   IDENTICAL during the fade, on purpose: a "paper relax" (the sheet
   arriving from a 0.4% scale) was tried and cut, because
   cross-fading two copies of the same text at slightly different
   sizes reads as edge ghosting, not a morph. Run backwards by
   close(): is-settled comes off and the sheet fades OUT (140ms, the
   is-refolding override below) over the theater — whose ink
   is-refolding holds drawn — before the folds visibly move. */

/* While both letters are on stage (the fade), only the THEATER may
   cast the shadow — both casting at once composited into a visibly
   darker pool for 200ms, a pulse at every swap. The sheet takes the
   shadow over instantly at is-swap-done (its transition list below is
   opacity-only, so the handoff between two identical shadows never
   animates and never shows). */

/* Mobile: the case-page media rule grows every .case-close to 44px,
   the letter's ✕ included, so the pocket is 10 + 44 — the 42px head
   clearance above ran the first line straight under the chip. */

/* The theater's paper panels and the settled sheet share one shadow so
   the swap is invisible; only the front faces cast it, never the scrim.
   TWO layers in the dock's language rather than one broad drop (August
   2026: the single `0 18px 44px -18px` at 45% ink threw a wide, heavy
   pool that read as a lifted card, not paper): a tight contact shadow
   right under the edge plus a much shallower ambient one. Half the
   spread, well under half the ink.

   And GROUNDED, not constant: the packet lifts out of a flat,
   shadowless plate and lands back into one, so the base state here is
   the transparent twin of the shadow (same layer geometry, so the two
   interpolate cleanly) and the ink fades in only as the letter rises
   and unfolds. Gated on is-f2, NOT is-mounted: on a first open the
   dialog is built and mounted in the same style recalc, so a shadow
   keyed to is-mounted has no prior painted frame and snaps (the exact
   same-recalc trap is-signed exists to dodge), while is-f2 always
   arrives on its own timer. The gate doubles as the exit: the refold
   removes is-f2 mid-close, so the shadow dissolves while the letter
   folds and the landing frame IS the plate, not a floating card
   dropped on top of it. */

/* Reduced motion: no folds, no travel — the letter simply fades in
   already open, and fades out. */

/* Dark: the shadow recipe above rides --ink-1, which flips to white at
   night and would glow. Literal black, like every dark shadow override
   in this file (the dock, the highlighter chip, the canvas paper). */

/* The cross-fade's one-shadow-at-a-time rule (see the sheet block)
   must also beat THIS dark override, which out-specifies the plain
   suppression — without this, dark alone kept both letters casting
   during the fade and pulsed. */

/* E-ink flatness pass: floating paper drops its shadow and takes a
   hairline edge instead, like the rest of the site's chrome. The edge
   goes ONLY on the settled sheet, never on the fold panels: the theater
   stacks THREE .blf__face--front thirds edge-to-edge, so a hard 1px
   border on each doubled up at the two seams into prominent fold lines
   that then vanished when the theater swapped to the single sheet (the
   e-ink "glitch"). In light/dark the faces use a soft drop shadow, which
   blends across the stack; a hard border does not, so in e-ink the fold
   panels simply carry no edge — the shared paper fill reads them as one
   seamless letter, and the paper already separates from the dimmed scrim
   on its own. */
/* .bio-letter.is-f2 in the selector, not just the bare class: the
   shadow-on rule above gates on .bio-letter.is-f2 (three classes),
   and a plain html[data-theme] .blf__face--front would lose that
   specificity contest and let a shadow leak into e-ink while the
   letter is open. */

/* ── Career block inside overview (exp + edu shown when expanded) ── */
/* No rule above it any more: the career block leads the card now, so the
   hairline and padding that used to separate it from the bio paragraphs
   above would draw a line along the card's own top edge with nothing on
   the other side of it. The divider between EXPERIENCE and EDUCATION is
   a separate rule and is untouched. */

/* No hairline between EXPERIENCE and EDUCATION. The letter has never
   drawn one (it used to be overridden away per-page), so the plate drawing
   it made the card and the letter disagree about the same two sections.
   Air separates them in both now. */

/* The collapsed card used to tighten this to 6px, because at 16px
   EDUCATION started half a pixel below the cut and vanished. Each section
   is a line shorter now that the label reads as part of its own title
   ("Experience ⊹ Circles") instead of sitting on a row above it, so the
   full 16px fits with room to spare. The override is gone: card and
   letter space these the same way again, and nothing is tuned twice. */

/* The about block: third beat in the card, after the two career sections.
   margin-top 6px resolves to 16px once the parent's 10px flex gap is
   added, which is the same 16px between EXPERIENCE and EDUCATION, so all
   three sit on one rhythm rather than the prose floating loose under
   them. The 4px gap inside hugs the header to the text it labels, exactly
   as .overview-career__section hugs its title to its sub-line, and the
   paragraphs restore their own 10px between each other. */
.overview-about {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 6px;
}

.overview-about p + p {
  margin-top: 6px;
}

/* Rows carry their own 9px top/bottom padding (.resume-row) for when
   multiple rows stack in one section — but the first/last row in each
   section sits against the section's own border/margin, so its half of
   that padding would double up with it. Zero it here so every divider
   gets an even 16px on both sides instead of 16px above and 25px below. */

/* ── Rail name (above manifesto) ───────────────────────────── */
.cs-view-btn:active { transform: scale(0.97); }

/* (The mobile 16px bump that used to live here is gone — the input now
   matches the message-bubble size at every width, and the iOS focus-zoom
   it guarded against is suppressed at the viewport-meta level instead.
   See the type-parity note at the base .chat-input rule and the guard in
   js/chatbot.js.) */

/* ════════════════════════════════════════════════════════════════
   FOOTER — minimal, just a one-liner caption
   ──────────────────────────────────────────────────────────────── */
.site-footer {
  position: relative;
  z-index: 2;
  margin-left: var(--side-nav-w);
  padding: 0 var(--pad-page-x) 28px var(--pad-page-x);
}

.site-footer__caption {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-primary);
  font-weight: 500;
  font-size: var(--t-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-4);
}

.site-footer__dot {
  width: 5px;
  height: 5px;
  border-radius: 0;
  background: var(--ink-3);
}

/* ════════════════════════════════════════════════════════════════
   RESPONSIVE NUDGES
   ──────────────────────────────────────────────────────────────── */
@media (max-width: 720px) {
  :root {
    --side-nav-w: 0px;
    --pad-page-x: 24px;
    --pad-page-y: 24px;
  }

  .main {
    margin-left: 0;
    padding-bottom: 48px;
    gap: 36px;
  }

  .site-footer {
    margin-left: 0;
  }
}

@media (max-width: 480px) {
  .card__title { font-size: 16px; }
  .card__num { font-size: var(--t-meta); }
}

/* ── Mobile card ordering ─────────────────────────────────────
   Below 720px the two-column structure flattens into a single
   vertical stack — the normal page scroll, no side-swipe carousel.
   display:contents removes the column boxes so cards become direct
   flex children of .projects, then order places them 01→02→03→04→05
   (the curated priority order, independent of which desktop column
   a card happens to live in). */
@media (max-width: 719px) {
  /* 0.4px blur reads as print texture on high-DPI desktop but looks
     smeared on mobile subpixel rendering — remove it entirely. */
  .chip-tag,
  .msg-form__submit { filter: none; }

  .projects {
    display: flex;
    flex-direction: column;
    gap: 14px;
    padding: 4px 0 20px;
  }

  .projects-col { display: contents; }
  [data-card="1"] { order: 1; } /* Circles   */
  [data-card="2"] { order: 2; } /* Oustre    */
  [data-card="6"] { order: 3; } /* Still     */
  [data-card="7"] { order: 4; } /* Experiments */
  [data-card="3"] { order: 5; } /* Ok-bye    */
  [data-card="4"] { order: 6; } /* TarotNook */

  .card {
    width: 100%;
    transform: rotate(0deg);
  }

  /* ── Mobile card expand on scroll-into-view ─────────────────
     Mirrors the desktop :hover expand. Triggered by data-expanded
     attribute toggled via IntersectionObserver in micro.js.
     No lift (translate 0 0) since there's no cursor to follow.  */
  .card[data-expanded="true"] {
    transform: rotate(0deg);
    translate: 0 0;
  }

  .card[data-expanded="true"] .card__rule {
    opacity: 1;
    transform: translateY(0);
  }

  .card[data-expanded="true"] .card__meta {
    grid-template-rows: 1fr;
    opacity: 1;
  }

  /* ── Mobile GPU perf: kill expensive blur ops ────────────────
     backdrop-filter is the single biggest GPU drain on mobile —
     each blurred element forces a full compositing layer repaint
     on every scroll frame. Replace with opaque fills so the
     compositor can skip the blur pass entirely.
     card::after spray filters also dropped — SVG displacement
     maps are expensive on mobile GPU and barely visible anyway. */
  .card__shape {
    -webkit-backdrop-filter: none;
            backdrop-filter: none;
    background: transparent;
  }

  /* (.msg-widget__inner blur-kill removed — the shell is solid paper now,
     no backdrop-filter anywhere, so the base rule serves mobile as-is.) */

  /* Panel stretches full width on mobile — lock to bottom, disable drag.
     Scoped OUT of the dock (:not(.tbar *)): this geometry belongs to the
     retired floating-window design, and once the widget is adopted into
     the bar these !important offsets and the safe-area padding leaked
     INTO the bar's input row — on a notched iPhone the env() padding
     shoved the field ~34px off-center inside a 40px bar. The dock's own
     bottom calc already handles the safe area for the whole bar. Kept
     (scoped) for any document where the widget renders un-adopted. */
  .msg-widget__panel:not(.tbar *) {
    right: 0 !important;
    left: 0 !important;
    bottom: 0 !important;
    width: 100% !important;
    border-radius: 0;
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }

  /* Simplify card transitions on mobile — reduce reflow work */
  .card {
    transition:
      box-shadow 200ms var(--ease-out-quart),
      translate   200ms var(--ease-out-quart),
      transform   200ms var(--ease-out-quart);
  }

  .card__shape {
    transition: background 200ms var(--ease-out-quart);
  }

}

/* (The old .top-actions corner cluster is retired — its three controls
   now live in the dock. See THE DOCK section further down the sheet.) */

/* About button — paper slip style */
.about-btn {
  display: inline-flex;
  align-items: center;
  padding: 5px 15px 7px 13px;
  background: #fff;
  color: #2d241c;
  border: none;
  border-radius: 0;
  font-family: var(--font-primary);
  font-weight: 500;
  font-size: var(--t-small);
  line-height: 1;
  letter-spacing: -0.07em;
  text-transform: uppercase;
  white-space: nowrap;
  filter: blur(0.4px);
  box-shadow:
    0 1px 2px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 3px 8px -1px color-mix(in oklab, var(--ink-1) 11%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
  transform: rotate(0deg);
  transition:
    transform  400ms var(--ease-out-soft),
    box-shadow 400ms var(--ease-out-soft);
  transform-origin: center center;
  will-change: transform;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
  cursor: pointer;
}
.about-btn:hover {
  transform: rotate(-2deg) scale(1.02);
  box-shadow:
    0 3px 4px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 7px 16px -3px color-mix(in oklab, var(--ink-1) 11%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
}
.about-btn:active { transform: rotate(-2deg) scale(0.98); }

/* ════════════════════════════════════════════════════════════════
   About canvas — explorable gallery (opens from "More About Me")
   ════════════════════════════════════════════════════════════════ */
.about-canvas {
  position: fixed;
  inset: 0;
  z-index: 400;                 /* above page chrome, below cs-overlay (500) */
  overflow: hidden;
  background:
    radial-gradient(60% 46% at 50% 22%,
      color-mix(in srgb, var(--accent-blue) 9%, transparent) 0%,  /* srgb, not oklab: oklab drifts this faint blue toward purple */
      transparent 68%),
    radial-gradient(70% 50% at 50% 108%,
      color-mix(in oklab, var(--ink-1) 8%, transparent) 0%,
      transparent 60%),
    var(--surface-base);
  opacity: 0;
  transform: scale(0.965);
  pointer-events: none;
  transition:
    opacity 380ms var(--ease-out-expo),
    transform 400ms var(--ease-out-expo);
  cursor: grab;
}

body.is-canvas-open .about-canvas {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

/* ── Exit: "the board comes apart" ──
   Pins let go and the pieces drop off in a staggered cascade, the chatbot
   powers down, the strings snap away, and the whole board sinks and fades.
   Deliberately asymmetric to the calm fade-in. */
body.is-canvas-closing .about-canvas {
  opacity: 0;
  transform: scale(0.97) translateY(8px);
  pointer-events: none;
  transition:
    opacity 440ms cubic-bezier(0.5, 0, 0.75, 0.1),
    transform 480ms cubic-bezier(0.5, 0, 0.75, 0.1);
}
body.is-canvas-closing .canvas-photo {
  pointer-events: none;
}
body.is-canvas-closing .canvas-photo__inner {
  transform: translateY(120px) rotate(calc(var(--rot, 0deg) + 18deg)) scale(0.9);
  transition: transform 460ms cubic-bezier(0.45, 0, 0.7, 0.25);
  transition-delay: calc(var(--i, 0) * 16ms);
}
body.is-canvas-closing .canvas-photo__inner::before {     /* pin pops off first */
  opacity: 0;
  transform: translateY(-10px) scale(0.6);
  transition: opacity 140ms var(--ease-out-quart), transform 200ms cubic-bezier(0.4, 0, 0.7, 0.2);
}
body.is-canvas-closing .canvas-threads {
  opacity: 0;
  transition: opacity 180ms var(--ease-out-quart);
}
body.is-canvas-closing .canvas-exit,
body.is-canvas-closing .canvas-hint,
body.is-canvas-closing .canvas-zoom {
  opacity: 0;
  transition: opacity 200ms var(--ease-out-quart);
}

/* ── Entrance stagger — photos drop from their pins in a cascade ──
   The [data-entering] attribute is set by canvas.js on open() and cleared
   after ~820ms (last piece's animation completes). */
.canvas-photo[data-entering] .canvas-photo__inner {
  animation: photo-enter 360ms var(--ease-out-soft) calc(var(--i, 0) * 22ms) backwards;
}
@keyframes photo-enter {
  from { transform: rotate(var(--rot, 0deg)) translateY(-14px) scale(0.88); }
  to   { transform: rotate(var(--rot, 0deg)); }
}

@media (prefers-reduced-motion: reduce) {
  body.is-canvas-closing .about-canvas { transition: opacity 200ms var(--ease-out-quart); transform: none; }
  body.is-canvas-closing .canvas-photo__inner { transform: rotate(var(--rot, 0deg)); transition: none; }
  .canvas-photo[data-entering] .canvas-photo__inner { animation: none; }
}

/* The pannable world — centred by CSS (left/top 50% + negative half-size
   margins) so the chatbot sits dead-centre without relying on JS measurement.
   JS only applies an additional translate() as the pan offset. */
.about-canvas__world {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 2400px;
  height: 1700px;
  margin-left: -1200px;
  margin-top: -850px;
  will-change: transform;
}

.about-canvas__world.is-panning { cursor: grabbing; }
.about-canvas__world.is-panning .canvas-photo { pointer-events: none; }

/* Dotted grid — reads unmistakably as an explorable canvas. Pans with content. */
.about-canvas__world {
  background-image: radial-gradient(
    color-mix(in oklab, var(--ink-1) 18%, transparent) 1.4px,
    transparent 1.5px);
  background-size: 26px 26px;
  background-position: 0 0;
}

/* ── String threads — link same-case pieces, evidence-board style ── */
.canvas-threads {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1;
  overflow: visible;
  pointer-events: none;
}
.canvas-thread {
  fill: none;
  stroke: oklch(55% 0.16 250);         /* board string — blue */
  stroke-width: 1.7;
  stroke-linecap: round;
  opacity: 0.62;
}

/* ── Gallery pieces ──
   Outer button = drag/spring offset (JS translate). Inner = frame, tilt and
   hover lift. Splitting them lets dragging and tilting coexist cleanly. */
.canvas-photo {
  position: absolute;
  display: block;
  padding: 0;
  border: 0;
  background: none;
  z-index: 2;
  cursor: grab;
  -webkit-tap-highlight-color: transparent;
}
.canvas-photo:hover,
.canvas-photo:focus-visible { z-index: 20; }
.canvas-photo.is-dragging { z-index: 25; cursor: grabbing; }

.canvas-photo__inner {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: var(--ar, 1.6);
  border: 6px solid #f5f5f5;
  border-radius: 0;
  background: #f5f5f5;
  overflow: visible;
  box-shadow:
    0 2px 5px -1px color-mix(in oklab, var(--ink-1) 16%, transparent),
    0 9px 24px -8px color-mix(in oklab, var(--ink-1) 24%, transparent);
  transform: rotate(var(--rot, 0deg));
  transform-origin: top center;   /* scale away from the pin, not from center */
  transition:
    transform 200ms var(--ease-out-quart),
    box-shadow 200ms var(--ease-out-quart);
}

/* Pushpin head holding the photo to the board */
.canvas-photo__inner::before {
  content: "";
  position: absolute;
  top: -7px;
  left: 50%;
  width: 12px;
  height: 12px;
  margin-left: -6px;
  border-radius: 0;
  background: radial-gradient(circle at 35% 30%, #6aa6e8, #2f5fb2 72%);
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.32),
    inset 0 1px 1px rgba(255, 255, 255, 0.45);
  z-index: 4;
}

.canvas-photo__inner img {
  width: 100%;
  height: 100%;
  object-fit: cover;          /* frame matches the real aspect, so nothing crops */
  display: block;
  border-radius: 0;
  user-select: none;
  -webkit-user-drag: none;
}

.canvas-photo:hover .canvas-photo__inner,
.canvas-photo:focus-visible .canvas-photo__inner,
.canvas-photo.is-dragging .canvas-photo__inner {
  transform: rotate(calc(var(--rot, 0deg) * 0.3)) scale(1.07);
  box-shadow:
    0 6px 14px color-mix(in oklab, var(--ink-1) 22%, transparent),
    0 20px 44px -10px color-mix(in oklab, var(--ink-1) 32%, transparent);
}

/* Press feedback — fires on mousedown before drag threshold is crossed.
   :not(.is-dragging) prevents this from overriding the lifted drag state. */
.canvas-photo:active:not(.is-dragging) .canvas-photo__inner {
  transform: rotate(var(--rot, 0deg)) scale(0.96);
  transition: transform 100ms var(--ease-out-quart),
              box-shadow 100ms var(--ease-out-quart);
}

.canvas-photo:focus-visible { outline: none; }

/* Caption tag — fades in under the piece on hover */
.canvas-photo__cap {
  position: absolute;
  left: 50%;
  bottom: -22px;
  transform: translateX(-50%) translateY(4px);
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
  color: #fff;
  background: rgba(20, 16, 12, 0.7);
  padding: 3px 8px;
  border-radius: 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms var(--ease-out-quart), transform 200ms var(--ease-out-quart);
}

.canvas-photo:hover .canvas-photo__cap,
.canvas-photo:focus-visible .canvas-photo__cap {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* ── Fixed chrome — exit + hint ── */
.canvas-exit {
  position: absolute;
  top: var(--pad-page-y);
  left: var(--pad-page-x);
  z-index: 30;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px 7px 11px;
  background: #fff;
  color: #2d241c;
  border: none;
  font-family: var(--font-primary);
  font-weight: 500;
  font-size: var(--t-small);
  line-height: 1;
  letter-spacing: -0.04em;
  text-transform: uppercase;
  white-space: nowrap;
  box-shadow:
    0 1px 2px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 3px 8px -1px color-mix(in oklab, var(--ink-1) 11%, transparent);
  transform: rotate(-1.5deg);
  transition:
    transform 200ms var(--ease-out-quart),
    box-shadow 200ms var(--ease-out-quart);
  cursor: pointer;
}
.canvas-exit:hover {
  transform: rotate(-2.5deg) scale(1.03);
  box-shadow:
    0 3px 5px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 8px 18px -3px color-mix(in oklab, var(--ink-1) 12%, transparent);
}
.canvas-exit:active { transform: rotate(-2.5deg) scale(0.97); }
.canvas-exit svg { transform: translateY(0.5px); }

.canvas-hint {
  position: absolute;
  bottom: 26px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 30;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 6px 13px;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-1);
  background: oklch(99% 0.001 90 / 0.7);
  border: 1px solid color-mix(in oklab, var(--ink-1) 12%, transparent);
  border-radius: 0;
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  pointer-events: none;
  opacity: 0;
  transition: opacity 400ms var(--ease-out-quart) 600ms;   /* fade in shortly after canvas opens */
}
.canvas-hint svg { opacity: 0.6; }

body.is-canvas-open .canvas-hint { opacity: 0.9; }

/* ── Zoom control — bottom-right ── */
.canvas-zoom {
  position: absolute;
  right: var(--pad-page-x);
  bottom: 24px;
  z-index: 30;
  display: inline-flex;
  align-items: stretch;
  background: oklch(99% 0.001 90 / 0.86);
  border: 1px solid color-mix(in oklab, var(--ink-1) 12%, transparent);
  border-radius: 0;
  box-shadow:
    0 1px 2px color-mix(in oklab, var(--ink-1) 18%, transparent),
    0 6px 16px -6px color-mix(in oklab, var(--ink-1) 16%, transparent);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  overflow: hidden;
}
.canvas-zoom__btn,
.canvas-zoom__pct {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  color: var(--ink-1);
  cursor: pointer;
  transition: background 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
}
.canvas-zoom__btn { width: 38px; height: 34px; }
.canvas-zoom__pct {
  min-width: 52px;
  padding: 0 4px;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 600;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  border-left: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  border-right: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
}
.canvas-zoom__btn:hover,
.canvas-zoom__pct:hover { background: color-mix(in oklab, var(--ink-1) 8%, transparent); }
.canvas-zoom__btn:active,
.canvas-zoom__pct:active { background: color-mix(in oklab, var(--ink-1) 14%, transparent); }

/* Lock the page + hide main chrome while the canvas is open */
body.is-canvas-open { overflow: hidden; }
body.is-canvas-open .tbar,
body.is-canvas-open .msg-widget {
  opacity: 0;
  pointer-events: none;
  transition: opacity 240ms var(--ease-out-quart);
}

@media (max-width: 719px) {
  .canvas-exit { top: 16px; left: 16px; }
  .canvas-hint { font-size: var(--t-meta); bottom: 64px; }
  .canvas-zoom { right: 16px; bottom: 16px; }
}

@media (prefers-reduced-motion: reduce) {
  .about-canvas { transition: opacity 200ms var(--ease-out-quart); transform: none; }
  .canvas-photo__inner { transition: box-shadow 200ms var(--ease-out-quart); }
}

/* Terminal cursor — blinks after typewriter finishes */
@keyframes cursor-blink {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0; }
}

.terminal-cursor {
  display: inline-block;
  width: 5.5px;
  height: 10px;
  background: color-mix(in oklab, var(--ink-inv) 42%, transparent);
  margin-left: 2px;
  vertical-align: text-bottom;
  border-radius: 0;
  opacity: 0;
  transition: opacity 100ms linear;
}

.terminal-cursor.is-blinking {
  opacity: 1;
  animation: cursor-blink 1.05s step-end infinite;
}

/* ── Sound toggle — the SoundCloud soundtrack (js/sound.js) ───
   Deliberately NOT in the dock: the dock's volume control governs the
   INTERFACE sounds (js/sfx.js — hover ticks, key clicks, press knocks).
   Two independent systems, two independent switches. This one keeps its
   original corner seat and its original labelled-chip look. */
.top-actions {
  position: fixed;
  /* Fixed (never scrolls away) but seated on the page grid, aligned with
     the content's top-right corner rather than floating in the viewport.
     Overridden below to sit inline with .page-locale's stack instead —
     .top-actions only ever renders inside #view-home. */
  top: var(--pad-page-y);
  right: var(--pad-page-x);
  z-index: 200;
  display: flex;
  align-items: center;
  gap: 6px;
  transition: transform 320ms var(--ease-out-quart), opacity 320ms var(--ease-out-quart);
}
#view-home .top-actions { top: 40px; }
/* Mobile: tucks away while scrolling down (it floats over card art) and
   returns on any scroll up — same behavior the dock has, set by
   js/toolbar.js, which owns both now. */
.top-actions.is-tucked {
  transform: translateY(-160%);
  opacity: 0;
  pointer-events: none;
}
.sound-btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 28px;
  padding: 0 10px;
  background: var(--surface-deep);
  border: none;
  border-radius: var(--radius-control);
  color: var(--ink-1);
  font-family: var(--font-primary);
  font-weight: 400;
  font-size: var(--t-meta);
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition: background 160ms var(--ease-out-quart), opacity 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}
@media (hover: hover) and (pointer: fine) {
  .sound-btn:hover { background: var(--surface-tint); }
}
.sound-btn:active { opacity: 0.45; }
.sound-btn:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
  .top-actions, .sound-btn { transition: none; }
}
.sound-waveform { flex-shrink: 0; display: block; }

.sd {
  fill: currentColor;
  opacity: 0.2;
  transition: opacity 70ms linear;
}

/* ════════════════════════════════════════════════════════════════
   CASE STUDY MODAL
   ════════════════════════════════════════════════════════════════ */

/* Overlay backdrop */
.cs-overlay {
  position: fixed;
  inset: 0;
  z-index: 500;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(16px, 3vw, 40px);
  /* Quiet ink scrim — paper register, not frosted glass */
  background: color-mix(in oklab, var(--ink-1) 32%, transparent);
  backdrop-filter: blur(5px);
  -webkit-backdrop-filter: blur(5px);
  opacity: 0;
  pointer-events: none;
  transition: opacity 380ms var(--ease-out-soft);
  /* Absorbs overscroll on iOS so rubber-band never reaches the body */
  overscroll-behavior: none;
}

.cs-overlay.is-open {
  opacity: 1;
  pointer-events: auto;
}

/* Panel — a flat sheet of paper with an ink rule around it, matching
   the card frames and the case pages (the old frosted-glass register
   predates the zine direction). */
.cs-panel {
  position: relative;
  width: min(680px, 100%);
  /* Sizes to its content — a short mini case is a short sheet. Past
     the cap the reading column scrolls internally. */
  height: auto;
  max-height: min(88vh, 780px);
  display: flex;
  flex-direction: column;
  border-radius: 0;
  /* overflow:clip clips WITHOUT creating a scroll container.
     overflow:hidden would make iOS think this is a blocked scroll target and
     cancel touch-scroll in any child — overflow:clip avoids that entirely. */
  overflow: hidden; /* fallback for Safari < 16 */
  overflow: clip;
  background: var(--surface-base);
  border: 1px solid color-mix(in oklab, var(--ink-1) 32%, transparent);
  box-shadow: 0 30px 90px -30px color-mix(in oklab, var(--ink-1) 45%, transparent);
  opacity: 0;
  transform: translateY(16px) scale(0.985);
  /* Sheet curve — soft start, feather landing. Less displacement +
     an in-out curve reads as the panel settling into place rather
     than leaping at the cursor. */
  transition:
    transform 560ms var(--ease-drawer),
    opacity 380ms var(--ease-out-quart);
}

.cs-overlay.is-open .cs-panel {
  opacity: 1;
  transform: translateY(0) scale(1);
}

/* cs-shape is now only the inner top-edge highlight ring — no fill, no blur */
.cs-shape {
  position: absolute;
  inset: 0;
  background: transparent;
  border-radius: 0;
  pointer-events: none;
  z-index: 0;
}

/* Inner layout — one reading column; the sheet's regions (hero, head,
   meta strip, body) separate with ink hairlines, no sub-panels. */
.cs-layout {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  padding: 0;
  gap: 0;
  box-sizing: border-box;
}

/* Close chip — floats over the hero image, so it gets a solid paper
   fill + hairline (the shared .case-close translucent fill would let
   the artwork bleed through). .cs-panel prefix outranks the shared
   .case-close:hover that appears later in the file. */
.cs-panel .cs-close {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 6;
  margin-bottom: 0;
  cursor: pointer;
  background: var(--surface-base);
  border: 1px solid color-mix(in oklab, var(--ink-1) 22%, transparent);
}
@media (hover: hover) and (pointer: fine) {
  .cs-panel .cs-close:hover {
    background: var(--surface-tint);
    color: var(--ink-1);
  }
}

/* ── Title row — card-title register: narrow caps under the same ink
   distress as the project cards, so the sheet reads as one of them
   pulled out and unfolded. Live-link chip sits opposite. ─────────── */
.cs-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 18px 24px 14px;
  flex-shrink: 0;
}

.cs-title {
  font-family: var(--font-primary);
  font-size: 19px;
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-1);
  filter: url(#distress-strong);
  word-break: break-word;
}

/* ── Meta strip — the case page's hairline meta band, miniaturised:
   blue eyebrows over ink values, one rule above and below. ───────── */
.cs-meta-strip {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  margin: 0 24px;
  padding: 12px 0 14px;
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 12%, transparent);
  border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 12%, transparent);
  flex-shrink: 0;
}

.cs-meta-cell {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin: 0;
}

.cs-meta-label {   /* eyebrow recipe; blue delta re-asserted after the group rule */
}

.cs-meta-value {
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 500;
  line-height: 1.4;
  color: var(--ink-1);
  margin: 0;
  padding: 0;
}

/* Live link — stamped-ink chip seated in the title row (the site's
   loud-label language: ink block, paper text). */
.cs-link-btn {
  position: static;
  flex-shrink: 0;

  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 28px;
  padding: 0 10px;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;

  background: var(--ink-1);
  border: 1px solid var(--ink-1);
  border-radius: var(--radius-control);
  color: var(--ink-inv);
  font-family: var(--font-primary);
  font-weight: 600;
  font-size: var(--t-meta);
  line-height: 1;
  letter-spacing: 0.08em;
  text-transform: uppercase;

  transition: background 160ms var(--ease-out-quart), border-color 160ms var(--ease-out-quart), opacity 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}

@media (hover: hover) and (pointer: fine) {
  .cs-link-btn:hover {
    background: var(--accent-blue);
    border-color: var(--accent-blue);
  }
}

.cs-link-btn:active {
  opacity: 0.45;
  transition-duration: 80ms;
}

/* ── Scrollable content pane ─────────────────────────────────── */
.cs-content-wrap {
  flex: 1;
  min-height: 0;
  position: relative;
  display: flex;
  flex-direction: column;
  overflow: hidden; /* fallback */
  overflow: clip;   /* no scroll container created — iOS can scroll children */
  border-radius: 0;
}

.cs-content {
  /* flex: 1 instead of height: 100% — works reliably on mobile when
     parent height comes from flexbox rather than an explicit value */
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
  scrollbar-color: color-mix(in oklab, var(--ink-1) 18%, transparent) transparent;
  display: flex;
  flex-direction: column;
  /* iOS Safari: momentum scroll + contain overscroll within this pane */
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  touch-action: pan-y;
}

/* ── Staggered content entry ─────────────────────────────────── */
.cs-content-wrap {
  opacity: 0;
  transition: opacity 180ms var(--ease-out-quart);
}

.cs-overlay.is-open .cs-content-wrap {
  opacity: 1;
  transition: opacity 380ms var(--ease-out-quart) 300ms;
}

/* Hero image / placeholder */
.cs-hero {
  position: relative;
  width: 100%;
  flex-shrink: 0;
  height: clamp(190px, 30vh, 250px);
  overflow: hidden;
  border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 14%, transparent);
}

/* No bottom vignette on hero */

.cs-hero-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
}

.cs-hero-placeholder {
  width: 100%;
  height: 100%;
  background: var(--surface-deep);
}

/* Single column, stacked — matches the full case study pages instead of
   the old asymmetric overview/contributions split. */
.cs-body {
  padding: 20px 24px 28px;
  display: grid;
  grid-template-columns: 1fr;
  gap: 24px;
  align-items: start;
}

/* Matches .case-section__heading on case.html — a real heading, not a
   small overline label, so Overview / Contributions read the same way
   here as they do on the full case study pages. */
.cs-section-label {
  font-family: var(--font-primary);
  font-size: clamp(15px, 1.6vw, 18px);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.005em;
  text-transform: none;
  color: var(--ink-1);
  margin-bottom: 12px;
}

.cs-overview-text {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-1);
  opacity: 0.88;
  font-weight: 400;
}

.cs-overview-para {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-1);
  opacity: 0.88;
  font-weight: 400;
  margin-bottom: 20px;
}

.cs-contributions-list {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.cs-contributions-list li {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.65;
  color: var(--ink-2);
  padding-left: 40px;
  position: relative;
}

.cs-contributions-list li::before {
  content: attr(data-n);
  position: absolute;
  left: 0;
  top: 0;
  color: var(--accent-blue);
  font-family: var(--font-primary);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.13em;
  line-height: 1.65;
}

/* Cards variant (js/case.js buildList, variant:'cards'). Same numbered
   items, laid out as plates rather than a stacked run — for a list where
   each line is a discrete outcome that stands on its own rather than a
   point in a sequence, which is what Circles' "My impact" is.

   --surface-card is the plate the objectives cards already use, so this
   reads as the same family rather than a new kind of box; radius stays 0,
   since content never rounds (DESIGN.md). auto-fit lands 2x2 at the case
   column's width and collapses to a single column on a phone without a
   breakpoint of its own. */
.cs-contributions-list--cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--space-3);
}
.cs-contributions-list--cards li {
  background: var(--surface-card);
  padding: var(--space-5) var(--space-4);
  /* The stacked list hangs its numeral in a 40px gutter; on a plate there
     is no gutter to hang into, so the number sits above the line instead
     and the text reclaims the full width of the card. */
  padding-left: var(--space-4);
}
.cs-contributions-list--cards li::before {
  position: static;
  display: block;
  margin-bottom: var(--space-2);
  line-height: 1;
}

/* Illustrated cards (items given as { art, title, text }): each outcome
   gets a loose black-ink drawing from DOODLES, a short title, and its
   line, after the hand-drawn service cards in editorial product sites.
   Ink is --ink-1 (structure, like the timeline spine), not the accent:
   four blue drawings would stop the blue being sparing. The drawing
   draws itself on with the persona marks' pen (initDoodles). No
   numerals: the drawing does the job the number did. */
.case .cs-contributions-list--art {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 719px) {
  .case .cs-contributions-list--art { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 419px) {
  .case .cs-contributions-list--art { grid-template-columns: 1fr; }
}
/* A three-card set spans the column in thirds instead of leaving a dead
   fourth slot; below 720px it stacks rather than stranding card three
   alone on a 2-up row. */
.case .cs-contributions-list--art:has(> .cs-impact:nth-child(3):last-child) {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (max-width: 719px) {
  .case .cs-contributions-list--art:has(> .cs-impact:nth-child(3):last-child) { grid-template-columns: 1fr; }
}

/* Sideways screen slider (image section, layout: 'scroll'). Each frame
   is a specimen figure, the same frame and caption as the Still case's
   feature GIFs. Each frame is 5/6 of the column; the active one is centred where it can be,
   so neighbours peek in at the sides. The track moves by transform on
   --ease-out-expo, never native scroll. */
.case .cs-shot-viewport {
  position: relative;
  overflow: hidden;
}
/* Side edges: the page's bottom-edge blur (.case-edge-blur) turned
   sideways, strongest at the edge and easing out inward. Each shows
   only while a screen actually sits under it. */
.case .cs-shot-edge {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 56px;
  z-index: 2;
  pointer-events: none;
  opacity: 0;
  transition: opacity 320ms var(--ease-out-soft);
}
.case .cs-shot-edge.is-on { opacity: 1; }
.case .cs-shot-edge--l { left: 0; --dir: to right; }
.case .cs-shot-edge--r { right: 0; --dir: to left; }
.case .cs-shot-edge .pb { position: absolute; inset: 0; -webkit-mask-image: var(--m); mask-image: var(--m); }
.case .cs-shot-edge .pb2 { -webkit-backdrop-filter: blur(1.5px); backdrop-filter: blur(1.5px); --m: linear-gradient(var(--dir), #000 0%, #000 55%, transparent 80%); }
.case .cs-shot-edge .pb3 { -webkit-backdrop-filter: blur(3px);   backdrop-filter: blur(3px);   --m: linear-gradient(var(--dir), #000 0%, #000 35%, transparent 60%); }
.case .cs-shot-edge .pb4 { -webkit-backdrop-filter: blur(6px);   backdrop-filter: blur(6px);   --m: linear-gradient(var(--dir), #000 0%, #000 20%, transparent 42%); }
.case .cs-shot-edge .pb5 { -webkit-backdrop-filter: blur(12px);  backdrop-filter: blur(12px);  --m: linear-gradient(var(--dir), #000 0%, #000 8%, transparent 25%); }
.case .cs-shot-row {
  display: flex;
  gap: var(--space-4);
  will-change: transform;
}
.case .cs-shot {
  flex: 0 0 calc((100% - var(--space-4)) * 5 / 6);
  margin: 0;
}
/* Slides reuse the specimen figure (frame, shadow, bold-lead caption);
   the viewport leaves room below so the specimen's drop shadow isn't
   clipped by overflow. */
.case .cs-shot-viewport { padding-bottom: 32px; margin-bottom: -32px; }
/* Scroll-linked: .cs-shot-carousel is made tall in initShotDots and
   this block (section heading, caption, screens, dots) sticks while page
   scroll slides the track; its `top` is set in JS to centre it. The
   heading moved in here keeps its section-head spacing. */
.case .cs-shot-sticky {
  position: sticky;
  top: var(--space-6);
}
/* Progress dots: small black ink controls (--radius-control) set close
   together; the active one stretches into a short solid bar. */
.case .cs-shot-dots {
  display: flex;
  justify-content: center;
  gap: 0;
  margin-top: var(--space-3);
}
.case .cs-shot-dot {
  width: 14px;
  height: 20px;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: pointer;
  display: grid;
  place-items: center;
}
.case .cs-shot-dot::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: var(--radius-control);
  background: color-mix(in oklab, var(--ink-1) 22%, transparent);
  transition: width 260ms var(--ease-out-quart), background-color 260ms var(--ease-out-quart);
}
.case .cs-shot-dot { transition: width 260ms var(--ease-out-quart); }
.case .cs-shot-dot:hover::before { background: color-mix(in oklab, var(--ink-1) 55%, transparent); }
.case .cs-shot-dot.is-active { width: 22px; }
.case .cs-shot-dot.is-active::before { width: 16px; background: var(--ink-1); }
.case .cs-shot-dot:focus-visible { outline: 1px solid var(--ink-1); outline-offset: 0; }
@media (prefers-reduced-motion: reduce) {
  .case .cs-shot-edge { transition: none; }
  .case .cs-shot-dot, .case .cs-shot-dot::before { transition: none; }
}
/* --surface-tint, one step deeper than the plain cards' --surface-card,
   so four narrow cards in a row still read as filled gray plates. */
.case .cs-contributions-list--art .cs-impact {
  display: flex;
  flex-direction: column;
  padding: var(--space-4);
  background: var(--surface-tint);
}
.case .cs-contributions-list--art .cs-impact::before { display: none; }
.case .cs-impact__art {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 92px;
  margin-bottom: var(--space-3);
}
.case .cs-impact__art .cs-doodle {
  position: static;
  width: 84px;
  height: 84px;
  transform: none;
  color: var(--ink-1);
}
.case .cs-impact__art .cs-doodle__stroke { stroke-width: 2.3; }
.case .cs-impact__title {
  margin: 0 0 var(--space-2);
  font-family: var(--font-primary);
  font-size: 14px;
  font-weight: 600;
  line-height: 1.2;
  color: var(--ink-1);
}
.case .cs-impact__text {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.55;
  color: var(--ink-3);
}
.case .cs-impact__text strong { color: var(--ink-1); font-weight: 600; }

/* ── Deep Dive extended content ───────────────────────────────── */
.cs-deep-dive-body {
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 9%, transparent);
  padding: 32px 32px 48px;
  display: flex;
  flex-direction: column;
  gap: 40px;
}

.cs-deep-dive-body[hidden] { display: none; }
.cs-body[hidden] { display: none !important; }

.cs-deep-section {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.cs-deep-heading {
  font-family: var(--font-primary);
  font-weight: 400;
  font-size: clamp(14px, 1.5vw, 18px);
  line-height: 1.12;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  color: var(--ink-2);
  filter: url(#distress-strong);
}

.cs-deep-heading::after {
  content: '';
}

.cs-deep-paragraphs {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.cs-deep-text {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-1);
  font-weight: 400;
}

/* Highlighted figures woven into body prose — the key numbers lift in the
   accent (ink alone can't pull them off the ink-1 body). Data only, sparingly. */
.cs-fig {
  color: var(--accent-blue);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* ── Reader highlighter (js/highlight.js, full-page cases only) ──
   The one sanctioned translucent blue fill in the system (DESIGN.md):
   a soft azure marker, mixed in srgb so it never drifts purple. */
/* Registered so the wash colour can interpolate inside the gradient below;
   without @property a custom prop would snap instead of transition. */
@property --cs-hl-wash {
  syntax: '<color>';
  inherits: false;
  initial-value: transparent;
}
/* The wash is a solid gradient "image" rather than a background-color, so its
   width is animatable: js/highlight.js releases .is-inking and the marker
   strokes in left to right. Restored highlights skip the class and are just
   present, since only YOUR stroke is worth confirming. */
.case mark.cs-hl {
  /* The ink: colour comes from --hl-ink-c (azure by default; the
     [data-c] variants below swap it for a palette token), strength
     from --hl-ink-a — and BOTH the strength and the geometry below are
     modulated per stroke by js/highlight.js:
       --hl-load   ink load, ~0.82-1.18 (no two passes carry the same)
       --hl-tilt   swipe angle, ~176-180deg
       --hl-gap    air at the top edge, 0-6%
       --hl-thick  stroke thickness (vertical padding)
       --hl-tip-l/r  end overshoots, uneven left vs right
     Every fragment of one stroke shares one set; strokes differ. */
  --hl-ink-c: var(--accent-blue);
  --hl-ink-a: 18%;
  --hl-ink-a-hot: 30%;
  --cs-hl-wash: color-mix(in srgb, var(--hl-ink-c) calc(var(--hl-ink-a) * var(--hl-load, 1)), transparent);
  background-color: transparent;
  /* Bottom-weighted wash — faint along the top edge, ink settling toward the
     baseline — so the stroke reads as one pass of a chisel tip, not a filled
     box. Every stop derives PURELY from the single registered var (never a
     standalone accent term): hover / dark / removal all just retint the var,
     and the fade-out in js/highlight.js drives it to transparent — a stop
     with its own opaque component would survive that fade as a residue line. */
  /* Three layers, all derived purely from the wash var (see above):
     1. dry-tip streaks — faint darker micro-bands running along the
        stroke, the fibre texture a chisel tip drags through its own wet
        ink; barely-there on purpose;
     2. a press blotch where the tip first lands — box-decoration-break
        clone gives every wrapped line its own press, like re-inking at
        each pass;
     3. the swipe itself, TILTED by --hl-tilt with the randomized top
        air (--hl-gap) and ink pooling toward the baseline — so no two
        strokes sit at the same angle or thickness and none reads as a
        filled box. */
  background-image:
    repeating-linear-gradient(
      calc(var(--hl-tilt, 178deg) + 0.8deg),
      transparent 0 3px,
      color-mix(in srgb, var(--cs-hl-wash) 13%, transparent) 3px 4px
    ),
    linear-gradient(
      100deg,
      color-mix(in srgb, var(--cs-hl-wash) 50%, transparent) 0%,
      transparent 24%
    ),
    linear-gradient(
      var(--hl-tilt, 178deg),
      transparent var(--hl-gap, 2%),
      color-mix(in srgb, var(--cs-hl-wash) 40%, transparent) calc(var(--hl-gap, 2%) + 10%),
      color-mix(in srgb, var(--cs-hl-wash) 82%, transparent) 48%,
      var(--cs-hl-wash) 86%,
      color-mix(in srgb, var(--cs-hl-wash) 68%, transparent) 100%
    );
  background-repeat: no-repeat;
  background-size: 100% 100%, 100% 100%, 100% 100%;
  color: inherit;
  padding: var(--hl-thick, 0.08em) 0;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  cursor: pointer;
  transition:
    background-size 240ms var(--ease-out-quart),
    --cs-hl-wash 160ms var(--ease-out-quart);
}
.case mark.cs-hl.is-inking { background-size: 0% 100%, 0% 100%, 0% 100%; }
/* Stroke ends overshoot the text by a fraction of an em — the sweep starts
   just before the first letter and runs just past the last, like a real
   marker pass, and the two ends are deliberately UNEVEN (randomized per
   stroke). js/highlight.js tags only the first and last fragment of each
   stroke (a passage often spans several <mark>s around links/italics), so
   interior seams stay flush and never double-wash. */
.case mark.cs-hl.is-tip-l { padding-left: var(--hl-tip-l, 0.22em); margin-left: calc(-1 * var(--hl-tip-l, 0.22em)); }
.case mark.cs-hl.is-tip-r { padding-right: var(--hl-tip-r, 0.22em); margin-right: calc(-1 * var(--hl-tip-r, 0.22em)); }
/* Fresh ink sits a shade heavier for a beat, then settles into the paper:
   js/highlight.js drops .is-fresh once the stroke lands and the registered
   var transitions the tint down. Restored highlights never get the class —
   only a stroke you just drew is wet. */
.case mark.cs-hl.is-fresh { --cs-hl-wash: color-mix(in srgb, var(--hl-ink-c) calc(var(--hl-ink-a-hot) * var(--hl-load, 1)), transparent); }

/* ── The palette — user-picked marker inks (see the tokens; part of the
   sanctioned highlighter exception). Only colour and strength change per
   ink; all the stroke behavior above is shared. Alphas are tuned per ink
   because equal alpha does not read equal — yellow needs far more body
   than azure to register on paper, and less lift in the dark. */
.case mark.cs-hl[data-c="sun"]  { --hl-ink-c: var(--hl-sun);  --hl-ink-a: 46%; --hl-ink-a-hot: 66%; }
.case mark.cs-hl[data-c="rose"] { --hl-ink-c: var(--hl-rose); --hl-ink-a: 26%; --hl-ink-a-hot: 40%; }
.case mark.cs-hl[data-c="mint"] { --hl-ink-c: var(--hl-mint); --hl-ink-a: 27%; --hl-ink-a-hot: 42%; }

/* Press feedback on the zoomable images (every one opens the lightbox, which
   takes 220ms to fade in, so the press is what confirms the click landed).
   0.99, not 0.98: these are large elements, per the scale ladder (cards 0.98,
   rows 0.99, chips 0.97). Each transition re-states the night-dim filter and
   opacity it would otherwise clobber. Scoped to .case so the modal keeps its
   own treatment. The lightbox close is extended in place further down the
   sheet, since a same-specificity rule declared here would lose the cascade. */
.case .cs-landing-img,
.case .cs-specimen__img {
  transition:
    transform 160ms var(--ease-out-quart),
    filter 260ms var(--ease-out-quart),
    opacity 260ms var(--ease-out-quart);
}
.case .cs-landing-img:active,
.case .cs-specimen__img:active { transform: scale(0.99); }
@media (hover: hover) and (pointer: fine) {
  .case mark.cs-hl:hover { --cs-hl-wash: color-mix(in srgb, var(--hl-ink-c) calc(var(--hl-ink-a-hot) * var(--hl-load, 1)), transparent); }
}
/* Dark mode: light-theme alphas over near-black composite to almost
   nothing, so every ink runs heavier — and azure swaps to the soft
   token. Only the ink VARS change here; the wash expressions above are
   shared, so fresh/hover need no dark duplicates. The [data-c] rules
   re-state their colour because this base rule out-specifies the light
   palette rules; the tokens themselves are already dark-lifted. */
html[data-theme="dark"] .case mark.cs-hl {
  --hl-ink-c: var(--accent-blue-soft);
  --hl-ink-a: 45%;
  --hl-ink-a-hot: 58%;
}
html[data-theme="dark"] .case mark.cs-hl[data-c="sun"]  { --hl-ink-c: var(--hl-sun);  --hl-ink-a: 30%; --hl-ink-a-hot: 44%; }
html[data-theme="dark"] .case mark.cs-hl[data-c="rose"] { --hl-ink-c: var(--hl-rose); --hl-ink-a: 34%; --hl-ink-a-hot: 48%; }
html[data-theme="dark"] .case mark.cs-hl[data-c="mint"] { --hl-ink-c: var(--hl-mint); --hl-ink-a: 32%; --hl-ink-a-hot: 46%; }

/* Full-page case studies only. highlight.js gates on #case-root existing,
   but the persistent-navigation shell (js/portfolio-nav.js) gave index.html
   its own hidden #case-root, so that gate now passes on the home page too
   and the pen was rendering there. Tie visibility to the same [hidden]
   attribute portfolio-nav already toggles: case showing = pen available,
   home showing = no pen, no selection chip. Standalone case.html and the
   prerendered <slug>.html pages have an unhidden #case-root, so they are
   unaffected. This follows the nav automatically, with nothing to keep in
   sync on the JS side. */
body:has(#case-root[hidden]) .hl-chip {
  display: none;
}
/* (The pen no longer needs this gate: it lives in the dock's case-only
   cell, which collapses on the home view — see THE DOCK below.) */

/* The ink tray — dock chrome, not the hand-drawn register (the old
   sketched paper tab clashed with the flat bar it floats over): the
   bar's own surface language at satellite size — flat --surface-deep,
   --radius-dock corner, the bar's drop shadow, 3px padding concentric
   with the dabs' --radius-control corners (3 + 3 = 6). Opens ONLY
   above the dock pen (js/highlight.js showTray) — there is no
   selection-triggered popup. Enters like the dock's tooltips: a short
   rise on the same tokens, no tilt, no scale theatrics. left/bottom
   are set inline (js/highlight.js), always centered over the pen. */
.hl-chip {
  position: fixed;
  top: auto;
  z-index: 400;
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 3px;
  background: var(--surface-deep);
  border: 0;
  border-radius: var(--radius-dock);
  box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.08), 0 10px 24px -12px rgba(0, 0, 0, 0.14);
  opacity: 0;
  transform: translate(-50%, 6px);
  pointer-events: none;
  transition: opacity 160ms var(--ease-out-quart), transform 260ms var(--ease-out-expo);
}
html[data-theme="dark"] .hl-chip {
  box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.45), 0 12px 28px -10px rgba(0, 0, 0, 0.55);
}
.hl-chip.is-on {
  opacity: 1;
  transform: translate(-50%, 0);
  pointer-events: auto;
}
/* Press feedback lives on the dabs — the container itself is not a button. */

/* ── The ink dabs — pick a marker colour ─────────────────────────
   Four dabs (azure / sun / rose / mint), each a little test swipe of
   its ink, seated in the tray like the dock's own controls. Tapping a
   dab picks the ink and remembers it (js/highlight.js); the pen tip
   and the pen-mode selection preview follow via --hl-cur, mapped from
   body[data-hl-ink]. */
body[data-hl-ink="sun"]  { --hl-cur: var(--hl-sun); }
body[data-hl-ink="rose"] { --hl-cur: var(--hl-rose); }
body[data-hl-ink="mint"] { --hl-cur: var(--hl-mint); }
.hl-dab {
  position: relative;
  width: 26px;
  height: 24px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 0;
  padding: 0;
  border-radius: var(--radius-control);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background 140ms var(--ease-out-quart), transform 140ms var(--ease-out-quart);
}
.hl-dab svg { width: 14px; height: 10px; overflow: visible; }
/* Same half-strength test swipe as the chip's swatch; the ACTIVE ink
   writes at full strength — the pen you are actually holding is wetter. */
.hl-dab path {
  fill: none;
  stroke: color-mix(in srgb, var(--dab-ink, var(--accent-blue)) 55%, transparent);
  stroke-width: 5;
}
.hl-dab[aria-pressed="true"] { background: color-mix(in oklab, var(--ink-1) 8%, transparent); }
.hl-dab[aria-pressed="true"] path { stroke: var(--dab-ink, var(--accent-blue)); }
.hl-dab:active { transform: scale(0.88); }
@media (hover: hover) and (pointer: fine) {
  .hl-dab:hover { background: color-mix(in oklab, var(--ink-1) 7%, transparent); }
}

/* The pen — a dock control now (js/highlight.js mounts it into the dock's
   case-only slot, #tbar-hl-slot). Click = pick it up (pen mode):
   selections ink instantly, no popover step. Its base look comes from
   .tbar__btn (see THE DOCK); only the pen's own states live here. */
.hl-pen .hl-pen__icon { display: block; transition: transform 260ms var(--ease-out-quart); }
@media (hover: hover) and (pointer: fine) {
  .hl-pen:hover .hl-pen__icon { transform: rotate(-14deg) translateY(-1px); }
}
/* Picked up: the sanctioned azure wash fills the control, pen tilted
   ready to write, and a small uncap pop so it feels physical rather than
   a state swap. */
.hl-pen.is-inked {
  background: color-mix(in srgb, var(--hl-cur, var(--accent-blue)) 12%, transparent);
  color: var(--hl-cur, var(--accent-blue));
  animation: hl-uncap 340ms var(--ease-out-expo);
}
.hl-pen.is-inked .hl-pen__icon { transform: rotate(-38deg); }
@keyframes hl-uncap {
  0%   { transform: scale(0.94); }
  45%  { transform: scale(1.08); }
  100% { transform: scale(1); }
}
/* A tiny scribble each time the pen writes. */
.hl-pen.did-ink .hl-pen__icon { animation: hl-scribble 420ms var(--ease-out-quart); }
@keyframes hl-scribble {
  0%   { transform: rotate(-38deg); }
  30%  { transform: rotate(-54deg) translate(-2px, 1px); }
  62%  { transform: rotate(-28deg) translate(2px, -1px); }
  100% { transform: rotate(-38deg); }
}
/* First-visit tag, also shown on hover — a jotted margin note, not a
   boxed label: lowercase italic body face carrying the marker's own wash,
   so the hint is literally a highlight of the words "highlight anything".
   It anchors to the DOCK (js/highlight.js appends it to .tbar, outside
   the width-clipping cells), floating above the bar's right end; :has()
   keys it to the pen's own state classes, and the case-context gate keeps
   the first-visit timer from flashing it over the home view. */
/* Redesigned July 2026 from the italic "jotted margin note" (azure
   wash, rotated) to the same stamped-ink chip the dock tooltips and the
   cursor tip use — one tooltip language everywhere. This one is the
   one-time first-visit nudge only; plain hovers get the standard
   "Highlighter" dock tooltip, so the old :has(:hover) trigger is gone. */
.hl-pen__hint {
  position: absolute;
  right: 8px;
  bottom: calc(100% + 10px);
  padding: 5px 9px 4px;
  background: var(--ink-1);
  color: var(--ink-inv);
  font-family: var(--font-primary);
  font-size: var(--t-micro);
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  white-space: nowrap;
  opacity: 0;
  translate: 0 4px;
  pointer-events: none;
  transition:
    opacity 200ms var(--ease-out-quart),
    translate 240ms var(--ease-out-expo);
}
.tbar[data-context="case"]:has(.hl-pen.show-hint) .hl-pen__hint {
  opacity: 1;
  translate: 0 0;
}
/* Pen mode: the selection itself previews the marker color. */
/* Holding the pen, the cursor IS the marker — a small chisel-tip glyph
   with its hotspot at the tip, instead of the text caret that promised
   "selecting" rather than "highlighting". White casing + ink cap + azure
   tip reads on both themes, so one asset serves light and dark. Falls
   back to the text caret where custom cursors are unsupported. */
body.hl-pen-mode .case,
body.hl-pen-mode .case * {
  cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cg transform='rotate(45 12 12)'%3E%3Cpolygon points='12,21.5 9.8,16.5 14.2,16.5' fill='%230033ff' stroke='%23111111' stroke-width='1.2' stroke-linejoin='round'/%3E%3Crect x='9.8' y='7' width='4.4' height='9.6' fill='%23ffffff' stroke='%23111111' stroke-width='1.2'/%3E%3Crect x='9.8' y='3.4' width='4.4' height='3.6' fill='%23111111' stroke='%23111111' stroke-width='1.2'/%3E%3C/g%3E%3C/svg%3E") 5 19, text;
}
/* The marker cursor's tip dips in the chosen ink (one URI per ink —
   cursors cannot read custom properties; the light hexes read on both
   themes since the cursor carries its own white casing). */
body.hl-pen-mode[data-hl-ink="sun"] .case,
body.hl-pen-mode[data-hl-ink="sun"] .case * {
  cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cg transform='rotate(45 12 12)'%3E%3Cpolygon points='12,21.5 9.8,16.5 14.2,16.5' fill='%23ffd400' stroke='%23111111' stroke-width='1.2' stroke-linejoin='round'/%3E%3Crect x='9.8' y='7' width='4.4' height='9.6' fill='%23ffffff' stroke='%23111111' stroke-width='1.2'/%3E%3Crect x='9.8' y='3.4' width='4.4' height='3.6' fill='%23111111' stroke='%23111111' stroke-width='1.2'/%3E%3C/g%3E%3C/svg%3E") 5 19, text;
}
body.hl-pen-mode[data-hl-ink="rose"] .case,
body.hl-pen-mode[data-hl-ink="rose"] .case * {
  cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cg transform='rotate(45 12 12)'%3E%3Cpolygon points='12,21.5 9.8,16.5 14.2,16.5' fill='%23ff4d94' stroke='%23111111' stroke-width='1.2' stroke-linejoin='round'/%3E%3Crect x='9.8' y='7' width='4.4' height='9.6' fill='%23ffffff' stroke='%23111111' stroke-width='1.2'/%3E%3Crect x='9.8' y='3.4' width='4.4' height='3.6' fill='%23111111' stroke='%23111111' stroke-width='1.2'/%3E%3C/g%3E%3C/svg%3E") 5 19, text;
}
body.hl-pen-mode[data-hl-ink="mint"] .case,
body.hl-pen-mode[data-hl-ink="mint"] .case * {
  cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cg transform='rotate(45 12 12)'%3E%3Cpolygon points='12,21.5 9.8,16.5 14.2,16.5' fill='%2316b364' stroke='%23111111' stroke-width='1.2' stroke-linejoin='round'/%3E%3Crect x='9.8' y='7' width='4.4' height='9.6' fill='%23ffffff' stroke='%23111111' stroke-width='1.2'/%3E%3Crect x='9.8' y='3.4' width='4.4' height='3.6' fill='%23111111' stroke='%23111111' stroke-width='1.2'/%3E%3C/g%3E%3C/svg%3E") 5 19, text;
}
/* Selecting text WITH THE PEN PICKED UP previews the current ink — the
   site's default ink-block selection read as a black bar and gave no
   hint a highlight was about to land. Scoped to hl-pen-mode: with the
   pen down, selecting is just selecting, so it keeps the ordinary
   selection colour rather than promising an action that won't happen. */
body.hl-pen-mode .case .case-section__body ::selection {
  background: color-mix(in srgb, var(--hl-cur, var(--accent-blue)) 24%, transparent);
  color: inherit;
}
html[data-theme="dark"] body.hl-pen-mode .case .case-section__body ::selection {
  background: color-mix(in srgb, var(--hl-cur, var(--accent-blue-soft)) 34%, transparent);
}
@media (prefers-reduced-motion: reduce) {
  .hl-chip, .hl-pen, .hl-pen__icon { transition: none; }
  .hl-dab { transition: none; }
  .hl-pen.did-ink .hl-pen__icon { animation: none; }
  .hl-pen.is-inked { animation: none; }
  /* The hint keeps its fade, drops the rise. */
  .hl-pen__hint { transition: opacity 160ms linear; translate: 0 0; }
  /* Gentler, not zero: the stroke still reads as a stroke, just brisk. */
  .case mark.cs-hl { transition: background-size 120ms linear, --cs-hl-wash 120ms linear; }
  .case .cs-landing-img:active,
  .case .cs-specimen__img:active { transform: none; }
  /* The lightbox close is declared later in this sheet; a media query adds no
     specificity, so its opt-out is co-located with it. */
}

/* ════════════════════════════════════════════════════════════════
   THE DOCK — the floating navbar/toolbar (js/toolbar.js, markup from
   PortfolioUI.toolbar in js/components.js). The second named radius
   exception (DESIGN.md): ONE soft-cornered glass surface
   (--radius-dock) holding rounded-square controls (--radius-control),
   segmented by hairlines — not separate pill islands. Context-aware:
   [data-context] on the bar collapses the cells that do not belong to
   the current view. The page's chat widget (#msg-widget) is adopted
   into the bar by js/toolbar.js: its input row becomes the bar's
   "Ask anything" segment and its conversation floats above the bar.
   ════════════════════════════════════════════════════════════════ */
.tbar {
  position: fixed;
  /* Centered with inset-0 + auto margins + fit-content, NOT left:50% +
     translateX(-50%): a fixed element's shrink-to-fit width resolves
     BEFORE transforms, capped at (containing block - left) — with
     left:50% the bar could never exceed HALF the viewport. Desktop
     never hit the cap; on a 375px phone the open chat bar got crushed
     to ~187px while its field track wanted 303px, and the input row
     overflowed the bar. This centering has no such cap. */
  left: 0;
  right: 0;
  margin-inline: auto;
  width: fit-content;
  bottom: calc(18px + env(safe-area-inset-bottom, 0px) + var(--tbar-kb-lift, 0px));
  z-index: 400;
  display: flex;
  align-items: center;
  gap: 0;
  padding: 3px;
  max-width: calc(100vw - 16px);
  /* The corner sound chip's surface language, for one chrome family:
     flat --surface-deep, no border, no glass, no shadow. The tokens
     flip it for dark mode on their own. */
  background: var(--surface-deep);
  border-radius: var(--radius-dock);
  /* One clean drop — the flat bar disappeared against same-color page
     regions mid-scroll without it. Deeper in dark (below), where a
     light shadow reads as nothing. */
  box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.08), 0 10px 24px -12px rgba(0, 0, 0, 0.14);
  /* The chat morph animates width here and grid tracks inside — layout,
     not compositor. `layout` containment scopes those recalcs to the bar
     so the rest of the document is never in the loop. NOT `paint` /
     `content` / `strict`: those clip overflow, which would cut off the
     conversation card and the tooltips that sit outside the bar's box. */
  contain: layout;
  animation: tbar-rise 640ms var(--ease-out-expo) 240ms backwards;
  /* border-radius carries a small delay in THIS direction only (card
     detaching): the corners must not round back while the exiting card
     still covers them. The :has() attached state below zeroes it, so
     squaring on open is immediate. */
  /* Coming BACK is expo, not quart: nearly all the distance is covered in
     the first breath and the rest is a settle, so a summoned bar answers
     the moment you ask rather than easing up politely. The exit (in
     .is-tucked below) keeps quart and is slower — leaving should be calm,
     arriving should be eager. Opacity is quick here so it is solid for
     the settle instead of still fading under the cursor. */
  transition:
    transform 460ms var(--ease-out-expo),
    opacity 200ms var(--ease-out-quart),
    border-radius 240ms var(--ease-out-quart) 160ms;
}
@keyframes tbar-rise {
  from { transform: translateY(16px); opacity: 0; }
  to   { transform: translateY(0);    opacity: 1; }
}
html.is-return .tbar { animation: none; }
html[data-theme="dark"] .tbar {
  box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.45), 0 12px 28px -10px rgba(0, 0, 0, 0.55);
}

/* ── Edge blur (Sept 2026; home and case pages) ────────────────────
   A progressive blur, lifted from mikes.cv's .folio-footer-fx: five
   stacked backdrop-filter layers, each stronger (0.6 → 12px) and masked
   to a shorter band nearer the bottom edge, so the blur deepens
   smoothly instead of stepping. No paint of its own (mikes.cv adds a
   page-colour tint on top; left out on purpose so nothing whitens), so
   it works in either theme. Markup: PortfolioUI.edgeBlur, mounted as a
   later sibling of #tbar in js/toolbar.js. z-index sits under the dock's
   400 so the bar stays crisp on top. No scroll JS. */
.case-edge-blur {
  display: none;
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  height: 56px;
  z-index: 350;
  pointer-events: none;
}
/* The fade rides each .pb layer, never the strip itself: opacity < 1 on
   the parent makes it a backdrop root, so its children would blur only
   the (empty) strip and the whole effect would blink out on the first
   frame instead of easing. On the layer that owns the backdrop-filter,
   opacity fades the blurred result smoothly. */
.case-edge-blur .pb { transition: opacity 250ms var(--ease-out-soft); }
.case-edge-blur.is-off .pb { opacity: 0; }
@media (prefers-reduced-motion: reduce) { .case-edge-blur .pb { transition: none; } }
.case-edge-blur .pb { position: absolute; inset: 0; }
.case-edge-blur .pb1 { -webkit-backdrop-filter: blur(0.6px); backdrop-filter: blur(0.6px); --m: linear-gradient(to top, #000 0%, #000 92%, transparent 100%); }
.case-edge-blur .pb2 { -webkit-backdrop-filter: blur(1.5px); backdrop-filter: blur(1.5px); --m: linear-gradient(to top, #000 0%, #000 55%, transparent 80%); }
.case-edge-blur .pb3 { -webkit-backdrop-filter: blur(3px);   backdrop-filter: blur(3px);   --m: linear-gradient(to top, #000 0%, #000 35%, transparent 60%); }
.case-edge-blur .pb4 { -webkit-backdrop-filter: blur(6px);   backdrop-filter: blur(6px);   --m: linear-gradient(to top, #000 0%, #000 20%, transparent 42%); }
.case-edge-blur .pb5 { -webkit-backdrop-filter: blur(12px);  backdrop-filter: blur(12px);  --m: linear-gradient(to top, #000 0%, #000 8%, transparent 25%); }
.case-edge-blur .pb { -webkit-mask-image: var(--m); mask-image: var(--m); }
/* Performance: every layer is a backdrop blur the browser redraws on each
   scroll frame. The 0.6px layer is all but invisible, so it is dropped;
   on phones (weaker GPUs, where scroll jank shows first) each strip
   keeps just its middle layer. */
.case-edge-blur .pb1 { display: none; }
@media (max-width: 719px) {
  .case-edge-blur .pb2,
  .case-edge-blur .pb4,
  .case-edge-blur .pb5 { display: none; }
}
/* Top edge: same 56px height as the bottom, the same layers with every mask flipped so the blur
   is strongest against the top of the viewport. Never switched off for
   the footer (toolbar.js only toggles the first, bottom strip). */
.case-edge-blur--top { top: 0; bottom: auto; }
.case-edge-blur--top .pb { -webkit-mask-image: var(--m-top); mask-image: var(--m-top); }
.case-edge-blur--top .pb1 { --m-top: linear-gradient(to bottom, #000 0%, #000 92%, transparent 100%); }
.case-edge-blur--top .pb2 { --m-top: linear-gradient(to bottom, #000 0%, #000 55%, transparent 80%); }
.case-edge-blur--top .pb3 { --m-top: linear-gradient(to bottom, #000 0%, #000 35%, transparent 60%); }
.case-edge-blur--top .pb4 { --m-top: linear-gradient(to bottom, #000 0%, #000 20%, transparent 42%); }
.case-edge-blur--top .pb5 { --m-top: linear-gradient(to bottom, #000 0%, #000 8%, transparent 25%); }
/* Home and case pages alike (home added on request, Sept 2026). */
#tbar ~ .case-edge-blur { display: block; }

/* Conversation attached: the panel sits flush on the bar's top edge and
   the two read as ONE card — the bar keeps only its bottom corners and
   the radius morph rides the same transition, so open/close melts
   instead of snapping. */
.tbar:has(.msg-widget__panel.is-open) {
  border-radius: 0 0 var(--radius-dock) var(--radius-dock);
  transition:
    transform 320ms var(--ease-out-quart),
    opacity 320ms var(--ease-out-quart),
    border-radius 200ms var(--ease-out-quart);
}
/* Tuck: the mobile scroll-down hide, and the street-footer hide (both
   set by js/toolbar.js). The bar SLIDES past the screen edge rather than
   blinking out, so leaving and returning read as one object moving.
   Travel is its own height plus a little more than the 18px it floats
   above the edge: exactly enough to clear the screen and no further,
   because shorter travel is a faster, more responsive return.
   Opacity trails the slide (delayed, and done before the travel is)
   instead of racing it — fading in step made the bar vanish mid-flight,
   which read as a blink rather than a slide. */
.tbar.is-tucked {
  transform: translateY(calc(100% + 24px));
  opacity: 0;
  pointer-events: none;
  transition:
    transform 360ms var(--ease-out-quart),
    opacity 240ms var(--ease-out-quart) 120ms;
}
/* Reduced motion keeps the STATE and drops the travel: the bar fades out
   where it stands rather than flying off the edge. It is still fully
   hidden and still pointer-events: none, so the footer underneath is
   just as reachable — only the journey is gone. */
@media (prefers-reduced-motion: reduce) {
  .tbar,
  .tbar.is-tucked {
    transition: opacity 140ms linear;
  }
  .tbar.is-tucked { transform: none; }
}
/* Hairline segment dividers. The ones ahead of contextual segments live
   inside the collapsing cell, so they vanish with their segment. */
.tbar__sep {
  flex: none;
  width: 1px;
  height: 18px;
  margin: 0 2px;
  background: color-mix(in oklab, var(--ink-1) 9%, transparent);
}
html[data-theme="dark"] .tbar__sep {
  background: color-mix(in oklab, var(--ink-1) 13%, transparent);
}

/* The tools group — a plain run of controls, no chrome of its own. */
.tbar__tools {
  display: flex;
  align-items: center;
}

/* Controls: rounded-fluid squares, quiet until touched. 40px, icons
   19px (37 and 17 until Oct 2026, when a critique found them too small
   to hit; the 30px pass before that read dainty). One small step, so
   the bar stays an island, not a sliver, and not a toolbar slab. */
.tbar__btn {
  position: relative;
  width: 40px;
  height: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  background: transparent;
  border: 0;
  border-radius: var(--radius-control);
  color: var(--ink-1);
  cursor: pointer;
  text-decoration: none;
  transition:
    background 180ms var(--ease-out-quart),
    color 180ms var(--ease-out-quart),
    opacity 220ms var(--ease-out-quart),
    transform 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
}
@media (hover: hover) and (pointer: fine) {
  .tbar__btn:hover { background: color-mix(in oklab, var(--ink-1) 7%, transparent); }
}
.tbar__btn:active { transform: scale(0.92); }
/* Inset rings on purpose: several controls live inside width-clipping
   .tbar__clip cells, where an outside ring gets sheared off. */
.tbar__btn:focus-visible,
.tbar__skip:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: -2px; }
.tbar__btn svg { display: block; }
/* The icons are drawn at 17 (16 for the close and skip arrows) in
   js/components.js; they step up with the buttons here. */
.tbar svg[width="17"] { width: 19px; height: 19px; }
.tbar svg[width="16"] { width: 18px; height: 18px; }
html[data-theme="dark"] .tbar__btn { color: var(--ink-1); }
@media (hover: hover) and (pointer: fine) {
  html[data-theme="dark"] .tbar__btn:hover { background: color-mix(in oklab, var(--ink-1) 10%, transparent); }
}

/* Contextual cells — the one morph mechanism. A cell is a grid whose
   single column animates 1fr <-> 0fr; the clip hides the contents while
   the track collapses, so width, spacing, and opacity all ride one
   transition. Cells collapse when their data-ctx does not match the
   bar's data-context (and the chat cell whenever no chat widget exists
   in this document). */
.tbar__cell {
  display: grid;
  grid-template-columns: 1fr;
  opacity: 1;
  transition:
    grid-template-columns 360ms var(--ease-out-expo),
    opacity 260ms var(--ease-out-quart) 140ms;
}
.tbar__clip {
  display: flex;
  align-items: center;
  min-width: 0;
  overflow: hidden;
}
.tbar[data-context="home"] .tbar__cell[data-ctx="case"],
.tbar[data-context="case"] .tbar__cell[data-ctx="home"],
.tbar:not([data-has-chat]) .tbar__cell[data-ctx="chat"] {
  grid-template-columns: 0fr;
  opacity: 0;
  pointer-events: none;
  transition:
    grid-template-columns 360ms var(--ease-out-expo),
    opacity 160ms var(--ease-out-quart);
}

/* Theme switcher — the resting button wears the CURRENT theme's icon
   (three stacked icons cross-fading with a small pop; [data-theme]
   drives which shows, so the streetlamp trigger and keyboard land on
   the right icon too). Hovering the group unfolds it into a SEGMENTED
   CONTROL: the group itself becomes a recessed --surface-tint well,
   the current option pops as a raised paper chip on it, and the other
   two options unfold beside it on the chat morph's track-length
   mechanism — the growing clip crops the well open, so the whole
   thing morphs as one surface instead of icons fading in. Pick one
   and it folds shut (js/toolbar.js). */
.tbar__theme {
  display: inline-flex;
  align-items: center;
  /* Concentric well: the group corner is --radius-dock (6px) and the
     chips inside inset 3px at --radius-control (3px) — 3 + 3 = 6, so
     the raised chip's corner shares a centre with the well's instead
     of pinching. */
  border-radius: var(--radius-dock);
  background: transparent;
  transition: background 420ms var(--ease-out-quart);
}
.tbar__theme.is-open { background: var(--surface-tint); }
.tbar #night-btn svg {
  position: absolute;
  inset: 0;
  margin: auto;
  opacity: 0;
  transform: scale(0.5);
  transition: opacity 260ms var(--ease-out-expo), transform 260ms var(--ease-out-expo);
}
.tbar #night-btn .icon-sun { opacity: 1; transform: scale(1); }
html[data-theme="dark"] .tbar #night-btn .icon-sun  { opacity: 0; transform: scale(0.5); }
html[data-theme="dark"] .tbar #night-btn .icon-moon { opacity: 1; transform: scale(1); }
html[data-theme="eink"] .tbar #night-btn .icon-sun  { opacity: 0; transform: scale(0.5); }
html[data-theme="eink"] .tbar #night-btn .icon-page { opacity: 1; transform: scale(1); }
@media (hover: hover) and (pointer: fine) {
  /* Per-theme rules on purpose: the themed resting rules above
     out-specify a bare hover rule, so each pop needs its own prefix. */
  .tbar #night-btn:hover svg { transform: scale(1.14); }
  html[data-theme="dark"] .tbar #night-btn:hover .icon-moon { transform: scale(1.14); }
  html[data-theme="eink"] .tbar #night-btn:hover .icon-page { transform: scale(1.14); }
}
/* The tray: a length track (0px closed, two controls open) so the
   unfold is interpolable end to end — the same law the chat field
   rides. The clip hides the options while the track grows. */
/* The tray track is a LENGTH (0px closed, two 40px cells open) so the
   unfold is one interpolable morph end to end — the SAME law the chat
   field rides. The reveal is purely the clip cropping the well open as
   the track grows; no opacity fade on top, or the options read as
   "appearing" rather than sliding out from behind the active chip. */
.tbar__theme-tray {
  display: grid;
  grid-template-columns: 0px;
  pointer-events: none;
  transition: grid-template-columns 640ms var(--ease-drawer);
}
.tbar__theme.is-open .tbar__theme-tray {
  grid-template-columns: 80px;
  pointer-events: auto;
}
.tbar__theme-tray > .tbar__clip {
  display: flex;
  align-items: center;
  min-width: 0;
  overflow: hidden;
}
/* Exactly two options ever unfold: the current theme's own option
   hides, since the resting button already IS that option. */
html:not([data-theme="dark"]):not([data-theme="eink"]) .tbar__theme-opt[data-set-theme="light"],
html[data-theme="dark"] .tbar__theme-opt[data-set-theme="dark"],
html[data-theme="eink"] .tbar__theme-opt[data-set-theme="eink"] { display: none; }
/* Segmented-control look: the WELL is the darker grey group background
   (above); the raised chip and hover pads are drawn by an INSET ::before
   (inset 3px, --radius-control) so every chip FLOATS in the well with an
   even margin — the concentric look the reference has, and no chip ever
   touches the well's edge. The icons paint above the pad. Fades on the
   pad so opening/hover eases rather than snaps. */
.tbar__theme-cur,
.tbar__theme-opt {
  color: var(--ink-3);
  /* Own stacking context so the inset pad (z-index -1) sits behind the
     icon but above the group's well, no matter the icon's positioning. */
  isolation: isolate;
}
.tbar__theme-cur::before,
.tbar__theme-opt::before {
  content: "";
  position: absolute;
  inset: 3px;
  z-index: -1;
  border-radius: var(--radius-control);
  background: transparent;
  box-shadow: none;
  pointer-events: none;
  /* Eased to sit with the 640ms unfold — the pad used to snap in at
     240ms while the tray was still opening. Still crisp enough for a
     hover pad. */
  transition: background 340ms var(--ease-out-quart), box-shadow 340ms var(--ease-out-quart);
}
.tbar__theme.is-open .tbar__theme-cur { color: var(--ink-1); }
.tbar__theme.is-open .tbar__theme-cur::before {
  background: var(--surface-base);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.09), 0 4px 10px -4px rgba(0, 0, 0, 0.14);
}
html[data-theme="dark"] .tbar__theme.is-open .tbar__theme-cur::before {
  background: var(--surface-card);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 10px -4px rgba(0, 0, 0, 0.5);
}
/* E-ink reads the active segment by value alone — no depth. */
html[data-theme="eink"] .tbar__theme.is-open .tbar__theme-cur::before { box-shadow: none; }
@media (hover: hover) and (pointer: fine) {
  /* The pad IS the hover state. Kill the base .tbar__btn:hover fill on
     these segments (light AND dark, which out-specifies a bare rule),
     or it paints the whole cell grey UNDER the inset pad — the "double
     hover" (a full-bleed box plus the floating chip). */
  .tbar .tbar__theme-cur:hover,
  .tbar .tbar__theme-opt:hover,
  html[data-theme="dark"] .tbar .tbar__theme-cur:hover,
  html[data-theme="dark"] .tbar .tbar__theme-opt:hover { background: transparent; }
  .tbar__theme-opt:hover { color: var(--ink-1); }
  .tbar__theme-opt:hover::before { background: color-mix(in oklab, var(--ink-1) 7%, transparent); }
}

/* Interface sounds (js/sfx.js) — volume on/off. The speaker body is
   shared between the two icons; only the waves and the mute cross swap,
   so the control reads as one icon changing state. Muted goes quiet in
   ink rather than loud in red: it is a preference, not a warning. */
.tbar__sfx svg {
  position: absolute;
  inset: 0;
  margin: auto;
  transition: opacity 200ms var(--ease-out-quart), transform 200ms var(--ease-out-quart);
}
.tbar__sfx .icon-vol-off { opacity: 0; transform: scale(0.72); }
.tbar__sfx[aria-pressed="false"] { color: var(--ink-4); }
.tbar__sfx[aria-pressed="false"] .icon-vol-on { opacity: 0; transform: scale(0.72); }
.tbar__sfx[aria-pressed="false"] .icon-vol-off { opacity: 1; transform: scale(1); }
html[data-theme="dark"] .tbar__sfx[aria-pressed="false"] { color: var(--ink-3); }

/* ── Copy-my-email (js/toolbar.js) ───────────────────────────────
   Three glyphs on one button, one visible at a time: envelope at rest,
   copy on hover, tick once taken. The address itself rides in the dock
   tooltip throughout, so this button never needs a label of its own.

   The swap is a MORPH, not a cross-fade: the outgoing glyph drops and
   twists slightly out while the incoming one rises and untwists into
   place, so the two read as one object turning over rather than two
   pictures dissolving. Expo easing, and the rotations are small (6-8deg)
   — enough to carry the motion, not enough to read as a spin. */
.tbar__mail svg {
  position: absolute;
  inset: 0;
  margin: auto;
  opacity: 0;
  transform: scale(0.7) rotate(-8deg) translateY(3px);
  transition:
    opacity 200ms var(--ease-out-quart),
    transform 320ms var(--ease-out-expo);
}
.tbar__mail .icon-mail { opacity: 1; transform: none; }
@media (hover: hover) and (pointer: fine) {
  .tbar__mail:hover .icon-mail { opacity: 0; transform: scale(0.7) rotate(8deg) translateY(-3px); }
  .tbar__mail:hover .icon-copy { opacity: 1; transform: none; }
}
/* Declared AFTER the hover block and tying it on specificity (0,2,1), so
   source order hands the copied state the win — you are still hovering
   at the moment it flips, and the copy glyph must yield to the tick. */
.tbar__mail.is-copied .icon-mail,
.tbar__mail.is-copied .icon-copy { opacity: 0; transform: scale(0.7) rotate(8deg) translateY(-3px); }
.tbar__mail.is-copied .icon-check { opacity: 1; transform: none; }
/* The tick DRAWS itself rather than appearing. 24 is a hair over the
   path's true length (~23.4 in viewBox units), so the stroke is fully
   hidden at rest and fully written at 0 with no clipped tail. The delay
   holds it back until the copy glyph is most of the way out, so the two
   read as a handover instead of overlapping. */
.tbar__mail .icon-check path {
  stroke-dasharray: 24;
  stroke-dashoffset: 24;
  transition: stroke-dashoffset 300ms var(--ease-out-expo);
}
.tbar__mail.is-copied .icon-check path {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 340ms var(--ease-out-expo) 90ms;
}
@media (prefers-reduced-motion: reduce) {
  /* Keep the state change legible, drop the travel: glyphs cross-fade in
     place and the tick is simply there. */
  .tbar__mail svg { transition: opacity 140ms linear; transform: none !important; }
  .tbar__mail .icon-check path { transition: none; }
  .tbar__mail.is-copied .icon-check path { transition: none; }
}
/* No sfx system in this document (touch, or the module failed to load):
   the cell collapses rather than offering a switch that does nothing. */
.tbar:not([data-has-sfx]) .tbar__cell[data-ctx="sfx"] {
  grid-template-columns: 0fr;
  opacity: 0;
  pointer-events: none;
}

/* ── Chat mode — closed is one bubble, open takes the whole bar ──
   THE MORPH'S ONE LAW: the bar itself is never given a width. Its
   closed width is auto, and CSS cannot interpolate from auto — the
   old `.tbar[data-chat="open"] { width: 560px }` rule made the outer
   box TELEPORT to 560px on open and teleport back on close while the
   contents glided inside it. That teleport was the "snap" every easing
   pass failed to fix, because no easing was ever running on it.
   Instead the bar hugs its contents, and the chat field's grid track
   animates between two real lengths (0px <-> its open width below) —
   interpolable, so the bar's outer edge finally TRAVELS. Everything
   else folds 0fr through the same 560ms drawer curve. */
/* Leaving: fade FAST and with no delay, so the content is already gone
   before its own box starts collapsing. Inheriting .tbar__cell's base
   here (260ms fade, 140ms delay, against the squeeze) was the original
   jump — you spent 400ms watching icons get compressed. */
.tbar[data-chat="open"] .tbar__cell[data-fold] {
  grid-template-columns: 0fr;
  opacity: 0;
  pointer-events: none;
  transition:
    grid-template-columns 560ms var(--ease-drawer),
    opacity 140ms var(--ease-out-quart);
}
/* The field's track is LENGTHS, not fr: 0px closed, a fixed target
   open. Both endpoints are definite, so the track — and with it the
   bar's whole silhouette — glides on the drawer curve. The width
   lives on the track, never on the bar. */
.tbar .tbar__chat-field {
  grid-template-columns: min(480px, calc(100vw - 72px));
}
.tbar[data-chat="closed"] .tbar__chat-field {
  grid-template-columns: 0px;
  opacity: 0;
  pointer-events: none;
  transition:
    grid-template-columns 560ms var(--ease-drawer),
    opacity 140ms var(--ease-out-quart);
}
/* Arriving: hold the fade until the track is well into opening, then
   ease in over a longer beat — content never appears inside a box that
   is still growing fast. Lower specificity than the collapsed rules
   above on purpose — those win in the leaving direction, this one
   governs the return. */
.tbar .tbar__cell[data-fold],
.tbar .tbar__chat-field {
  transition:
    grid-template-columns 560ms var(--ease-drawer),
    opacity 240ms var(--ease-out-quart) 260ms;
}
/* The separator is a plain span, not a cell — collapsed by size in
   EVERY chat state, not just [data-chat="closed"]. Scoped to closed
   only, it correctly sat flush with the mail button at rest, but had
   no override for "open", so it fell back to the base .tbar__sep 1px
   hairline the moment chat expanded — a stray vertical line at the
   leading edge of the "Ask anything" input pill (August 2026 fix). */
.tbar__sep--chat {
  width: 0;
  margin: 0;
  opacity: 0;
  transition: width 560ms var(--ease-drawer), margin 560ms var(--ease-drawer), opacity 140ms var(--ease-out-quart);
}
/* The bubble yields to the field, and the field's cell flexes. */
.tbar[data-chat="open"] .tbar__chat-open {
  width: 0;
  min-width: 0;
  opacity: 0;
  pointer-events: none;
  transition: width 560ms var(--ease-drawer), opacity 140ms var(--ease-out-quart);
}
.tbar__chat-open { transition: width 560ms var(--ease-drawer), opacity 240ms var(--ease-out-quart) 260ms, background 180ms var(--ease-out-quart); }
.tbar__chat { min-width: 0; }
.tbar[data-chat="open"] .tbar__chat,
.tbar[data-chat="open"] .tbar__chat-field { flex: 1 1 auto; min-width: 0; }
.tbar[data-chat="open"] .tbar__chat > .tbar__clip { flex: 1 1 auto; min-width: 0; }
.tbar__chat-field > .tbar__clip { flex: 1 1 auto; min-width: 0; gap: 2px; }
/* Every close on this site speaks one language: a quiet ink chip whose
   X redraws its two strokes on hover (.case-close is the canonical one,
   DESIGN.md; the modal's .cs-close matches it). The dock's X now speaks
   it too — it wore a --surface-tint fill with a full-strength glyph and
   no hover gesture, which made the one control that closes things read as a
   different species from every other close in the product. Size stays
   the dock's (34px, its press scale) so the button still lines up with
   its neighbours in the bar; what it adopts is the close TREATMENT —
   the 7% ink chip, the --ink-3 glyph, the hover that brings the glyph
   to full ink as it redraws — and --radius-control, which every chipped
   close on the site now shares. */
.tbar__chat-close {
  flex: none;
  background: color-mix(in oklab, var(--ink-1) 7%, transparent);
  color: var(--ink-3);
}
/* Ties html[data-theme="dark"] .tbar__btn on specificity and wins on
   source order (that rule is ~150 lines above), which is what keeps the
   glyph at --ink-3 instead of the dock's default full ink at night. */
html[data-theme="dark"] .tbar__chat-close { color: var(--ink-3); }
@media (hover: hover) and (pointer: fine) {
  .tbar__chat-close:hover,
  html[data-theme="dark"] .tbar__chat-close:hover {
    background: color-mix(in oklab, var(--ink-1) 12%, transparent);
    color: var(--ink-1);
  }
}

/* ── The chat segment — the adopted #msg-widget ─────────────────
   js/toolbar.js moves the whole widget into #tbar-chat-slot. In here
   it stops being a floating window: the input row restyles as an
   inset "Ask anything" field (the thing that makes the bar read as a
   command bar at a glance), and the conversation body reanchors as a
   floating panel above the bar. Every behavior — hover-intent expand,
   focus-to-open, click-away collapse, Escape — is untouched
   chatbot.js/inline-script logic; only geometry changes here. */
/* The adopted widget nests four levels deep (slot > widget > panel >
   inner > dock). Every level has to grow, or the input stops at its
   content width and leaves dead space in an open bar. */
.tbar__chat-slot,
.tbar .msg-widget,
.tbar .msg-widget__panel,
.tbar .msg-widget__inner {
  display: flex;
  align-items: center;
  flex: 1 1 auto;
  min-width: 0;
}

/* Neutralize the floating-window shell. position:static throughout so
   the conversation body's containing block becomes the bar itself
   (the bar's transform makes it one), letting the panel align to the
   bar's edge rather than to this mid-bar segment. */
.tbar .msg-widget,
.tbar .msg-widget__panel,
.tbar .msg-widget__inner { position: static; }
.tbar .msg-widget__panel {
  width: auto;
  filter: none;
  will-change: auto;
  transition: none;
}
.tbar .msg-widget__panel:not(.is-open):hover { transform: none; }
.tbar .msg-widget__inner {
  display: flex;
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  overflow: visible;
  max-height: none;
}

/* The input row — an inset field, ref: a command bar's "Ask anything".
   Focus ring rides focus-within so the ring wraps prefix, caret, and
   text as one control. */
/* The field carries NO surface of its own and NO states (requested):
   no fill, no hover tint, no focus change. It sits directly on the bar
   like the icons do — the caret, the placeholder, and the > prefix are
   the whole affordance. Only the geometry and the text cursor remain. */
.tbar .msg-widget__dock {
  height: 37px;
  min-height: 0;
  padding: 0 7px 0 10px;
  gap: 6px;
  flex: 1 1 auto;
  min-width: 0;
  background: transparent;
  cursor: text;
}
.tbar #msg-widget-header { cursor: text; }
/* The collapsed widget's hover darkening (placeholder + prefix) belongs
   to the floating-window design this field no longer is; inside the bar
   it would be a state on a surface that has none. Both are restated at
   their resting --ink-4 rather than `inherit`, which would have pulled
   the parent's ink instead of their own quiet grey. */
.tbar .msg-widget__panel:not(.is-open):hover .chat-placeholder,
.tbar .msg-widget__panel:not(.is-open):hover .msg-widget__dock .chat-form__prefix {
  color: var(--ink-4);
}
/* No chevron in the dock: clicking/focusing the field already opens the
   conversation and the X closes the whole chat, so a third control for
   the same two states was redundant. It stays in the DOM (chatbot.js and
   the inline expand script both click it programmatically) — only hidden.
   The mobile override further down would re-show it, so this wins with
   the extra .tbar specificity. */
.tbar .msg-widget__expand-btn { display: none; }

/* The conversation — a floating glass panel above the bar, aligned to
   the bar's right edge. Absolutely positioned against the bar (see the
   position:static chain above), so the overflow-hidden .tbar__clip it
   sits inside cannot clip it: clipping only reaches descendants whose
   containing block passes through the clipper. */
.tbar .msg-widget__body {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  min-height: 0;
  max-height: min(480px, calc(100dvh - 140px));
  overflow: hidden;
  z-index: 5;
  /* Same flat surface as the bar, so the attached pair reads as one
     chip; its only edge is the hairline where list meets input row
     (the bar itself is borderless now). */
  background: var(--surface-deep);
  border: 0;
  border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
  border-radius: var(--radius-dock) var(--radius-dock) 0 0;
  box-shadow: 0 -14px 30px -22px rgba(0, 0, 0, 0.20);
  /* Grows out of the seam it shares with the bar, not a floating corner. */
  transform-origin: bottom center;
  opacity: 0;
  transform: translateY(8px) scale(0.99);
  visibility: hidden;
  pointer-events: none;
  transition:
    opacity 150ms var(--ease-out-quart),
    transform 240ms var(--ease-out-expo),
    visibility 0s 240ms;
}
.tbar.is-chat-open .msg-widget__body {
  opacity: 1;
  transform: translateY(0) scale(1);
  visibility: visible;
  pointer-events: auto;
  transition:
    opacity 180ms var(--ease-out-quart),
    transform 560ms var(--ease-drawer),
    visibility 0s;
}
.tbar .msg-widget__body-content { padding: 16px 16px 12px; }

/* ── Tooltips — one stamped-ink chip, positioned by js/toolbar.js ──
   A single element serves every [data-tip] control (several sit inside
   width-clipping cells, where per-button pseudo-element tooltips would
   be sheared off — the same reason the focus rings are inset). Ink on
   paper by token, so dark mode inverts it to a light chip on its own.
   First hover waits; once one tooltip has shown, neighbors show
   instantly (.is-instant), so scanning the bar reads as one gesture. */
.tbar__tip {
  position: absolute;
  bottom: calc(100% + 10px);
  left: 0;
  z-index: 6;
  padding: 5px 9px 4px;
  border-radius: var(--radius-control);
  background: var(--ink-1);
  color: var(--ink-inv);
  font-family: var(--font-primary);
  font-size: var(--t-micro);
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  translate: 0 4px;
  scale: 0.97;
  transform-origin: bottom center;
  transition:
    opacity 125ms var(--ease-out-quart),
    translate 160ms var(--ease-out-expo),
    scale 160ms var(--ease-out-expo);
}
.tbar__tip.is-on { opacity: 1; translate: 0 0; scale: 1; }
.tbar__tip.is-instant { transition-duration: 0ms; }
/* Re-labelling a chip that is ALREADY up (the mail button's "Copied!"):
   the box travels to the new text's width instead of snapping, so it
   reads as one object answering rather than two tooltips flickering.
   js/toolbar.js pins both endpoints — width cannot interpolate from
   auto. overflow: hidden only while morphing, so the longer label clips
   cleanly inside the shrinking box rather than spilling past its
   corners for the length of the animation. */
.tbar__tip.is-morphing {
  overflow: hidden;
  transition:
    opacity 125ms var(--ease-out-quart),
    width 340ms var(--ease-out-expo),
    left 340ms var(--ease-out-expo);
}
@media (prefers-reduced-motion: reduce) {
  .tbar__tip.is-morphing { transition: opacity 120ms linear; }
}

/* ── The case-navigation chip ───────────────────────────────────
   Mid-case it skips to the next section; past the last section
   js/toolbar.js morphs it into the next-case link (label width
   animates via a measured span, the arrow rotates from "down the
   page" to "onward"). */
/* The section chip is a FILLED tint pill, so it can't sit hard against
   its neighbours the way the ghost icon buttons do — it needs air, not
   a divider line. So: drop the hairline sep that led into it (the pill
   is already its own visible shape), and give the pill breathing room
   on both sides — enough that it doesn't kiss the mail button or the
   chat bubble, but not so much that it stands out against the dock's
   otherwise zero-gap icon cluster (an initial 6px read as too loose,
   tightened to 4px). Margin lives on the pill inside its
   overflow-hidden clip, so the case→home fold still clips cleanly. */
.tbar__go .tbar__sep { display: none; }
.tbar__skip {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 40px;
  margin: 0 4px;
  padding: 0 13px 0 14px;
  border-radius: var(--radius-control);
  background: var(--surface-tint);
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--ink-1);
  text-decoration: none;
  transition:
    color 200ms var(--ease-out-quart),
    background 200ms var(--ease-out-quart),
    transform 160ms var(--ease-out-quart);
}
.tbar__skip:active { transform: scale(0.96); }
/* The label is a TICKER. On a destination change the old line rides up
   and out while the new one rises in from below (setPill stages the
   under-the-baseline start with .is-below + a reflow) — one continuous
   upward motion instead of a blink-in-place crossfade. Width still
   glides to the measured target underneath it. */
.tbar__skip-label {
  display: inline-block;
  overflow: hidden;
  text-align: left;
  opacity: 1;
  transition:
    width 420ms var(--ease-out-expo),
    opacity 170ms var(--ease-out-quart),
    transform 240ms var(--ease-out-expo);
}
.tbar__skip-label.is-swap { opacity: 0; transform: translateY(-8px); }
/* The staged start for the incoming line: parked below, no transition,
   so removing it releases the ride up. Declared after .is-swap so it
   wins while both are momentarily present. */
.tbar__skip-label.is-below { transition: none; opacity: 0; transform: translateY(9px); }
.tbar__skip-measure {
  position: absolute;
  visibility: hidden;
  white-space: nowrap;
  pointer-events: none;
}
/* The arrow stack — three glyphs, one visible at a time. The old single
   arrow ROTATED between meanings, which read as a spinner; a change of
   destination is a different glyph. Swaps ride the same upward ticker
   as the label: the incoming arrow rises from below the clip. */
.tbar__skip-ic { position: relative; width: 16px; height: 16px; flex: none; overflow: hidden; }
.tbar__skip-ic svg {
  position: absolute;
  inset: 0;
  margin: auto;
  opacity: 0;
  transform: translateY(7px) scale(0.85);
  transition: opacity 220ms var(--ease-out-quart), transform 300ms var(--ease-out-expo);
}
/* Which arrow is up: down at rest, forward at the endgame, up on the
   back-to-top fallback. Classes set by setPill in js/toolbar.js. */
.tbar__skip:not(.is-endgame):not(.is-top) .icon-skip-down,
.tbar__skip.is-endgame .icon-skip-fwd,
.tbar__skip.is-top .icon-skip-up { opacity: 1; transform: none; }
/* Hover: the SWEEP. The arrow leaves through the edge it points at and
   re-enters from the opposite side — a full rehearsal of the trip the
   click will take, clipped by the icon box. Plays once per hover. */
@keyframes tbar-sweep-down {
  0%   { transform: none; opacity: 1; }
  45%  { transform: translateY(11px); opacity: 0; }
  55%  { transform: translateY(-11px); opacity: 0; }
  100% { transform: none; opacity: 1; }
}
@keyframes tbar-sweep-fwd {
  0%   { transform: none; opacity: 1; }
  45%  { transform: translateX(11px); opacity: 0; }
  55%  { transform: translateX(-11px); opacity: 0; }
  100% { transform: none; opacity: 1; }
}
@keyframes tbar-sweep-up {
  0%   { transform: none; opacity: 1; }
  45%  { transform: translateY(-11px); opacity: 0; }
  55%  { transform: translateY(11px); opacity: 0; }
  100% { transform: none; opacity: 1; }
}
@media (hover: hover) and (pointer: fine) {
  .tbar__skip:hover { color: var(--accent-blue); background: color-mix(in oklab, var(--ink-1) 8%, transparent); }
  .tbar__skip:not(.is-endgame):not(.is-top):hover .icon-skip-down { animation: tbar-sweep-down 480ms var(--ease-out-quart); }
  .tbar__skip.is-endgame:hover .icon-skip-fwd { animation: tbar-sweep-fwd 480ms var(--ease-out-quart); }
  .tbar__skip.is-top:hover .icon-skip-up { animation: tbar-sweep-up 480ms var(--ease-out-quart); }
}
/* Endgame: the chip points onward — sanctioned azure wash. (The
   highlighter family established translucent azure; this reuses that
   recipe, srgb so it stays azure.) */
.tbar__skip.is-endgame {
  color: var(--accent-blue);
  background: color-mix(in srgb, var(--accent-blue) 10%, transparent);
}
html[data-theme="dark"] .tbar__skip.is-endgame {
  color: var(--accent-blue-soft);
  background: color-mix(in srgb, var(--accent-blue-soft) 14%, transparent);
}

/* The skip targets land clear of the viewport edge. */
.case .case-section[id] { scroll-margin-top: 28px; }

@media (max-width: 719px) {
  .tbar {
    bottom: calc(12px + env(safe-area-inset-bottom, 0px) + var(--tbar-kb-lift, 0px));
    max-width: calc(100vw - 12px);
    padding: 3px;
  }
  .tbar__sep { margin: 0 2px; }
  .tbar__clip { min-width: 0; }
  /* Thumb room: the chip drops its label and keeps the arrow. */
  .tbar__skip { padding: 0 12px; }
  .tbar__skip-label { display: none; }
  /* Open chat fills the width on mobile — a narrow floating field
     wastes the screen and made the conversation read cramped. The
     field track drives the bar's silhouette (the auto-width law), so
     widen the track toward full-bleed instead of setting a bar width. */
  .tbar .tbar__chat-field {
    grid-template-columns: calc(100vw - 40px);
  }
  /* The panel is sized against --tbar-avail: the REAL measured gap
     between the top of the viewport and the top of the bar, taken from
     getBoundingClientRect in js/toolbar.js. That is deliberate. Every
     previous attempt sized this from viewport APIs (dvh, visualViewport)
     and every one of them lied under some combination of keyboard,
     browser chrome and page zoom. The bar's own rect cannot lie: it is
     the thing the panel actually sits on top of.

     NOTE FOR ANYONE EDITING THIS: max-height is a CEILING, not a floor.
     An earlier fix used `max(220px, ...)` here believing it guaranteed a
     readable panel — it does not, and could not, because a panel whose
     CONTENT is short stays short no matter how high the ceiling is. The
     actual guarantee is the min-height below, on the open state. Do not
     replace it with more max-height arithmetic. */
  .tbar .msg-widget__body {
    max-height: min(480px, var(--tbar-avail, 60vh));
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
  }
  /* THE ACTUAL FLOOR. An open conversation is never allowed to collapse
     to a sliver, whatever the content or the measurements are doing. It
     yields to --tbar-avail so it can never push its own top off the
     screen on a genuinely short viewport. */
  .tbar.is-chat-open .msg-widget__body {
    min-height: min(200px, var(--tbar-avail, 200px));
  }
  /* One scroll container on mobile: the message list drops its own cap
     and lets the body be the single scroller, so the prompts above it
     can never be stranded above a nested clip. */
  .tbar .chat-messages { max-height: none; }
}

@media (prefers-reduced-motion: reduce) {
  .tbar { animation: none; transition: none; }
  .tbar__cell,
  .tbar__chat-field,
  .tbar__sep--chat,
  .tbar__chat-open,
  .tbar__btn,
  .tbar__skip,
  .tbar__skip-label,
  .tbar__skip svg,
  .tbar .msg-widget__dock,
  .tbar #night-btn svg { transition: none; }
  .tbar__skip-ic svg { animation: none; transition: none; }
  .tbar__theme-tray { transition: none; }
  /* Tooltips keep their fade, drop the rise/scale. */
  .tbar__tip { transition: opacity 120ms linear; translate: 0 0; scale: 1; }
  /* The conversation panel keeps its fade, drops the movement. */
  .tbar .msg-widget__body {
    transform: none;
    transition: opacity 160ms linear, visibility 0s 160ms;
  }
  .tbar.is-chat-open .msg-widget__body {
    transform: none;
    transition: opacity 160ms linear, visibility 0s;
  }
}

/* ════════════════════════════════════════════════════════════════
   CURSOR TIP — the label IS the cursor
   (js/cursortip.js). Context before commitment: "View" over the project
   cards, "Coming soon" over the locked one, "Zoom" over zoomable case
   images. Same chip language as the dock tooltips. Fine pointers only;
   the element follows via the translate property (set directly, never
   transitioned) while opacity/scale carry the enter.

   It is centred on the pointer and the native cursor is hidden beneath
   it, so the word REPLACES the arrow rather than trailing it. */
.cursor-tip {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 620;
  padding: 5px 9px 4px;
  border-radius: var(--radius-control);
  background: var(--ink-1);
  color: var(--ink-inv);
  font-family: var(--font-primary);
  font-size: var(--t-micro);
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  scale: 0.92;
  /* Centre, not top left: the label now sits ON the pointer, so it has
     to grow from its own middle or the enter animation slides it off
     the very point it is marking. */
  transform-origin: center;
  transition:
    opacity 140ms var(--ease-out-quart),
    scale 180ms var(--ease-out-expo);
}
.cursor-tip.is-on { opacity: 1; scale: 1; }
/* The native cursor goes while a tip is showing — otherwise the arrow
   and the label occupy the same pixel and read as one broken object.
   js/cursortip.js sets this attribute on the hovered target only, and
   clears it on exit, so no other cursor on the site is affected.

   The attribute selector is DOUBLED for weight: .card--soon:hover sets
   cursor: not-allowed at (0,2,0), which a single attribute (0,1,0)
   would lose to, leaving the locked card with both an arrow and a
   label. Losing not-allowed there costs nothing — that card's tip
   reads "Coming soon", which says the same thing in words. */
[data-cursor-tip-on][data-cursor-tip-on],
[data-cursor-tip-on][data-cursor-tip-on] * { cursor: none; }
@media (prefers-reduced-motion: reduce) {
  .cursor-tip { transition: opacity 120ms linear; scale: 1; }
}

.cs-panel em {
  font-style: normal;
}

.cs-deep-text--callout em {
  text-decoration: underline;
  text-underline-offset: 2px;
}

.cs-deep-text--callout {
  display: block;
  width: fit-content;
  margin: 14px 0;
  padding: 6px 15px 7px 12px;
  background: #fff;
  border: none;
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 700;
  letter-spacing: -0.07em;
  text-transform: uppercase;
  line-height: 1;
  color: #2d241c;
  filter: blur(0.4px);
  box-shadow:
    0 1px 2px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 3px 8px -1px color-mix(in oklab, var(--ink-1) 11%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
  transform: rotate(-2deg);
  transition:
    transform  400ms var(--ease-out-soft),
    box-shadow 400ms var(--ease-out-soft);
  transform-origin: center center;
  will-change: transform;
}

.cs-deep-text--callout:hover {
  transform: rotate(2.5deg) scale(1.02);
  box-shadow:
    0 3px 4px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 7px 16px -3px color-mix(in oklab, var(--ink-1) 11%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
}

/* ── Comparison table ─────────────────────────────────────────── */
.cs-compare-table {
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.cs-compare-row {
  display: grid;
  grid-template-columns: 28fr 36fr 36fr;
  align-items: center;
  gap: 8px;
}

.cs-compare-row--head {
  padding: 0 13px 7px;
}

.cs-compare-row:not(.cs-compare-row--head) {
  padding: 9px 13px;
  border-radius: 0;
  background: color-mix(in oklab, var(--ink-1) 4%, transparent);
}

.cs-compare-row--highlight:not(.cs-compare-row--head) {
  background: color-mix(in oklab, var(--ink-1) 8%, transparent);
}

.cs-compare-cell {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.4;
  color: var(--ink-1);
  opacity: 0.6;
}

.cs-compare-cell--header {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  /* 0.38 dropped these below 3:1 in both themes at 11px; 0.55 is the
     floor that keeps AA contrast while staying quieter than the cells. */
  opacity: 0.55;
}

.cs-compare-row--highlight .cs-compare-cell {
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 700;
  opacity: 1;
  letter-spacing: -0.01em;
}

/* ── Design principles cards ──────────────────────────────────── */
.cs-principles-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  padding: 10px 6px;
}

.cs-principle-card {
  padding: 16px 14px;
  border-radius: 0;
  background: var(--surface-deep);
  border: none;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* Full-page case cards: quiet grey plate (--surface-deep, matching the
   statement box and other case panels) with the same hand-drawn, sprayed
   border as the project thumbnails — surface-base read as invisible here,
   flush against the page's own white. The wobble rides a border-only
   ::after with the #spray-frame-* filters (defined in case.html), so only
   the outline distorts, never the card's text. Scoped to .case so the
   modal cards (.cs-panel) stay untouched. */
.case .cs-principle-card {
  position: relative;
  background: transparent;
  border: none;
  transform-origin: center;
  transition: transform 170ms var(--ease-out-quart);
}
.case .cs-principle-card::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px solid color-mix(in oklab, var(--ink-1) 28%, transparent);
  border-radius: 0;
  pointer-events: none;
  z-index: 1;
}
/* Each card pulls a different seed so the set reads hand-drawn, not stamped. */
.case .cs-principle-card:nth-child(3n+1)::after { filter: url(#spray-frame-card-1); }
.case .cs-principle-card:nth-child(3n+2)::after { filter: url(#spray-frame-card-2); }
.case .cs-principle-card:nth-child(3n+3)::after { filter: url(#spray-frame-card-3); }
/* Dark theme: page and --surface-base are both near-black, so lift the card
   to --surface-card and let the sprayed outline define it. */
html[data-theme="dark"] .case .cs-principle-card {
  background: transparent;
}
/* Mobile: drop the displacement filter (GPU-expensive), keep a clean border —
   the same trade the project thumbnails make. :nth-child(n) matches the
   specificity of the per-seed rules above so this actually wins. */
@media (max-width: 719px) {
  .case .cs-principle-card:nth-child(n)::after { filter: none; }
  /* Paired images (e.g. the before/after two-up) stack on phones instead of
     squishing into two ~160px columns. Scoped to .case so the modal is left
     to its own 900px rule. */
  .case .cs-landing-row { grid-template-columns: 1fr; }
}
/* Subtle, quick tilt on hover — pointer devices only. Each card leans a
   slightly different way (matching its per-card border seed) so the row feels
   hand-placed, not stamped. */
@media (hover: hover) and (pointer: fine) {
  .case .cs-principle-card:nth-child(3n+1):hover { transform: rotate(-1.4deg); }
  .case .cs-principle-card:nth-child(3n+2):hover { transform: rotate(1.2deg); }
  .case .cs-principle-card:nth-child(3n+3):hover { transform: rotate(-0.9deg); }
}
@media (prefers-reduced-motion: reduce) {
  .case .cs-principle-card { transition: none; }
  .case .cs-principle-card:hover { transform: none; }
}

.cs-principle-icon {
  display: flex;
  color: var(--ink-1);
  opacity: 0.6;
  margin-bottom: 4px;
}

/* Number symbol — the icon's alternate. Sits in the same slot at the top of
   the card, same muted weight, so a numbered grid and an icon grid read as
   the same component. */
.cs-principle-num {
  display: block;
  font-family: var(--font-primary);
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 0.06em;
  line-height: 1;
  color: var(--ink-1);
  opacity: 0.55;
  margin-bottom: 8px;
  font-variant-numeric: tabular-nums;
}

.cs-principle-title {
  font-family: var(--font-primary);
  font-size: var(--t-body);
  font-weight: 700;
  color: var(--ink-1);
  letter-spacing: -0.01em;
  line-height: 1.3;
}

.cs-principle-body {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-2);
  font-weight: 400;
}

/* Principles, variant:'flow' — the same points as an unboxed list: a
   numeral in its own narrow column, title and body beside it, rows
   parted by 11% hairlines (the soft register), so a run of points
   between two image blocks doesn't become one more grid of boxes. */
.case .cs-principles-flow {
  list-style: none;
  margin: 0;
  padding: 0;
}
.case .cs-principles-flow__item {
  display: grid;
  grid-template-columns: 40px minmax(0, 1fr);
  column-gap: var(--space-4);
  padding: var(--space-4) 0;
}
.case .cs-principles-flow__item + .cs-principles-flow__item {
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 11%, transparent);
}
.case .cs-principles-flow__num { padding-top: 2px; }
.case .cs-principles-flow .cs-principle-title { margin: 0 0 var(--space-1); }
.case .cs-principles-flow .cs-principle-body { margin: 0; }

/* ── Landing page images — side-by-side row ─────────────────────── */
.cs-landing-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
}

/* Stacked variant — one full-width image per row (each fills the column). */
/* The "before" of a compare pair reads as the past, in the same language
   as the Assumed persona card (the discarded draft): nearly drained of
   color, a touch flatter, tilted -0.8deg like an old print pinned up.
   The filter sits on the FRAME only, so handwritten notes over it stay
   full accent blue (today's ink on an old print). The tilt sits on the
   wrapper so notes turn with the picture. Hover restores it to inspect;
   the lightbox always opens the full-color original. Full-page only. */
.case .cs-landing-item--before .cs-landing-frame {
  filter: grayscale(0.9) contrast(0.9) brightness(1.04);
  transition: filter 320ms var(--ease-out-quart);
}
.case .cs-landing-item--before > .cs-annot,
.case .cs-landing-item--before > .cs-landing-frame {
  transform: rotate(-0.8deg);
  transform-origin: 50% 0;
  transition: transform 320ms var(--ease-out-quart), filter 320ms var(--ease-out-quart);
}
.case .cs-landing-item--before .cs-landing-label { color: var(--ink-4); }
@media (hover: hover) {
  .case .cs-landing-item--before:hover .cs-landing-frame { filter: none; }
  .case .cs-landing-item--before:hover > .cs-annot,
  .case .cs-landing-item--before:hover > .cs-landing-frame { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .case .cs-landing-item--before .cs-landing-frame,
  .case .cs-landing-item--before > .cs-annot { transition: none; }
}

/* Staggered pair (`stagger: true`): the second frame drops lower than
   the first, so a before/after reads as two pages pinned up by hand
   rather than a tidy grid. The drop scales with the column; the extra
   gap lets the two breathe as separate objects. Stacked phones get no
   drop (the pair is one column there). Full-page only. */
.case .cs-landing-row--stagger {
  align-items: start;
  column-gap: var(--space-6);
}
.case .cs-landing-row--stagger > .cs-landing-item:nth-child(2) {
  margin-top: clamp(56px, 13%, 120px);
}
@media (max-width: 719px) {
  .case .cs-landing-row--stagger > .cs-landing-item:nth-child(2) { margin-top: 0; }
}

.cs-landing-row--stack {
  grid-template-columns: 1fr;
  gap: 20px;
}

/* Trio — three across, for a sequence read left to right (the cart
   evolution). The default 1fr 1fr would break three frames as two then
   one, which reads as a grid of examples rather than as steps. Drops to
   one column on a phone, where three abreast would be unreadable. */
.cs-landing-row--trio {
  grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 767px) {
  .cs-landing-row--trio { grid-template-columns: 1fr; }
}

/* ── Numbered steps (js/case.js buildImage, numbered:true) ──────
   A sequence needs to say WHICH step each frame is, not just sit in
   reading order: side by side that order is inferred, and stacked on a
   phone it is lost entirely. The numeral is the part that survives both,
   so it carries the job and the connector below is decoration on top. */
.cs-landing-row--steps .cs-landing-item { position: relative; }
.cs-landing-stage { position: relative; }
.cs-landing-step {
  color: var(--ink-4);
  margin-right: 8px;
}

/* The connector: a hairline running from each frame to the next, with a
   chevron at its end, so the eye is walked forward rather than left to
   infer the order. Desktop only and never after the last item; on a
   phone the frames stack and a horizontal arrow would point at nothing.

   Anchored on .cs-landing-stage (the frame's unclipped wrapper), NOT the
   item: the step label sits above the frame, so the item's 50% landed
   15px above the frames' centre. The whole arrow spans the 14px grid
   gap exactly, so it is centred between the two frames horizontally
   too: the line runs the full gap and the chevron's tip ends on it. */
@media (min-width: 768px) {
  .cs-landing-row--steps .cs-landing-item:not(:last-child) .cs-landing-stage::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 100%;
    width: 14px;
    height: 1px;
    transform: translateY(-50%);
    background: color-mix(in oklab, var(--ink-1) 22%, transparent);
    pointer-events: none;
  }
  /* 5px box rotated 45deg: its tip reaches ~3.5px past its centre, so a
     box starting 8px in puts the tip on the line's 14px end */
  .cs-landing-row--steps .cs-landing-item:not(:last-child) .cs-landing-stage::before {
    content: "";
    position: absolute;
    top: 50%;
    left: calc(100% + 8px);
    width: 5px;
    height: 5px;
    border-top: 1px solid color-mix(in oklab, var(--ink-1) 22%, transparent);
    border-right: 1px solid color-mix(in oklab, var(--ink-1) 22%, transparent);
    transform: translateY(-50%) rotate(45deg);
    pointer-events: none;
  }
}

/* Caption printed under the row instead of over it (buildImage,
   compare:true), centred to match the slider's own caption
   (.cs-wipe__note) so the two comparison figures on this page caption
   themselves the same way. */
.cs-image-caption--below {
  margin-top: 10px;
  margin-bottom: 0;
  text-align: center;
}
.case .cs-image-caption--below { margin-bottom: 0; }

.cs-landing-item {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.cs-landing-side {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  gap: 14px;
}

.cs-landing-side .cs-landing-row {
  flex: 0 0 62%;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.cs-landing-side .cs-landing-item {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.cs-landing-side .cs-landing-row .cs-landing-frame {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.cs-landing-side .cs-landing-row .cs-landing-img {
  flex: 1;
  object-fit: cover;
  min-height: 0;
  height: 100%;
}

.cs-landing-side .cs-landing-desc {
  flex: 1;
  margin-top: 0;
  padding-top: 0;
}

.cs-landing-label {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  /* ink-4 at 0.55 opacity double-dimmed this to ~2.2:1 at night —
     ink-3 at full opacity keeps the caption quiet but readable. */
  color: var(--ink-3);
  text-align: center;
}

.cs-landing-desc {
  /* Default (stacked) layout: image sits above this text, so it needs
     real breathing room — .cs-landing-side overrides back to 0 below,
     since there the image sits beside the text instead of above it. */
  margin-top: 20px;
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-1);
  font-weight: 400;
}

.cs-landing-desc .cs-deep-text {
  margin-top: 0;
  margin-bottom: 0;
}

/* Full-page case sub-section label. Sources the .eyebrow recipe
   (10px / 600 / 0.14em / uppercase / --ink-4); only its block layout
   and rhythm live here. The modal re-asserts its own 11px/700 via
   .cs-panel .cs-desc-label, so this stays a full-page-only change. */
.cs-desc-label {
  display: block;
  margin-top: 14px;
  margin-bottom: 4px;
}

.cs-desc-label:first-child {
  margin-top: 0;
}

.cs-desc-label--section {
  margin-top: 14px;
}

.cs-deep-list {
  list-style: none;
  padding: 0;
  margin: 14px 0 0 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.cs-deep-list li {
  position: relative;
  padding: 14px 16px;
  border-radius: 0;
  background: color-mix(in oklab, var(--ink-1) 3%, transparent);
  border: 1px solid color-mix(in oklab, var(--ink-1) 9%, transparent);
}

/* ── Feature row (3-col with dividers) ───────────────────────── */
.cs-dl-block {
  display: grid;
  grid-template-columns: 38% 1fr;
  row-gap: 10px;
  column-gap: 16px;
  margin-top: 10px;
  align-items: baseline;
}

.cs-dl-term {
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 700;
  color: var(--ink-1);
  opacity: 0.88;
  line-height: 1.62;
}

.cs-dl-desc {
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 400;
  color: var(--ink-1);
  opacity: 0.88;
  line-height: 1.62;
  margin: 0;
}

.cs-persona-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  margin-top: 14px;
}

.cs-persona-card {
  flex: 1;
  border: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
  border-radius: 0;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  background: color-mix(in oklab, var(--ink-1) 3%, transparent);
}
/* Personas do NOT take the sprayed-edge filter; the wobbly outline stays
   reserved for the evidence components below. When a case sets an
   ASSUMED persona against the ACTUAL one, they are told apart by the
   rules after "Assumed vs actual persona" instead (dashed draft edge,
   faded ink, a hand-drawn X / tick). */

/* Hand-drawn sprayed edge: the floated stat block (the "17%" figure) and
   the REJECTED side of the contrast fork ("the usual answer"). On the fork
   the wobble is doing narrative work — the discarded option is the sketch,
   still in pencil, while the shipped bet opposite it gets a clean ruled
   frame. Same border-only ::after recipe as the principle cards, so the
   wobble rides the outline and never the text, with the filter dropped on
   mobile for performance. */
.case .case-stat {
  position: relative;
  border: none;
}
.case .case-stat::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px solid color-mix(in oklab, var(--ink-1) 28%, transparent);
  border-radius: 0;
  pointer-events: none;
  z-index: 1;
  filter: url(#spray-frame-card-1);
}
@media (max-width: 719px) {
  .case .case-stat::after { filter: none; }
}

.cs-persona-header {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
}

.cs-persona-num {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-1);
  opacity: 0.55;
}

.cs-persona-name {
  display: block;
  font-family: var(--font-primary);
  font-size: 16px;
  font-weight: 600;
  letter-spacing: -0.02em;
  color: var(--ink-1);
  line-height: 1.15;
}

.cs-persona-loc {
  display: inline-block;
  flex-shrink: 0;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-1);
  opacity: 0.55;
  background: color-mix(in oklab, var(--ink-1) 7%, transparent);
  border-radius: 0;
  padding: 2px 7px 3px;
}

.cs-persona-demo {
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 400;
  line-height: 1.62;
  color: var(--ink-1);
  margin: 0;
  padding-bottom: 14px;
  border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
}

.cs-persona-block {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.cs-persona-label {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-1);
  opacity: 0.55;
}

.cs-persona-text {
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 400;
  line-height: 1.62;
  color: var(--ink-1);
  margin: 0;
}

.cs-font-grid {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-top: 14px;
}

.cs-font-box {
  flex: 1;
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  border-radius: 0;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.cs-font-specimen {
  display: block;
  font-size: 26px;
  line-height: 1;
  color: var(--ink-1);
  opacity: 0.82;
  margin-bottom: 4px;
}

.cs-font-name {
  display: block;
  font-family: var(--font-primary);
  font-size: 8px;
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.cs-font-desc {
  font-family: var(--font-body);
  font-size: var(--t-meta);
  font-weight: 500;
  line-height: 1.5;
  color: var(--ink-1);
  margin: 0;
}

/* ── OUSTRE brand specimens (case-study content) ─────────────────────
   Type rows + color swatches replacing the old photo-with-color-strip.
   Portfolio chrome (quiet surface-card plates, zero radius, tracked
   labels); the specimen fonts and swatch hexes are OUSTRE's own brand,
   so they stay literal in both themes. */
/* Type sheet: OUSTRE's three faces drawn as a typographer's spec sheet.
   Every line of every sample is its own ruled row: a unit gutter on the
   left (the line's leading as a multiple of X, the font size), the
   sample, and a note column on the right naming the face and its
   leading. Hairlines run the full width under each line, so the sheet
   reads as one ruled page. Line breaks are set in the markup, never
   wrapped, and the sample sizes scale with the sheet's own width (cqi)
   so the longest line always fits. */
.cs-type-sheet {
  --type-rule: color-mix(in oklab, var(--ink-1) 12%, transparent);
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--type-rule);
}
/* Spacer lines: empty ruled rows at the top of the sheet and between
   roles, so the page stays one continuous run of hairlines. */
.cs-type-row::before {
  content: '';
  display: block;
  height: clamp(12px, 3.5cqi, 18px);
  border-bottom: 1px solid var(--type-rule);
}
.cs-type-row + .cs-type-row::before {
  height: clamp(22px, 6cqi, 32px);
}
.cs-type-line {
  display: grid;
  grid-template-columns: clamp(30px, 9cqi, 44px) minmax(0, 1fr) clamp(68px, 22cqi, 104px);
  column-gap: clamp(8px, 2.5cqi, 16px);
  align-items: last baseline;
  margin: 0;
  border-bottom: 1px solid var(--type-rule);
}
.cs-type-unit,
.cs-type-note {
  font-family: var(--font-body);
  font-style: normal;
  font-size: 9px;
  line-height: 1.3;
  letter-spacing: 0;
  text-transform: none;
  color: var(--ink-4);
  padding-bottom: 4px;
}
.cs-type-text {
  white-space: nowrap;
  color: var(--ink-1);
}
.cs-type-sub { color: var(--ink-4); }
.cs-type-row--label .cs-type-text {
  font-size: clamp(10px, 3cqi, 13px);
  line-height: 1.3;   /* the spec'd 130% */
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.cs-type-row--display .cs-type-text {
  font-size: clamp(26px, 9.5cqi, 46px);
  line-height: 1.1;   /* the spec'd 110% */
  letter-spacing: -0.01em;
}
.cs-type-row--body .cs-type-text {
  font-size: clamp(14px, 4.4cqi, 20px);
  line-height: 1.6;   /* the spec'd 160% */
}

/* Envelope photo beside the type specimen, swatches underneath across
   both columns. The description wrapper steps out of the layout
   (display: contents) so its type list and swatch grid become grid
   items of their own. */
.case .cs-landing-side--type {
  display: grid;
  grid-template-columns: minmax(0, 7fr) minmax(0, 4fr);
  column-gap: clamp(24px, 4vw, 48px);
  row-gap: 32px;
  align-items: stretch;
}
.case .cs-landing-side--type .cs-landing-desc { display: contents; }
/* The photo fills the row to the type column's height; its dark mat
   absorbs the cover crop, so only background is ever trimmed. */
.case .cs-landing-side--type .cs-landing-row .cs-landing-img { min-height: 280px; }
.case .cs-landing-side--type .cs-type-sheet { grid-column: 1; grid-row: 1; align-self: center; }
.case .cs-landing-side--type > .cs-landing-row { grid-column: 2; grid-row: 1; }
.case .cs-landing-side--type .cs-swatch-grid { grid-column: 1 / -1; margin-top: 0; }
/* Breathing room for the Brand & Visual System block. The type sheet
   rides in a headless continuation section, which the global rule pulls
   up to a 20px gap; this one needs a real pause after the moodboard
   paragraph, plus more air between the sheet, photo, and swatches. */
.case .case-section:not(:first-child):has(.cs-landing-side--type) {
  margin-top: calc(clamp(44px, 6vw, 80px) - clamp(32px, 4.5vw, 56px));
}
.case .case-section:has(+ .case-section .cs-landing-side--type) .cs-landing-desc { margin-top: clamp(20px, 2.4vw, 28px); }
.case .cs-landing-side--type {
  column-gap: clamp(28px, 5vw, 64px);
  row-gap: clamp(40px, 5vw, 64px);
}
.case .cs-landing-side--type .cs-swatch-grid { gap: 12px; }

@media (max-width: 760px) {
  .case .cs-landing-side--type { grid-template-columns: minmax(0, 1fr); row-gap: 36px; }
  .case .cs-landing-side--type .cs-type-sheet,
  .case .cs-landing-side--type > .cs-landing-row { grid-column: 1; grid-row: auto; }
  .case .cs-landing-side--type .cs-landing-row .cs-landing-img { min-height: 0; aspect-ratio: 961 / 928; }
}

.cs-swatch-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
  gap: 10px;
  margin-top: 34px;   /* a clearer break from the type list — separate chapter (color, not type) */
}
/* --surface-card alone reads as flush white next to the page — same gap as
   .cs-statement and .cs-principle-card had; same fix, a border so the card
   (and the meta strip's fill, which has none of its own and depends on
   this one showing through) reads as a defined card instead of just the
   chip's own inset hairline floating in space. */
.cs-swatch {
  background: var(--surface-card);
  border: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
}
.cs-swatch__chip {
  display: block;
  height: 92px;
  /* inset hairline so a near-paper swatch on the light card (and INK on
     the dark card) still shows a clean edge. */
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--ink-1) 10%, transparent);
}
/* flex-wrap, not overflow: hidden or an ellipsis: a hex code is real
   information, and truncating it would just hide the bug instead of
   fixing it. Flex children default to min-width: auto, so name and hex
   never shrink below their own content width — at the grid's narrow
   floor (.cs-swatch-grid bottoms out at 104px), "Current" + "#768998"
   together are wider than the card, and with no wrap that overflowed
   the swatch outright (the reported bug). Wrapping drops the hex to its
   own line instead; each half easily fits alone. */
.cs-swatch__meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 3px 8px;
  padding: 9px 11px 11px;
}
.cs-swatch__name {
  font-family: var(--font-primary);
  font-weight: 600;
  font-size: var(--t-meta);
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-2);
}
.cs-swatch__hex {
  font-family: var(--font-mono);
  font-size: var(--t-meta);
  letter-spacing: 0.02em;
  color: var(--ink-4);
}

.cs-landing-row--full {
  grid-template-columns: 1fr;
}

.cs-wip-notice {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px;
  border-radius: 0;
  border: 1px dashed color-mix(in oklab, var(--ink-1) 18%, transparent);
}

.cs-wip-badge {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-1);
  opacity: 0.55;
  animation: wip-pulse 2.8s ease-in-out infinite;
}

@keyframes wip-pulse {
  0%, 100% { opacity: 0.55; }
  50%       { opacity: 0.85; }
}

/* ── Case study micro-interactions ───────────────────────────── */

/* Images — subtle scale on hover */

/* Principle cards */
/* Principle cards stay flat — no hover shadow reveal, tilt, or press lift. */
.cs-principle-card {
  transition: none;
}

/* Persona cards */
.cs-persona-card {
  transition:
    background 200ms var(--ease-out-soft),
    transform  200ms var(--ease-out-soft),
    border-color 200ms var(--ease-out-soft);
}

.cs-persona-card:hover {
  background: color-mix(in oklab, var(--ink-1) 6%, transparent);
  transform: translateY(-2px);
}

.cs-persona-card:active {
  transform: translateY(0) scale(0.99);
  transition-duration: 100ms;
}
/* ── Assumed vs actual persona (full-page cases) ────────────────────
   The assumed persona is the draft that got thrown out, so it is drawn
   like one: a dashed edge instead of a ruled one, no plate, its ink
   faded, and knocked slightly off square like a sheet pushed aside.
   Same idea as the contrast fork, where the rejected option is the one
   still in pencil. The actual persona keeps a clean, firmer frame. */
.case .cs-persona-card--assumed,
.case .cs-persona-card--actual { position: relative; }

.case .cs-persona-card--assumed {
  background: transparent;
  border: 1px dashed color-mix(in oklab, var(--ink-1) 32%, transparent);
  transform: rotate(-0.8deg);
}
/* Fade the words, never the mark (62% keeps 13px body above 4.5:1).
   The X lives INSIDE the Assumed pill, so the pill cannot take opacity
   (it would fade the mark with it): it fades by colour instead. */
.case .cs-persona-card--assumed .cs-persona-name,
.case .cs-persona-card--assumed .cs-persona-demo,
.case .cs-persona-card--assumed .cs-persona-block { opacity: 0.62; }
.case .cs-persona-card--assumed .cs-persona-loc {
  opacity: 1;
  color: var(--ink-4);
}
.case .cs-persona-card--assumed:hover {
  background: transparent;
  transform: rotate(-0.8deg) translateY(-2px);
}
.case .cs-persona-card--assumed:active { transform: rotate(-0.8deg) scale(0.99); }

/* Same reason as the Assumed pill above: the tick is drawn INSIDE this
   pill, and the base .cs-persona-loc opacity (0.55) would fade the mark
   with the word — a 55% --accent-blue on paper reads as the periwinkle
   the system bans. Fade by colour instead, matched to the tone the
   opacity was producing, and the mark stays true blue. */
.case .cs-persona-card--actual .cs-persona-loc {
  opacity: 1;
  color: color-mix(in oklab, var(--ink-1) 55%, var(--surface-base));
}

.case .cs-persona-card--actual {
  background: var(--surface-base);
  border-color: color-mix(in oklab, var(--ink-1) 22%, transparent);
}

/* ── Timeline (js/case.js buildTimeline) ────────────────────────────
   A progression read down a HAND-DRAWN spine: a pen line in ink that
   opens with a small loop and wanders about a pixel either side of
   true, generated at the timeline's real height by initTimeline(). The
   same hand as the doodles and the bio letter's signature, but in ink
   rather than the accent: a line running the height of a section is
   structure, and a full-height blue stroke would not be "sparing".
   Entries zigzag either side of it; each node dot sits on the spine
   level with its stage tag. Full-page only, scoped under .case. */
.case .cs-tl {
  position: relative;
  margin: var(--space-4) 0 var(--space-6);
}
.case .cs-tl__spine {
  position: absolute;
  top: 0;
  left: 50%;
  width: 40px;
  /* Pre-JS fallback only: initTimeline() always sets an explicit inline
     px height once it measures the page. With no .cs-tl__outcome that
     target is the full container (same as 100%); with one, it stops
     exactly at the outcome's top edge instead of running through the
     picture — the gap below is real whitespace, but the LINE keeps
     going the whole way down it, so nothing reads as cut off. */
  height: 100%;
  margin-left: -20px;
  overflow: visible;
  pointer-events: none;
}
.case .cs-tl__path {
  fill: none;
  stroke: var(--ink-1);
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.case .cs-tl__list {
  list-style: none;
  margin: 0;
  /* the loop is the spine's first 106px: entries start below it */
  padding: 124px 0 var(--space-5);
  display: flex;
  flex-direction: column;
  gap: var(--space-7);
}
.case .cs-tl__item {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr);
  align-items: center;
}

/* The timeline's payoff (Circles: the 29% chart) — a full-width cap
   after the last item, not one more alternating row: it is the RESULT
   of the sequence, not another step in it. A real gap above it (more
   than the between-item --space-7, since this is a bigger beat), but
   the spine keeps running through that gap rather than stopping at the
   last node and leaving it stranded — see .cs-tl__spine's comment. */
.case .cs-tl__outcome {
  margin-top: var(--space-8);
}
.case .cs-tl__outcome-img {
  display: block;
  width: 100%;
  height: auto;
}
/* Timeline outcome as a live share bar (js/case.js buildBars/initBars).
   A full grey track; the accent segment grows into it from 0 to its
   share once the bar scrolls into view (.is-armed parks it empty, .is-in
   releases it). Segments and panel carry --radius-content (see "Content
   corners"). No JS or reduced motion: shown at its share. */
.case .cs-bars {
  position: relative;
  margin: 0;
  border-radius: var(--radius-content); /* the panel and its sky photo; see "Content corners" */
  overflow: hidden;
  padding: var(--space-7) 20%;
  isolation: isolate;
}
.case .cs-bars--backdrop {
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
}
/* Night keeps the sky photo but lays the page's own surface over it, so
   the flipped (light) ink stays legible on it. */
html[data-theme="dark"] .case .cs-bars--backdrop::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: color-mix(in srgb, var(--surface-base) 72%, transparent);
}
.case .cs-bars__track {
  display: flex;
  height: 40px;
}
.case .cs-bars__fill,
.case .cs-bars__rest { border-radius: var(--radius-content); }
.case .cs-bars__fill {
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  width: calc(var(--v) * 100%);
  margin-right: 4px;
  background: var(--brand-circles);
}
.case .cs-bars__rest { flex: 1; background: color-mix(in srgb, var(--ink-4) 60%, var(--surface-base)); }
.case .cs-bars__value {
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--surface-base);
  white-space: nowrap;
}
.case .cs-bars.is-armed .cs-bars__fill {
  width: 0;
  margin-right: 0;
  transition:
    width 1200ms var(--ease-out-expo) 150ms,
    margin-right 200ms var(--ease-out-soft) 150ms;
}
.case .cs-bars.is-armed .cs-bars__value {
  opacity: 0;
  transition: opacity 300ms var(--ease-out-soft) 900ms;
}
.case .cs-bars.is-armed.is-in .cs-bars__fill { width: calc(var(--v) * 100%); margin-right: 4px; }
.case .cs-bars.is-armed.is-in .cs-bars__value { opacity: 1; }
.case .cs-bars__cap {
  margin: var(--space-3) 0 0;
  font-family: var(--font-body);
  font-size: 14px;
  line-height: 1.55;
  text-align: center;
  color: var(--ink-1);
}
@media (prefers-reduced-motion: reduce) {
  .case .cs-bars.is-armed .cs-bars__fill { width: calc(var(--v) * 100%); margin-right: 4px; transition: none; }
  .case .cs-bars.is-armed .cs-bars__value { opacity: 1; transition: none; }
}
@media (max-width: 560px) {
  .case .cs-bars { padding: var(--space-6) var(--space-4); }
  .case .cs-bars__track { height: 32px; }
  .case .cs-bars__value { font-size: var(--t-small); }
}

.case .cs-tl__text { position: relative; grid-row: 1; max-width: 300px; }
.case .cs-tl__media { grid-row: 1; line-height: 0; }
.case .cs-tl__img { display: block; width: 100%; height: auto; }

/* odd: text left, set toward the spine; image right. even: mirrored. */
.case .cs-tl__item:nth-child(odd)  .cs-tl__text  { grid-column: 1; justify-self: end; text-align: right; }
.case .cs-tl__item:nth-child(odd)  .cs-tl__tag   { margin-left: auto; }
.case .cs-tl__item:nth-child(odd)  .cs-tl__media { grid-column: 3; }
.case .cs-tl__item:nth-child(even) .cs-tl__media { grid-column: 1; }
.case .cs-tl__item:nth-child(even) .cs-tl__text  { grid-column: 3; justify-self: start; text-align: left; }

/* The node is centred on the spine: the text column's inner edge plus
   half the 64px gutter, less half the dot. Level with the tag's middle:
   the tag is 23px tall and sits flush at the top of the text block. */
.case .cs-tl__node {
  position: absolute;
  top: 8px;
  width: 7px;
  height: 7px;
  border-radius: 50%;   /* a dot, not a rounded surface */
  background: var(--ink-1);
}
.case .cs-tl__item:nth-child(odd)  .cs-tl__node { right: -35.5px; }
.case .cs-tl__item:nth-child(even) .cs-tl__node { left: -35.5px; }

/* the stage marker: the .chip-tag pill family */
.case .cs-tl__tag {
  /* a block, not inline: inline it rode the text's line box and landed
     ~4px below the node it is meant to sit level with */
  display: flex;
  width: fit-content;
  align-items: center;
  font-family: var(--font-primary);
  font-weight: 600;
  font-size: var(--t-eyebrow);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-2);
  padding: 4px 8px 3px;
  border: 1.2px solid color-mix(in oklab, var(--ink-1) 35%, transparent);
  border-radius: var(--radius-control);
  background: var(--surface-base);
}
.case .cs-tl__title {
  margin: var(--space-3) 0 var(--space-2);
  font-family: var(--font-primary);
  font-size: var(--t-card-title);
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--ink-1);
}
.case .cs-tl__body {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-3);
}

/* Motion, only once initTimeline() has armed it. The spine inks with the
   scroll (dashoffset set from JS, no transition: it follows the reader
   exactly), nodes appear as the line reaches them, entries rise in. */
.case .cs-tl.is-armed .cs-tl__path { stroke-dasharray: 1 2; stroke-dashoffset: 1; }
.case .cs-tl.is-armed .cs-tl__node { opacity: 0; transition: opacity 240ms var(--ease-out-quart); }
.case .cs-tl.is-armed .cs-tl__node.is-reached { opacity: 1; }
.case .cs-tl.is-armed .cs-tl__item {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 560ms var(--ease-out-quart), transform 560ms var(--ease-out-quart);
}
.case .cs-tl.is-armed .cs-tl__item.is-in { opacity: 1; transform: none; }

/* Phone: one column, spine on the left, text above its image. */
@media (max-width: 767px) {
  .case .cs-tl__spine { left: 0; margin-left: 0; }
  .case .cs-tl__list { padding-top: 116px; gap: var(--space-6); }
  .case .cs-tl__item:nth-child(odd) .cs-tl__tag { margin-left: 0; }
  .case .cs-tl__item { grid-template-columns: 44px minmax(0, 1fr); row-gap: var(--space-4); align-items: start; }
  .case .cs-tl__item:nth-child(odd)  .cs-tl__text,
  .case .cs-tl__item:nth-child(even) .cs-tl__text { grid-column: 2; grid-row: 1; justify-self: start; text-align: left; max-width: none; }
  .case .cs-tl__item:nth-child(odd)  .cs-tl__media,
  .case .cs-tl__item:nth-child(even) .cs-tl__media { grid-column: 2; grid-row: 2; }
  /* spine at x=20 of a 44px gutter: 24px back from the text, less half the dot */
  .case .cs-tl__item:nth-child(odd)  .cs-tl__node,
  .case .cs-tl__item:nth-child(even) .cs-tl__node { left: -27.5px; right: auto; }
}
@media (prefers-reduced-motion: reduce) {
  .case .cs-tl .cs-tl__path { stroke-dasharray: none; stroke-dashoffset: 0; }
  .case .cs-tl .cs-tl__node,
  .case .cs-tl .cs-tl__item { opacity: 1; transform: none; transition: none; }
}

/* ── Hand-drawn marks (PortfolioUI.renderDoodle) ────────────────────
   The same pen as the bio letter's signature (.bl-sig__path): a thin
   accent-blue line with the tremor of a real hand in the path itself,
   drawn stroke by stroke at a constant rate with a lift between
   strokes. One pen for both marks, like one person annotating the
   page; the X and the tick tell wrong from right by shape alone. Sits
   over the card's top-right corner, as if written on after the fact. */
.cs-doodle {
  position: absolute;
  top: -34px;
  right: -26px;
  width: 82px;
  height: 82px;
  overflow: visible;
  pointer-events: none;
  z-index: 2;
  color: var(--accent-blue);
}
.cs-doodle--x    { transform: rotate(-6deg); }
/* the X is on the LEFT card: tucked in so its down-stroke ends inside
   the 14px gap rather than on the other persona's border */
.cs-doodle--x { right: -12px; }
.cs-doodle--tick { transform: rotate(4deg); }
.cs-doodle__stroke {
  fill: none;
  stroke: currentColor;
  /* ~2px at this size, the signature's weight */
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* On a persona card the mark is drawn ON the Assumed / Actual pill
   rather than in the card's corner: the X crosses the word out, the tick
   lands over its right end. Anchored to the pill itself, so it follows
   the pill wherever the header puts it and the pill keeps its normal
   padding (reserving a corner for the mark left an awkward gap). */
.case .cs-persona-card--assumed .cs-persona-loc,
.case .cs-persona-card--actual .cs-persona-loc { position: relative; overflow: visible; }
.case .cs-persona-loc .cs-doodle {
  top: 50%;
  right: auto;
  left: 50%;
  width: 60px;
  height: 60px;
  /* both marks sit a touch right of dead centre on their pill */
  transform: translate(calc(-50% + 5px), -50%) rotate(-6deg);
}
.case .cs-persona-loc .cs-doodle--tick {
  left: 78%;
  width: 58px;
  height: 58px;
  transform: translate(calc(-40% + 5px), -64%) rotate(4deg);
}
/* the box is smaller here, so the stroke is thickened in viewBox units
   to keep the signature's ~2px pen on screen */
.case .cs-persona-loc .cs-doodle__stroke { stroke-width: 3.4; }

/* dasharray "1 2" (not "1"), as on the signature: a plain "1" lets the
   round cap at the dash boundary show as a dot before the pen arrives */
.cs-doodle.is-armed .cs-doodle__stroke,
.cs-annot__ink.is-armed .cs-annot__stroke {
  stroke-dasharray: 1 2;
  stroke-dashoffset: 1;
}
/* linear, deliberately, like the signature: ink leaves a moving pen at
   a roughly constant rate (DESIGN.md's constant-rate exception) */
.cs-doodle.is-armed.is-drawn .cs-doodle__stroke,
.cs-annot__ink.is-armed.is-drawn .cs-annot__stroke {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset var(--dur, 300ms) linear var(--delay, 0ms);
}
@media (max-width: 719px) {
  /* pulled in so the overhang never pushes the page sideways */
  .cs-doodle,
  .cs-doodle--x { top: -26px; right: -12px; width: 64px; height: 64px; }
  .case .cs-persona-loc .cs-doodle { top: 50%; right: auto; width: 50px; height: 50px; }
  .case .cs-persona-loc .cs-doodle--tick { width: 44px; height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
  .cs-doodle .cs-doodle__stroke,
  .cs-annot__ink .cs-annot__stroke { stroke-dashoffset: 0 !important; transition: none !important; }
}

/* Font specimen boxes */
.cs-font-box {
  transition:
    background    200ms var(--ease-out-soft),
    border-color  200ms var(--ease-out-soft),
    transform     200ms var(--ease-out-soft);
}

.cs-font-box:hover {
  background: color-mix(in oklab, var(--ink-1) 4%, transparent);
  border-color: color-mix(in oklab, var(--ink-1) 20%, transparent);
  transform: translateY(-1px);
}

/* Contributions list: static on hover in both systems. The sideways
   nudge and the numeral fade were removed on request (Sept 2026); these
   rows are read, not pressed. */

/* Deep list spray boxes — lift on hover */
.cs-deep-list li {
  transition:
    transform   240ms var(--ease-out-soft),
    background  200ms var(--ease-out-quart);
}

.cs-deep-list li:hover {
  transform: translateY(-2px);
  background: color-mix(in oklab, var(--ink-1) 6%, transparent);
  border-color: color-mix(in oklab, var(--ink-1) 14%, transparent);
}

/* Compare table rows — deepen + nudge on hover */
.cs-compare-row:not(.cs-compare-row--head) {
  transition:
    background 160ms var(--ease-out-quart),
    transform  160ms var(--ease-out-soft);
}

.cs-compare-row:not(.cs-compare-row--head):hover {
  background: color-mix(in oklab, var(--ink-1) 7%, transparent);
  transform: translateX(2px);
}

/* ── Panel open — staggered content entrance ─────────────────── */
@keyframes cs-item-in {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}

.cs-overlay.is-open .cs-hero {
  animation: cs-item-in 460ms var(--ease-out-soft) 200ms both;
}

.cs-overlay.is-open .cs-body .cs-section:nth-child(1) {
  animation: cs-item-in 420ms var(--ease-out-soft) 290ms both;
}

.cs-overlay.is-open .cs-body .cs-section:nth-child(2) {
  animation: cs-item-in 420ms var(--ease-out-soft) 380ms both;
}

/* Prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  .cs-deep-section {
    opacity: 1;
    transform: none;
    transition: none;
  }
  .cs-landing-img,
  .cs-landing-frame,
  .cs-principle-card,
  .cs-persona-card,
  .cs-font-box,
  .cs-contributions-list li,
  .cs-contributions-list li::before,
  .cs-deep-list li,
  .cs-compare-row:not(.cs-compare-row--head) {
    transition: none;
  }
  .cs-wip-badge {
    animation: none;
  }
  .cs-overlay.is-open .cs-hero,
  .cs-overlay.is-open .cs-body .cs-section {
    animation: none;
  }
}

.cs-caption-image {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* One frame treatment shared by hero, body and phone captures:
   a single hairline on the frame surface — no thick mat, no inset
   shadow. Keeps every image on the page reading as the same object. */
.cs-landing-frame {
  position: relative;
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  border-radius: 0;
  overflow: hidden;
  background: var(--surface-frame);
}

.cs-landing-img {
  display: block;
  width: 100%;
  border-radius: 0;
  cursor: zoom-in;
  transition: filter 260ms var(--ease-out-quart), opacity 260ms var(--ease-out-quart);
}

/* ── Cohesive image framing ─────────────────────────────────────────
   Every case image sits in the same hairline frame with a soft, subtle
   shadow for depth. The shadow uses literal black (not an ink token) so
   it stays a real shadow in the dark theme instead of inverting to a
   glow — the same trick as .cs-canvas__paper. Edit here for all images. */
.cs-landing-frame,
.cs-phone-frame,
.case-hero__media,
.cs-specimen__mat,
.cs-scenes__stage {
  box-shadow:
    0 2px 8px -3px rgba(0, 0, 0, 0.08),
    0 12px 30px -16px rgba(0, 0, 0, 0.13);
}
/* The specimen mat was borderless by design; bring it into the family so
   every image reads as one framed set. */
.cs-specimen__mat {
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  overflow: hidden;
}

/* Opt-in (`frameless: true` on an `image` section) for a capture whose
   own corners are already rounded — a cutout exported with a radius
   baked into its alpha channel, not the house's square content plate.
   Drops the surface-frame plate behind it (the export needs no ground)
   but keeps the hairline and the shared drop-shadow above, both traced
   to the SAME curve as the PNG (fit from its alpha channel: ~44.5px on
   a 2160×2046 export ≈ 2.1%/2.2% of the frame's own box, so it scales
   with the image at any width instead of a fixed px going stale on a
   re-export). Without this the hairline runs square across a rounded
   photo and visibly floats off its corner.
   Deliberate, named exception to the site's zero-radius content law —
   see DESIGN.md — for a capture that carries a radius of its own,
   same footing as tarotnook's specimen exception. Never generalize it
   to a section whose image is a flat rectangle. */
.cs-landing-frame--bare {
  border-radius: 2.1% / 2.2%;
  background: none;
  overflow: visible;
}
.cs-landing-frame--bare .cs-landing-img { border-radius: inherit; }

/* ── Content corners (Oct 2026) ─────────────────────────────────────
   Content surfaces carry --radius-content (4px), in both case systems:
   every case image frame, the plates and
   cards that sit beside them, and the homepage project thumbnails. The
   contact rows' tiny thumbnails take --radius-thumb (2px) and small tags
   take --radius-control (3px), like the chips. Each
   frame here clips, so rounding the frame rounds the image, and its
   hairline and shadow follow the curve. Two exceptions keep their own
   corners: Circles' bare PDP pair (the curve fit to its alpha channel,
   above) and the redrawn product parts inside the shift morph (tiles,
   panels, rings). The shift morph's plate and screenshots set theirs
   in place, beside the rules they would otherwise lose to. The modal's
   media frames and facts box take the token in place too
   (.cs-split__hero/.cs-split__media, .cs-card--box); its component
   stills and videos (.cs-piece__still, .cs-piece__video) are in
   this block. The modal sheet
   itself stays at --radius-dock, so its 4px images nest inside a
   rounder outer corner.
   See DESIGN.md "Radius". */
.case .case-hero__media,
.case .cs-landing-frame:not(.cs-landing-frame--bare),
.case:not([data-case="circles"]) .cs-landing-frame--bare,
.case .cs-specimen__mat,
.case .cs-wipe__frame,
.case .cs-tl__img,
.case .cs-canvas,
.case .cs-impact,
.case .cs-metric,
.case .cs-persona-card,
.case .cs-statement,
.case .cs-swatch,
.case .cs-contrast__panel,
.cs-panel--split .cs-piece__still,
.cs-panel--split .cs-piece__video,
.cs-scenes__stage,
.card__frame,
.card__frame::after,
.card__frame-shape,
.card__soon {
  border-radius: var(--radius-content);
}
/* The swatch chip sits flush in its card's top edge: round its top to
   the card's inner curve (outer minus the 1px border). */
.case .cs-swatch__chip {
  border-radius: calc(var(--radius-content) - 1px) calc(var(--radius-content) - 1px) 0 0;
}
.case .cs-persona-loc,
.case .still-embed__hint,
.card__meta dd {
  border-radius: var(--radius-control);
}
.contact-row__thumb-wrap {
  border-radius: var(--radius-thumb);
  overflow: hidden;
}

/* ── Specimen card — museum / catalogue-label treatment ─────────
   Deliberately a second, heavier frame language than .cs-landing-frame
   above: the image floats on a matted ground (real padding, not flush
   edges) and a monospace accession label runs below as one line, the
   way a gallery wall label or library catalogue card reads. Reserve
   this for images that earn the ceremony (a hero artifact, a specimen
   set) — the plain landing-frame stays the default for screenshots. */
.cs-specimen-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 20px;
}
.cs-specimen-grid--single {
  grid-template-columns: 1fr;
  max-width: 560px;
}
.cs-specimen-grid--stack {
  grid-template-columns: 1fr;
  gap: clamp(36px, 5vw, 56px);
}
/* No frame, no grey mat, no boxed caption bar: the figure sits directly on
   the paper and the caption reads as a clean tracked label over body text. */
.cs-specimen {
  display: flex;
  flex-direction: column;
}
.cs-specimen__mat {
  padding: 0;
  background: none;
}
/* Named, one-off exception to the site's zero border-radius law — see
   DESIGN.md "Radius" and CLAUDE.md's non-negotiables. Scoped to the
   single figure carrying `rounded: true` in its data (currently just
   tarotnook's Landing Page specimen), not the shared .cs-specimen__mat
   class, so it can never leak onto another image by inheritance. The
   mat already clips overflow, so rounding the mat rounds the image. */
.cs-specimen__mat--rounded { border-radius: 14px; }
.cs-specimen__img {
  display: block;
  width: 100%;
  height: auto;
  cursor: zoom-in;
  transition: filter 260ms var(--ease-out-quart), opacity 260ms var(--ease-out-quart);
}
.cs-specimen__caption {
  margin: 14px auto 0;
  max-width: 440px;
  padding: 0;
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.55;
  color: var(--ink-3);
  text-align: center;
}
.cs-specimen__id {
  color: var(--ink-1);
  font-weight: 700;
}

/* Phone-screenshot gallery — tall mobile captures sit in equal-height
   scroll windows so a very long screen stays compact. Same 6px frame
   as the standard image frames; you scroll within each to view it. */
.cs-phone-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
}

.cs-phone-item {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}

.cs-phone-media {
  position: relative;
  cursor: zoom-in;
  border-radius: 0;
}

.cs-phone-media:focus-visible {
  outline: 2px solid var(--ink-1);
  outline-offset: 3px;
}

.cs-phone-frame {
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  border-radius: 0;
  overflow-y: auto;
  overflow-x: hidden;
  height: clamp(380px, 60vh, 600px);
  background: var(--surface-frame);
  -webkit-overflow-scrolling: touch;
  scrollbar-width: thin;
  scrollbar-color: color-mix(in oklab, var(--ink-1) 22%, transparent) transparent;
}

/* "Expand" affordance — makes it obvious the screen opens larger */
.cs-phone-expand {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 3;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 5px 9px;
  border-radius: 0;
  font-size: var(--t-meta);
  font-weight: 600;
  letter-spacing: 0.02em;
  color: #fff;
  background: rgba(20, 16, 12, 0.62);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  pointer-events: none;
  opacity: 0;
  transform: translateY(-3px);
  transition: opacity 200ms var(--ease-out-quart), transform 200ms var(--ease-out-quart);
}

.cs-phone-media:hover .cs-phone-expand,
.cs-phone-media:focus-visible .cs-phone-expand {
  opacity: 1;
  transform: translateY(0);
}

@media (hover: none) {
  .cs-phone-expand { opacity: 1; transform: translateY(0); }
}

.cs-phone-media .cs-phone-img { cursor: zoom-in; }

.cs-phone-frame::-webkit-scrollbar { width: 6px; }
.cs-phone-frame::-webkit-scrollbar-thumb {
  background: color-mix(in oklab, var(--ink-1) 22%, transparent);
  border-radius: 0;
}

.cs-phone-img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 0;
  transition: filter 260ms var(--ease-out-quart), opacity 260ms var(--ease-out-quart);
}

@media (max-width: 719px) {
  .cs-phone-row { grid-template-columns: 1fr; gap: 16px; }
  .cs-phone-frame { height: clamp(340px, 64vh, 540px); }
}

/* ── Mobile: same single column, tighter ─────────────────────── */
@media (max-width: 719px) {
  /* dvh adapts as the browser URL bar appears/disappears */
  .cs-overlay {
    padding: 14px;
    align-items: center;
  }

  .cs-panel {
    width: 100%;
    height: auto;
    max-height: 90dvh;
    border-radius: 0;
  }

  .cs-head {
    padding: 14px 16px 10px;
    flex-wrap: wrap;
    gap: 8px 12px;
  }

  .cs-title { font-size: 16px; }

  /* Meta strip stacks into label-left / value-right rows */
  .cs-meta-strip {
    margin: 0 16px;
    grid-template-columns: 1fr;
    gap: 0;
    padding: 3px 0;
  }

  .cs-meta-cell {
    flex-direction: row;
    align-items: baseline;
    justify-content: space-between;
    gap: 8px;
    padding: 7px 0;
    border-top: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
  }

  .cs-meta-cell:first-child { border-top: none; }

  .cs-meta-value { text-align: right; }

  .cs-hero { height: 150px; }

  .cs-body {
    grid-template-columns: 1fr;
    padding: 16px;
    gap: 20px;
  }

  .cs-deep-dive-body {
    padding: 20px 16px 32px;
    gap: 28px;
  }

  .cs-principles-grid {
    grid-template-columns: 1fr;
  }

  .cs-compare-row:not(.cs-compare-row--head) { padding: 8px 10px; }
  .cs-compare-cell { font-size: var(--t-meta); }
  .cs-compare-row--head { padding: 0 10px 6px; }
}

@media (max-width: 900px) {
  .cs-landing-side {
    flex-direction: column;
  }

  .cs-landing-side .cs-landing-row {
    flex: none;
    width: 100%;
  }

  .cs-landing-side .cs-landing-desc {
    width: 100%;
  }

  .cs-font-grid {
    grid-template-columns: 1fr;
  }
}

/* ── Image lightbox ──────────────────────────────────────────── */
/* #cs-lightbox-case (not the bare #cs-lightbox the id used to be): the
   modal case system (js/casestudy.js) creates its OWN, separate element
   with id="cs-lightbox" for the ok-bye and Fairy-tale UI popups, scoped via its own
   .cs-panel #cs-lightbox rules further down. Both systems only ever
   coexisted in the same document once the persistent-nav shell merged
   full-page cases into index.html (see js/portfolio-nav.js) — before
   that, this file's own auto-boot never ran there, so the shared name
   never collided. Once it could collide, case.js's own re-invocation
   guard (`if (document.getElementById('cs-lightbox')) return;`) would
   find casestudy.js's element first and skip attaching its own listeners
   entirely, silently breaking every full-page case's lightbox on the
   shell. Renamed the ID to fix that; the .cs-lightbox-* classes below
   stay shared between both systems since a class, unlike an id lookup,
   was never the thing colliding. */
/* top/left/width/height (not inset: 0) because position: fixed is
   anchored to the layout viewport, not whatever the browser is
   currently showing. Pinch-zoom lets those diverge — the visible
   (visual) viewport becomes a smaller, panned rectangle inside the
   full page — so a fixed inset: 0 box still covers the whole layout
   viewport while the flex-centered image centers within it, landing
   off-screen-center relative to what the person can actually see. The
   --vv-* custom properties (js/case.js initLightbox, kept in sync with
   window.visualViewport on resize/scroll) pin this box to the real
   visible rectangle instead; the px fallbacks reproduce inset: 0 when
   visualViewport is unavailable. */
#cs-lightbox-case {
  position: fixed;
  top: var(--vv-top, 0px);
  left: var(--vv-left, 0px);
  width: var(--vv-width, 100vw);
  height: var(--vv-height, 100vh);
  z-index: 9999;
  background: rgba(0, 0, 0, 0.72);
  display: flex;
  align-items: center;
  justify-content: center;
  /* ONE sizing rule for the whole lightbox: equal padding on every side,
     and the image fits whatever is left (see .cs-lightbox-img). It used
     to be three caps that disagreed: the centred wrapper stopped at 78vh
     while the image inside it was allowed 88vh, so the wrapper centred
     and the image hung 10vh out of its bottom. More space above than
     below, the bottom pressed into the screen edge (read as cropped),
     and a 64vw wrapper under an 82vw image (read as zoomed). The close
     button was not the cause: it is absolute and takes no space. The
     vertical padding is sized to clear it anyway (it ends 56px down), so
     the image can never sit under the button. */
  --lb-py: clamp(64px, 9vh, 96px);
  --lb-px: clamp(64px, 6vw, 120px);
  padding: var(--lb-py) var(--lb-px);
  box-sizing: border-box;
  opacity: 0;
  pointer-events: none;
  transition: opacity 220ms var(--ease-out-quart);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

#cs-lightbox-case.is-open {
  opacity: 1;
  pointer-events: all;
}

.cs-lightbox-inner {
  /* no caps of its own: the image carries the only size limit */
  transform: scale(0.94);
  transition: transform 260ms var(--ease-out-soft);
}

#cs-lightbox-case.is-open .cs-lightbox-inner {
  transform: scale(1);
}

.cs-lightbox-frame {
  position: relative;
  width: fit-content;
  max-width: 100%;
  margin: 0 auto;
  border-radius: 0;
  overflow: hidden;
  box-shadow:
    0 32px 80px rgba(0, 0, 0, 0.38),
    0 8px 24px rgba(0, 0, 0, 0.18);
}

/* width/height: auto (not 100%) — a percentage width here has no fixed
   reference (the frame is itself shrink-to-fit inside a flex-centered,
   auto-width chain), so it can resolve against a stale or indeterminate
   box and render the image off-center within its own frame. Auto sizing
   lets the image's intrinsic size drive the frame directly. */
.cs-lightbox-img {
  display: block;
  width: auto;
  height: auto;
  /* the space inside the overlay's padding, on the visible viewport.
     auto width/height mean a small image shows at its real size rather
     than being blown up to fill the box. The fallbacks serve the modal
     system's #cs-lightbox, which shares these classes. */
  max-width: calc(var(--vv-width, 100vw) - 2 * var(--lb-px, 64px));
  max-height: calc(var(--vv-height, 100vh) - 2 * var(--lb-py, 64px));
  object-fit: contain;
  border-radius: 0;
}
/* same [hidden]-vs-class specificity trap as .cs-body[hidden] etc. above:
   plain [hidden] ties display:block above, so the img/video sibling that
   open() leaves hidden would still lay out at its replaced-element
   default size (the video's 300x150) and pad the frame past the visible
   media, throwing off the centering. */
.cs-lightbox-img[hidden] { display: none; }

/* absolute, not fixed — its containing block is #cs-lightbox-case
   itself (already position: fixed), so once that root tracks the
   visual viewport (see the comment above it), the button inherits the
   correction for free instead of needing its own layout-viewport math. */
.cs-lightbox-close {
  position: absolute;
  top: 22px;
  right: 26px;
  width: 34px;
  height: 34px;
  border-radius: var(--radius-control);
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.1);
  color: rgba(255, 255, 255, 0.75);
  font-size: 12px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition:
    background 180ms var(--ease-out-quart),
    border-color 180ms var(--ease-out-quart),
    transform 160ms var(--ease-out-quart);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

.cs-lightbox-close:hover {
  background: rgba(255, 255, 255, 0.18);
  border-color: rgba(255, 255, 255, 0.32);
  color: #fff;
}
.cs-lightbox-close:active { transform: scale(0.97); }
/* scroll lock while the full-page lightbox is open (js/case.js) */
html.cs-lightbox-lock,
html.cs-lightbox-lock body { overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
  .cs-lightbox-close:active { transform: none; }
}

.cs-landing-img {
  cursor: zoom-in;
}

/* Tall mode — full-page mobile captures open at a readable phone width and
   scroll vertically inside the frame, instead of shrinking to fit height. */
#cs-lightbox-case.is-tall .cs-lightbox-frame {
  width: min(440px, calc(var(--vv-width, 100vw) - 2 * var(--lb-px)));
  max-height: calc(var(--vv-height, 100vh) - 2 * var(--lb-py));
  overflow-y: auto;
  overflow-x: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.4) transparent;
}

#cs-lightbox-case.is-tall .cs-lightbox-frame::-webkit-scrollbar { width: 8px; }
#cs-lightbox-case.is-tall .cs-lightbox-frame::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.4);
  border-radius: 0;
}

#cs-lightbox-case.is-tall .cs-lightbox-img {
  width: 100%;
  max-width: none;
  max-height: none;
  height: auto;
}

@media (max-width: 768px) {
  /* phones: the width is precious, so the sides tighten to 16px; the
     top and bottom stay equal and still clear the close button */
  #cs-lightbox-case { --lb-py: 72px; --lb-px: 16px; }
}

/* ════════════════════════════════════════════════════════════════
   Chat widget — AI-powered "ask Isabelle" panel
   Builds on the existing .msg-widget shell; only adds new classes.
   ════════════════════════════════════════════════════════════════ */

/* Cursor: include chat inputs in the I-beam exception */
.msg-widget .chat-input { cursor: text; }

/* Panel: allow it to grow up to ~520px before it clips */
.msg-widget__panel {
  max-height: min(520px, calc(100dvh - 80px));
}

/* Inner: no overrides needed — original styles handle layout correctly.
   height: 100% without an explicit parent height resolves to 0 in some
   contexts; overflow: hidden was clipping entry animations. Both removed. */

/* ── Chat surface — bare ink on the frosted glass ──────────────── */
/* The dark LCD terminal is retired (July 2026). The conversation sits
   directly on the milk glass: ink on frosted paper, no inner chrome. */
.chat-surface {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
/* The ask-anything block cursor: solid ink, and alive from first paint
   (the .is-blinking class only arrives after the panel first opens, so
   the collapsed bubble carries its own blink). */
.msg-widget__dock .terminal-cursor {
  background: var(--ink-1);
  opacity: 1;
  animation: cursor-blink 1.05s step-end infinite;
}

/* ── Input row — redesigned as a terminal command line ─────────── */

/* Gap for the prompt row */
.msg-widget__dock .chat-form { gap: 6px; }

/* Wrap holds input + following cursor together */
.chat-input-wrap {
  position: relative;
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
}

/* Override: cursor absolutely positioned, JS drives translateX to follow text */
.chat-input-wrap .terminal-cursor {
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateX(0px) translateY(-50%);
  vertical-align: unset;
  margin-left: 0;
  pointer-events: none;
  /* Bigger in this context — user-facing prompt cursor */
  width: 9px;
  height: 15px;
}

/* Custom placeholder — offset right of the cursor so they never overlap.
   Uses :placeholder-shown (input has placeholder=" ") for pure-CSS detection. */
.chat-placeholder {
  position: absolute;
  left: 13px;  /* 9px cursor + 4px breathing room */
  top: 50%;
  transform: translateY(-50%);
  pointer-events: none;
  font-family: var(--font-mono);
  font-size: var(--t-small); /* tracks .chat-input — it sits in the same slot */
  color: var(--ink-4);
  white-space: nowrap;
  transition: opacity 120ms var(--ease-out-quart);
  user-select: none;
  -webkit-user-select: none;
}

/* Fade out once the user has typed anything */
.chat-input:not(:placeholder-shown) ~ .chat-placeholder {
  opacity: 0;
}

/* › prompt symbol — anchors the row as a quiet command line */
.msg-widget__dock .chat-form__prefix {
  font-family: var(--font-mono);
  font-size: var(--t-small); /* tracks .chat-input, so the › optically aligns */
  line-height: 1;
  color: var(--ink-4);
  flex-shrink: 0;
  user-select: none;
  -webkit-user-select: none;
  margin-top: 1px; /* optical align to text baseline */
}

/* Input text — mono to match every other voice in the box, and the
   mirror span that measures the block cursor's position. */
.msg-widget__dock .chat-input {
  font-family: var(--font-mono);
  caret-color: transparent; /* █ in the form row is the only cursor */
}
.msg-widget__dock .chat-input::placeholder {
  color: transparent; /* visual placeholder is .chat-placeholder instead */
}

/* ── Scrollable message list ────────────────────────────────────── */
.chat-messages {
  display: flex;
  flex-direction: column;
  gap: 18px;
  max-height: 260px;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: color-mix(in srgb, var(--ink-1) 22%, transparent) transparent;
  margin-left: -4px;
  padding-left: 4px;
}

/* Chat scrollbar: a slim ink thumb with no track, floating in a 10px
   gutter so it reads as a pill with air around it rather than a stripe
   glued to the edge. Ink mixed down, not a surface grey (the old
   --surface-deep thumb vanished into the panel); darkens on hover and
   while dragged. Covers both scrollers: .chat-messages on desktop and
   the body that takes over scrolling on mobile. Chrome, so it carries
   --radius-control. Ink tokens flip with the theme on their own. */
.chat-messages::-webkit-scrollbar,
.tbar .msg-widget__body::-webkit-scrollbar { width: 10px; }
.chat-messages::-webkit-scrollbar-track,
.tbar .msg-widget__body::-webkit-scrollbar-track { background: transparent; }
.chat-messages::-webkit-scrollbar-thumb,
.tbar .msg-widget__body::-webkit-scrollbar-thumb {
  background: color-mix(in srgb, var(--ink-1) 22%, transparent);
  background-clip: padding-box;
  border: 3px solid transparent;
  border-radius: var(--radius-control);
  min-height: 32px;
}
.chat-messages::-webkit-scrollbar-thumb:hover,
.tbar .msg-widget__body::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--ink-1) 40%, transparent); }
.chat-messages::-webkit-scrollbar-thumb:active,
.tbar .msg-widget__body::-webkit-scrollbar-thumb:active { background-color: color-mix(in srgb, var(--ink-1) 55%, transparent); }
.tbar .msg-widget__body {
  scrollbar-width: thin;
  scrollbar-color: color-mix(in srgb, var(--ink-1) 22%, transparent) transparent;
}

/* ── Messages ───────────────────────────────────────────────────── */
.chat-msg { display: flex; }

/* User: right-aligned chip, enters from the right */
.chat-msg--user {
  align-self: flex-end;
  max-width: 84%;
  animation: chat-from-right 240ms var(--ease-out-expo) both;
}

/* Assistant: left-aligned, full-width, NO bubble.
   This is a Q&A column, not a support widget.
   The left alignment, bare typography, and ink-2 color
   are the only differentiators — that's enough.
   align-self: stretch (not flex-start) is load-bearing: flex-start would
   shrink-wrap the item to its widest line instead of filling the panel,
   starving the [[gallery]]/[[timeline]]/[[contact]] components of the
   width they need to read as rows, not a cramped column. */
.chat-msg--assistant {
  align-self: stretch;
  max-width: 100%;
  animation: chat-ink-in 340ms var(--ease-out-expo) both;
}

@keyframes chat-ink-in {
  from { opacity: 0; transform: translateY(4px); filter: blur(2.5px); }
  60%  { filter: blur(0.6px); }
  to   { opacity: 1; transform: translateY(0); filter: blur(0); }
}

/* Word-level ink morph — the hero's reveal at chat scale. Words
   materialise wet, hold for a beat, and dry in place; streamed replies
   morph word by word as they arrive (js/chatbot.js). No contrast()
   pass here: the milk glass is translucent, so the hero's solid-paper
   trick would print visible rectangles — at 13px, the blur alone
   reads as the same wet-ink family. */
.chat-ink-word { display: inline-block; }
@media (prefers-reduced-motion: no-preference) {
  /* 260ms, not the hero's 560: streamed words must dry as fast as they
     arrive or the tail of a reply reads as lag (Emil: UI under 300ms). */
  .chat-ink-word { animation: chat-ink-word 260ms var(--ease-out-quart) both; }
}
@keyframes chat-ink-word {
  0%   { opacity: 0; filter: blur(1.1px); }
  35%  { opacity: 1; filter: blur(0.85px); }
  100% { opacity: 1; filter: blur(0); }
}

@keyframes chat-from-right {
  from { opacity: 0; transform: translateX(8px); }
  to   { opacity: 1; transform: translateX(0);   }
}

@keyframes chat-from-below {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0);   }
}

/* User chip — base (light glass context). Still stamped ink (the
   site's loud-chip language), but softened for the dock era: it wears
   the chat panel's own radius and a diffuse shadow instead of the old
   square corner + 2px letterpress offset, which read brutal next to
   the rounded chrome around it. */
.chat-msg--user .chat-msg__text {
  font-family: var(--font-mono);
  font-size: var(--t-small);
  line-height: 1.6;
  padding: 7px 13px;
  border-radius: var(--radius-dock);
  background: var(--ink-1);
  color: var(--ink-inv);
  box-shadow: 0 3px 10px -4px color-mix(in oklab, var(--ink-1) 45%, transparent);
  overflow-wrap: break-word;
  max-width: 100%;
}

/* Assistant: bare prose on glass — no padding, no background.
   Mono, one voice with the input and the user chips. */
.chat-msg--assistant .chat-msg__text {
  font-family: var(--font-mono);
  font-size: 11.5px;
  line-height: 1.72;
  color: var(--ink-2);
  padding: 0;
  text-wrap: pretty;
  overflow-wrap: break-word;
  max-width: 100%;
}

/* Bullet list rendered inside assistant messages.
   Use padding-left + absolute ::before — NOT flexbox.
   Flex turns inline elements like <strong> into flex items
   which breaks word-wrap on mobile. */
.chat-list {
  margin: 4px 0 0;
  padding: 0;
  list-style: none;
  display: block;
}
.chat-list li {
  position: relative;
  padding-left: 14px;
  margin-bottom: 3px;
  font-size: var(--t-small);
  line-height: 1.65;
  color: var(--ink-2);
  overflow-wrap: break-word;
}
.chat-list li::before {
  content: '—';
  position: absolute;
  left: 0;
  top: 0;
  color: var(--ink-4);
  font-size: 11px;
}

/* Inline link chip — replaces raw URLs */
.chat-link {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-family: var(--font-mono);
  font-size: var(--t-meta);
  font-weight: 500;
  letter-spacing: 0.03em;
  padding: 3px 8px 2px;
  color: var(--ink-2);
  background: color-mix(in oklab, var(--ink-1) 6%, transparent);
  border: 1px solid color-mix(in oklab, var(--ink-1) 18%, transparent);
  border-radius: 0;
  text-decoration: none;
  cursor: pointer;
  transition:
    color         200ms var(--ease-out-quart),
    background    200ms var(--ease-out-quart),
    border-color  200ms var(--ease-out-quart),
    transform     300ms var(--ease-out-expo);
  vertical-align: middle;
  line-height: 1;
  white-space: nowrap;
}
.chat-link:hover {
  color: var(--ink-1);
  background: color-mix(in oklab, var(--ink-1) 11%, transparent);
  border-color: color-mix(in oklab, var(--ink-1) 35%, transparent);
  transform: translateY(-1px);
}
.chat-link:active { opacity: 0.72; }

/* First message (welcome): the opening line of the Q&A.
   Slightly more presence — ink-1 is the loudest reading weight. */
.chat-msg--assistant[data-first] .chat-msg__text {
  color: var(--ink-1);
}

/* ── Quick-action prompts ────────────────────────────────────────── */
/* Outline labels — "questions not yet asked"
   Empty border rather than filled chip: less aggressive, more editorial. */
.chat-prompts {
  display: block;
  counter-reset: opt;
  padding: 2px 0;
}

/* Dialogue choices, RPG-style: each option carries its letter, and the
   chosen row gets the ▸ cursor and a small step forward. No rules, no
   boxes; the letters and the cursor do all the work. */
.chat-prompt {
  display: flex;
  width: 100%;
  align-items: baseline;
  gap: 9px;
  font-family: var(--font-mono);
  font-size: var(--t-meta);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-align: left;
  padding: 5px 3px;
  background: transparent;
  color: var(--ink-2);
  border: none;
  border-radius: 0;
  cursor: pointer;
  transition:
    color     200ms var(--ease-out-quart),
    transform 260ms var(--ease-out-expo),
    filter    340ms var(--ease-out-quart),
    opacity   340ms var(--ease-out-quart);
  /* Stagger set via inline style in JS: animation-delay: Nms */
  animation: prompt-in 260ms var(--ease-out-expo) both;
  opacity: 0; /* start hidden; animation fills forward to opacity: 1 */
}

/* Hover defocus, same gesture as the case-study side nav: the chosen
   option steps forward while its siblings recede on a whisper of blur.
   Hover-capable pointers only, so it never sticks on touch. */
@media (hover: hover) and (pointer: fine) {
  .chat-prompts:has(.chat-prompt:hover) .chat-prompt:not(:hover) {
    filter: blur(0.7px);
    opacity: 0.42;
  }
}
.chat-prompt::before {
  counter-increment: opt;
  content: counter(opt, upper-alpha) '.';
  flex-shrink: 0;
  min-width: 22px;
  color: var(--ink-4);
  transition: color 160ms var(--ease-out-quart);
}
.chat-prompt:hover,
.chat-prompt:focus-visible {
  color: var(--ink-1);
  transform: translateX(4px);
  outline: none;
}
.chat-prompt:hover::before,
.chat-prompt:focus-visible::before {
  content: '\25B8 ' counter(opt, upper-alpha) '.';
  color: var(--ink-1);
}

@keyframes prompt-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0);   }
}

.chat-prompt:active { opacity: 0.72; transform: scale(0.97); }

/* Action row (feed the cat) — same ink as its siblings; the words
   carry the playfulness. */

/* ── Input row ──────────────────────────────────────────────────── */
.chat-form {
  display: flex;
  align-items: center;
  gap: 8px;
  border-top: none; /* floats; the › prefix anchors the row */
  padding-top: 8px;
  margin-top: 0;
  transition: opacity 200ms var(--ease-out-quart);
}

/* Streaming state: dim the whole row so typing dots read as the active element */
/* Streaming: the input stays live (a message sent now queues behind the
   reply, js/chatbot.js), so it is not dimmed or made click-through. That
   click-through was also what let the pointer "leave" the chat mid-reply. */
.chat-form--streaming { opacity: 1; }

/* 12px, matching .chat-msg--user .chat-msg__text exactly: what you type
   here becomes that chip, so the two must not change size mid-send. This
   used to run 13px (16px on mobile, an iOS focus-zoom guard — see the
   viewport-meta note below), which read as an oversized field next to
   the conversation. */
.chat-input {
  flex: 1; /* wrap owns the flex grow; input fills wrap */
  min-width: 0;
  font-family: inherit;
  font-size: var(--t-small);
  color: var(--ink-1);
  background: transparent;
  border: none;
  outline: none;
  padding: 0;
}

.chat-input::placeholder  { color: var(--ink-4); }
.chat-input:focus,
.chat-input:focus-visible { outline: none; }
.chat-input:disabled      { opacity: 0.5; }

/* Send button — paper slip, matches .about-btn language */
.chat-send {
  flex-shrink: 0;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #fff;
  color: #2d241c;
  border: none;
  border-radius: 0;
  font-family: var(--font-primary);
  font-size: 15px;
  font-weight: 500;
  line-height: 1;
  filter: blur(0.4px);
  box-shadow:
    0 1px 2px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 3px 8px -1px color-mix(in oklab, var(--ink-1) 11%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
  transform: rotate(0deg);
  transform-origin: center center;
  cursor: pointer;
  transition:
    transform  400ms var(--ease-out-soft),
    box-shadow 400ms var(--ease-out-soft);
  will-change: transform;
  -webkit-tap-highlight-color: transparent;
}

.chat-send:hover {
  transform: rotate(2deg) scale(1.05);
  box-shadow:
    0 3px 4px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 7px 16px -3px color-mix(in oklab, var(--ink-1) 11%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
}
.chat-send:active   { transform: rotate(2deg) scale(0.96); }
.chat-send:disabled { opacity: 0.28; pointer-events: none; }

/* ── Typing indicator — a terminal cursor waiting for ink ───────── */
/* The "thinking" state — a pen stroke drawing itself beside a whispered
   label. Deliberately NOT a cursor: the input already owns the one
   blinking block, and a second one read as a glitch. No bouncing
   support-widget dots either; a line inking in and drying is the
   site's own gesture. */
.chat-typing {
  align-self: flex-start;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 4px 0;
  animation: chat-from-below 220ms var(--ease-out-expo) both;
}

.chat-typing__dots {
  display: inline-flex;
  gap: 3px;
}
.chat-typing__dots i {
  width: 4px;
  height: 4px;
  background: color-mix(in oklab, var(--ink-1) 55%, transparent);
  animation: chat-dot 1.05s var(--ease-out-quart) infinite;
}
.chat-typing__dots i:nth-child(2) { animation-delay: 0.14s; }
.chat-typing__dots i:nth-child(3) { animation-delay: 0.28s; }
@keyframes chat-dot {
  0%, 70%, 100% { opacity: 0.22; transform: translateY(0); }
  35%           { opacity: 1;    transform: translateY(-3px); }
}

.chat-typing__label {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-4);
}

/* System notes (errors, rate limits) — a dry terminal comment,
   not an alarm. Monospace, whispered, prefixed like code. */
.chat-msg--system .chat-msg__text {
  font-family: var(--font-mono);
  font-size: var(--t-meta);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.chat-msg--system .chat-msg__text::before { content: '// '; color: var(--ink-4); }

/* ── Follow-up chips — appear below each assistant reply ─────────── */
.chat-followups {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  margin-top: 6px;
  padding-bottom: 2px;
}

/* Base (light glass context): ink outline, same editorial language as
   .chat-prompt. NOTE: this previously used --ink-inv colors, which made
   the chips near-invisible on the light widget; ink is the base, and the
   dark terminal gets its own override below. */
.chat-followup {
  font-family: var(--font-mono);
  font-size: var(--t-meta);
  font-weight: 500;
  letter-spacing: 0.03em;
  padding: 3px 9px 2px;
  background: transparent;
  border: 1px solid color-mix(in oklab, var(--ink-1) 26%, transparent);
  border-radius: var(--radius-control);
  color: var(--ink-3);
  cursor: pointer;
  transition:
    color        200ms var(--ease-out-quart),
    border-color 200ms var(--ease-out-quart),
    transform    320ms var(--ease-out-expo);
  animation: prompt-in 260ms var(--ease-out-expo) both;
  opacity: 0; /* start hidden; animation fills forward, stagger set in JS */
  -webkit-tap-highlight-color: transparent;
}

.chat-followup:hover {
  color: var(--ink-1);
  border-color: color-mix(in oklab, var(--ink-1) 55%, transparent);
  transform: translateY(-1px);
}

.chat-followup:active { opacity: 0.65; transform: scale(0.97); }

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

/* Motion off: the ink squares hold steady, ink settles instantly */
@media (prefers-reduced-motion: reduce) {
  .chat-typing__dots i { animation: none; opacity: 0.5; }
  .chat-followup, .chat-prompt { animation: none; opacity: 1; }
  .chat-msg--assistant { animation: none; }
  .msg-widget.is-thinking .chat-form__prefix { animation: none; }
}

/* ── Design-system primitives ───────────────────────────────────────
   Shared recipes that components COMPOSE (add the class alongside the
   component class in markup) rather than re-declaring. Documented in
   DESIGN.md. Change the recipe here and every composer updates.

   .eyebrow — the uppercase tracked label. This single recipe was hand
   copied across ~78 rules (contact-row__label, chat-*__label/head/cta/
   when, cs-section-label, rail__eyebrow, ...). New labels should compose
   this; the old ones are being migrated to it (see DESIGN.md audit). */
/* The grouped selectors are legacy labels that carried this exact recipe
   by hand; they now source it here (their own rules keep only deltas such
   as blue color or layout). New markup composes the class directly. */
.eyebrow,
.rail__eyebrow,
.rail__currently-eyebrow,
.cs-meta-label,
.case-meta__row dt,
.cs-mini__label,
.cs-snapshot__label,
.cs-desc-label,
.case-pager__dir {
  font-family: var(--font-primary);
  font-weight: 600;
  font-size: var(--t-eyebrow);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-4);
}
/* Cramped contexts (stat labels, the timeline's year column): one step
   smaller and tighter, still the same recipe. Compose as `eyebrow eyebrow--sm`. */
.eyebrow--sm { font-size: var(--t-micro); letter-spacing: 0.1em; }

/* Legacy-label deltas (these selectors source the recipe above; only
   their intentional differences live here). Meta labels are the one
   blue label treatment, per DESIGN.md. */
.cs-meta-label,
.case-meta__row dt,
.cs-snapshot__label { color: var(--accent-blue); }
.rail__currently-eyebrow { font-size: var(--t-body); font-weight: 500; }

/* ── Chat visual components (bot-emitted: [[case]], [[gallery]], [[metrics]],
   [[timeline]], [[stack]], [[quote]], [[contact]]) ──────────────────
   Soft register: no boxes or hard strokes. Labels compose .eyebrow;
   11% hairline separators and hover-to-blue mirror the INFO section. */
.chat-card {
  display: block;
  width: 100%;
  max-width: 300px;
  margin: 10px 0 2px;
  padding: 10px;
  background: var(--surface-tint);
  text-decoration: none;
  color: var(--ink-1);
  transition: transform 170ms var(--ease-out-quart);
}
.chat-card:active { transform: scale(0.98); }
/* The homepage card's thumbnail frame, in miniature: 16:10, a darkened
   plate behind the image and the same sprayed hairline (#spray-frame-*
   lives in both index.html and case.html). */
.chat-card__frame {
  position: relative;
  display: block;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  background: color-mix(in oklab, var(--surface-frame) 80%, var(--ink-1));
  line-height: 0;
}
.chat-card__frame img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 260ms var(--ease-out-soft);
}
.chat-card__frame::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px solid color-mix(in oklab, var(--ink-1) 28%, transparent);
  filter: url(#spray-frame-card-2);
  pointer-events: none;
}
.chat-card__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 10px;
}
.chat-card__title {
  font-family: var(--font-primary);
  font-size: 17px;
  font-weight: 500;
  letter-spacing: 0.04em;
  line-height: 1.05;
  color: var(--ink-2);
  /* No text-transform: titles carry brand casing (tarotnook, ok-bye). */
  transition: color 160ms var(--ease-out-quart);
}
.chat-card__badge { font-size: var(--t-micro); padding: 3px 6px 2px; }
.chat-card__sub {
  display: block;
  margin-top: 6px;
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink-3);
  text-wrap: pretty;
}
.chat-card__tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 10px; }
/* Grey meta chips, as on the homepage cards' expanded meta. Also the
   [[stack]] chips, so every chip in the chat is the same object. */
.chat-tag {
  display: inline-flex;
  align-items: center;
  padding: 4px 7px 3px;
  border-radius: var(--radius-control);
  background: color-mix(in oklab, var(--ink-1) 7%, transparent);
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1;
  white-space: nowrap;
  color: var(--ink-3);
}
@media (hover: hover) and (pointer: fine) {
  .chat-card:hover .chat-card__title { color: var(--accent-blue); }
  .chat-card:hover .chat-card__frame img { transform: scale(1.03); }
}
@media (prefers-reduced-motion: reduce) {
  .chat-card:hover .chat-card__frame img { transform: none; }
}
@media (max-width: 719px) {
  .chat-card__frame::after { filter: none; }
}

/* Several case cards in one reply: a row that scrolls sideways and snaps
   card by card, so six cards do not become a wall to scroll past. */
.chat-cards {
  display: flex;
  gap: 8px;
  margin: 10px 0 2px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  padding-bottom: 2px;
}
.chat-cards::-webkit-scrollbar { display: none; }
.chat-cards .chat-card {
  flex: 0 0 236px;
  margin: 0;
  scroll-snap-align: start;
}

/* Popup-case card ([[case ok-bye]], [[case fairy-tale]]): a .chat-card that is
   a button, opening the cs-overlay popup instead of navigating. */
.chat-card--modal {
  appearance: none;
  -webkit-appearance: none;
  border: 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

/* [[metrics]] — bare stat pairs, no dividers */
.chat-stats {
  display: flex;
  gap: 18px;
  margin: 10px 0 2px;
  padding: 2px 2px 0;
}
.chat-stat { display: block; min-width: 0; }
.chat-stat__value {
  display: block;
  font-family: var(--font-primary);
  font-size: 19px;
  font-weight: 700;
  line-height: 1.1;
  color: var(--ink-1);
}
.chat-stat__label {   /* composes .eyebrow.eyebrow--sm */
  display: block;
  margin-top: 3px;
  line-height: 1.4;
}

/* [[contact]] — status line + rows in the INFO-section register */
.chat-contact { display: block; margin: 10px 0 2px; }
.chat-contact__status {   /* composes .eyebrow */
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 2px 8px;
}
.chat-contact__dot {
  flex: none;
  width: 5px;
  height: 5px;
  background: var(--accent-blue);
}
/* Contact rows reuse the canonical .contact-row (see js/chatbot.js
   renderComponent 'contact'); the old .chat-crow duplicate was removed. */

/* [[gallery]] — the project index, quiet rows like the INFO section */
.chat-index { display: block; margin: 10px 0 2px; }
.chat-index__head {   /* composes .eyebrow */
  display: block;
  padding: 0 2px 5px;
}
.chat-index__row {
  appearance: none;
  -webkit-appearance: none;
  display: grid;
  grid-template-columns: 20px 1fr auto;
  align-items: baseline;
  width: 100%;
  padding: 8px 2px 9px;
  background: transparent;
  border: 0;
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 11%, transparent);
  font: inherit;
  text-align: left;
  text-decoration: none;
  color: var(--ink-1);
  cursor: pointer;
  transition: transform 160ms var(--ease-out-quart);
}
.chat-index__row:active { transform: scale(0.99); }
.chat-index__num {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--ink-4);
  transition: color 220ms var(--ease-out-quart);
}
.chat-index__name {
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 600;
  letter-spacing: 0.03em;
  transition: color 220ms var(--ease-out-quart);
}
.chat-index__year {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: 0.03em;
  color: var(--ink-4);
  justify-self: end;
}
.chat-index__meta {
  grid-column: 2 / 4;
  margin-top: 1px;
  font-size: var(--t-meta);
  line-height: 1.4;
  color: var(--ink-3);
}
@media (hover: hover) and (pointer: fine) {
  .chat-index__row:hover .chat-index__name { color: var(--accent-blue); }
  .chat-index__row:hover .chat-index__num  { color: var(--accent-blue); }
}

/* [[timeline]] — career and education, label column + hairline rows */
.chat-tl { display: block; margin: 10px 0 2px; }
.chat-tl__item {
  display: grid;
  grid-template-columns: 38px 1fr;
  column-gap: 10px;
  padding: 8px 2px 9px;
}
.chat-tl__item + .chat-tl__item {
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 11%, transparent);
}
.chat-tl__when {   /* composes .eyebrow.eyebrow--sm */
  padding-top: 2px;
}
.chat-tl__item:first-child .chat-tl__when { color: var(--accent-blue); }
.chat-tl__body { display: block; }
.chat-tl__what {
  display: block;
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 600;
  letter-spacing: 0.02em;
}
.chat-tl__sub {
  display: block;
  margin-top: 1px;
  font-size: var(--t-meta);
  line-height: 1.5;
  color: var(--ink-3);
}

/* [[stack]] — tools and skills, soft tint chips */
.chat-stack { display: block; margin: 10px 0 2px; }
.chat-stack__group { display: block; padding: 0 2px; }
.chat-stack__group + .chat-stack__group { margin-top: 12px; }
.chat-stack__label {   /* composes .eyebrow */
  display: block;
  margin-bottom: 6px;
}
.chat-stack__chips { display: flex; flex-wrap: wrap; gap: 4px; }

/* [[quote]] — pull-quote as blue type, no rule */
.chat-quote {
  display: block;
  margin: 10px 2px 2px;
  font-family: var(--font-primary);
  font-size: 15px;
  font-weight: 600;
  line-height: 1.4;
  color: var(--accent-blue);
}

/* ── Easter eggs (js/eggs.js) ───────────────────────────────────────
   Site-wide: the console stamp, typed words (meow, grid), and the
   konami ink flood. Tokens only; zero radius; reduced motion keeps
   the payoffs but drops the movement. */

/* Konami: a retro-blue wash sweeps up, stamps a line, recedes. */
.egg-flood {
  position: fixed;
  inset: 0;
  z-index: 600;               /* above the case popup (500) and widget (300) */
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent-blue);
  clip-path: inset(100% 0 0 0);
  pointer-events: none;
  transition: clip-path 520ms var(--ease-out-expo);
}
.egg-flood.is-in  { clip-path: inset(0 0 0 0); }
.egg-flood.is-out { clip-path: inset(0 0 100% 0); }
.egg-flood__stamp {
  font-family: var(--font-mono);
  font-size: 13px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--surface-base);
  border: 1px solid var(--surface-base);
  padding: var(--space-3) var(--space-4);
}

/* Grid: an 8px baseline overlay for anyone who types the word. */
.egg-grid {
  position: fixed;
  inset: 0;
  z-index: 550;
  pointer-events: none;
  opacity: 0;
  transition: opacity 200ms var(--ease-out-quart);
  background: repeating-linear-gradient(
    to bottom,
    transparent 0 7px,
    color-mix(in srgb, var(--accent-blue) 22%, transparent) 7px 8px
  );
}
.egg-grid.is-on { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .egg-flood {
    clip-path: none;
    opacity: 0;
    transition: opacity 200ms var(--ease-out-quart);
  }
  .egg-flood.is-in  { opacity: 1; }
  .egg-flood.is-out { opacity: 0; }
}

/* ── Case-study content components ──────────────────────────────────
   New shared cs-* components (rendered by js/components.js, used by the
   full-page case system and previewed on styleguide.html). Soft register:
   quiet surface-card plates instead of borders, spacing from --space-*,
   zero radius as always. Safe to use bare: these are new classes designed
   to be shared, unlike the legacy cs-* rules the scoping warning covers. */

/* Objectives — a labelled grid of goal cards (icon / eyebrow / body) */
.cs-objectives {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: var(--space-3);
}
.cs-obj {
  background: var(--surface-card);
  padding: var(--space-5) var(--space-4) var(--space-5);
  min-width: 0;
}
.cs-obj__icon {
  display: block;
  font-size: 15px;
  line-height: 1;
  color: var(--ink-2);
  margin-bottom: var(--space-3);
}
.cs-obj__title {
  display: block;
  margin-bottom: var(--space-2);
}
.cs-obj__body {
  margin: 0;
  font-family: var(--font-primary);
  font-size: var(--t-body);
  line-height: 1.55;
  color: var(--ink-3);
}

/* Figure — media on a quiet plate with a mono caption, specimen-style */
.cs-figure {
  margin: 0;
  background: var(--surface-card);
  padding: var(--space-4);
}
.cs-figure__frame { line-height: 0; }
.cs-figure__frame img,
.cs-figure__frame video {
  display: block;
  width: 100%;
  height: auto;
}
.cs-figure__cap {
  margin-top: var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--t-meta);
  line-height: 1.65;
  color: var(--ink-3);
}

/* ── Scenes (Oct 2026, PortfolioUI.renderScenes / initScenes) ─────────
   One screen, a pill per interaction: the set plays through in turn, and
   picking a pill plays that recording on the same screen. The stage is
   the screen itself, framed like every other case image (the hairline
   here, the shared shadow in the cohesive framing block, --radius-content
   in "Content corners"), with no mockup field around it: the recordings
   are the site, edge to edge (on request, Oct 2026). The screen takes its
   shape from the recordings (`w`/`h`, default 16:10), or the phone
   recordings' portrait shape when the Desktop / Phone switch says so. */
.cs-scenes {
  display: grid;
  gap: var(--space-4);
}
.cs-scenes__intro { margin-bottom: var(--space-1); }
/* The card (Oct 2026, on request): the homepage project card's hovered
   state, held on. A 5% ink wash at --radius-content with the card's 8px
   margin, the screen inside it framed like .card__frame (a 28% ink
   hairline, no drop shadow, since the wash already lifts it), and the
   pills and caption sitting on the wash like the card's tags and line. */
.cs-scenes__card {
  position: relative;
  isolation: isolate;
  display: grid;
  gap: 12px;
  padding: 8px;
}
/* The wash is its own layer, so it can narrow around the phone screen
   (--scenes-fill, set by initScenes' fitFill) without moving the pills
   or the caption: they keep their places and the grey draws in behind
   them, with the frame's morph. */
.cs-scenes__card::before {
  content: '';
  position: absolute;
  z-index: -1;
  top: 0;
  bottom: 0;
  left: 50%;
  width: var(--scenes-fill, 100%);
  max-width: 100%;
  translate: -50% 0;
  border-radius: var(--radius-content);
  background: color-mix(in oklab, var(--ink-1) 5%, transparent);
  transition: width 560ms var(--ease-out-expo);
}
@media (prefers-reduced-motion: reduce) {
  .cs-scenes__card::before { transition: none; }
}
.cs-scenes__card .cs-scenes__stage {
  border-color: color-mix(in oklab, var(--ink-1) 28%, transparent);
  box-shadow: none;
}
.cs-scenes__card .cs-scenes__captions { padding-bottom: 4px; }
/* The caption runs as wide as the pill row above it (--scenes-row, set
   by initScenes' fitFill), so the two read as one column. */
.cs-scenes__card .cs-scenes__cap { max-width: min(60ch, var(--scenes-row, 60ch)); }
.cs-scenes__stage {
  position: relative;
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  overflow: hidden;
}
.cs-scenes__screen {
  position: relative;
  overflow: hidden;
  aspect-ratio: var(--scene-ar, 1.6);
  background: var(--surface-frame);
  cursor: zoom-in;
}
/* Phone: a portrait screen, centred, as tall as the window comfortably
   allows (a full-width portrait screen would run past the fold). */
.cs-scenes.is-phone .cs-scenes__stage {
  justify-self: center;
  width: fit-content;
  max-width: 100%;
}
.cs-scenes.is-phone .cs-scenes__screen {
  aspect-ratio: var(--scene-ar-phone);
  width: calc(min(640px, 72svh) * var(--scene-ar-phone));
  max-width: 100%;
}
/* The device morph (setupScenes' morphTo): a veil of the screen's own
   ground covers the clip, the controls and the progress line while the
   frame reshapes, then lifts off the new clip. While it reshapes, the
   stage is sized by the animation and the screen simply fills it. */
.cs-scenes__screen::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 4;
  background: var(--surface-frame);
  opacity: 0;
  pointer-events: none;
  transition: opacity 300ms var(--ease-out-quart);
}
.cs-scenes.is-veiled .cs-scenes__screen::after {
  opacity: 1;
  transition-duration: 160ms;
}
.cs-scenes.is-morphing .cs-scenes__stage {
  justify-self: center;
  box-sizing: border-box;
  max-width: 100%;
}
.cs-scenes.is-morphing .cs-scenes__screen {
  width: 100%;
  height: 100%;
  max-width: none;
  aspect-ratio: auto;
}
@media (prefers-reduced-motion: reduce) {
  .cs-scenes__screen::after { transition: none; }
}
/* Every scene sits stacked in the screen. The incoming one fades in on
   top while the outgoing one holds underneath and only drops away once
   the fade is done, so the screen never dims through to its ground. */
.cs-scenes__media {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top center;
  opacity: 0;
  transition: opacity 0s 360ms;
}
.cs-scenes__media.is-active {
  z-index: 1;
  opacity: 1;
  transition: opacity 360ms var(--ease-out-quart);
}
/* How far through the clip: a hairline along the screen's foot. In the
   screenshot's own ink (--shot-*), since it sits on the recording, which is
   a light page in every theme. */
.cs-scenes__progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 2;
  /* A 16px band to grab; the line itself is drawn by ::before. */
  height: 16px;
  cursor: pointer;
  touch-action: none;
}
.cs-scenes__progress::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: color-mix(in srgb, var(--shot-ink-strong) 10%, transparent);
  transition: height 160ms var(--ease-out-quart);
}
/* It thickens when pointed at or held, so it reads as something to drag. */
.cs-scenes__progress:hover::before,
.cs-scenes__progress:focus-visible::before,
.cs-scenes.is-scrubbing .cs-scenes__progress::before { height: 5px; }
.cs-scenes__progress:focus-visible { outline: none; }
.cs-scenes__progress:hover .cs-scenes__progress-fill,
.cs-scenes__progress:focus-visible .cs-scenes__progress-fill,
.cs-scenes.is-scrubbing .cs-scenes__progress-fill { height: 5px; }
.cs-scenes__progress-fill {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: block;
  height: 2px;
  transition: height 160ms var(--ease-out-quart);
  background: var(--shot-ink-strong);
  opacity: 0.72;
  transform: scaleX(0);
  transform-origin: left center;
}
/* Expand (into the case lightbox) and pause, tucked into the screen's corner: they show on
   hover and focus, always on touch screens (no hover to find them), and
   whenever the set is paused, so Play is never hidden. Dark chips on the
   recording, like a player's own controls. */
.cs-scenes__controls {
  position: absolute;
  right: var(--space-3);
  bottom: var(--space-3);
  z-index: 3;
  display: flex;
  gap: var(--space-1);
  opacity: 0;
  transition: opacity 200ms var(--ease-out-quart);
}
.cs-scenes__stage:hover .cs-scenes__controls,
.cs-scenes__controls:focus-within,
.cs-scenes.is-paused .cs-scenes__controls { opacity: 1; }
@media (hover: none) { .cs-scenes__controls { opacity: 1; } }
.cs-scenes__ctl {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: none;
  border-radius: var(--radius-control);
  background: color-mix(in srgb, var(--shot-ink-strong) 56%, transparent);
  color: var(--shot-paper);
  cursor: pointer;
  transition: background 160ms var(--ease-out-quart), transform 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
}
@media (hover: hover) and (pointer: fine) {
  .cs-scenes__ctl:hover { background: color-mix(in srgb, var(--shot-ink-strong) 78%, transparent); }
}
.cs-scenes__ctl:active { transform: scale(0.97); }
.cs-scenes__ctl:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 2px; }
.cs-scenes__icon { display: block; }
.cs-scenes__icon--pause,
.cs-scenes__icon--play { fill: currentColor; }
.cs-scenes__icon--enter {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.cs-scenes__icon--play,
.cs-scenes.is-paused .cs-scenes__icon--pause { display: none; }
.cs-scenes.is-paused .cs-scenes__icon--play { display: block; }
/* The scenes, centred under the screen. The device switch is not here:
   it rides its own top bar (.cs-scenes-nav). */
.cs-scenes__bar {
  display: flex;
  justify-content: center;
}
.cs-scenes__tabs,
.cs-scenes__devices {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
}
/* The device switch: the dock's theme switcher (.tbar__theme) as a
   two-way well. A --surface-tint well at --radius-dock, its chips inset
   3px at --radius-control (3 + 3 = 6, concentric), icons in --ink-3; the
   picked chip is raised paper with the dock's pad shadow, in full ink. */
.cs-scenes__devices {
  padding: 3px;
  gap: 0;
  flex-wrap: nowrap;
  border-radius: var(--radius-dock);
  background: var(--surface-tint);
}
.cs-scenes__device {
  display: grid;
  place-items: center;
  width: 31px;   /* square, the dock's theme chip: a 37px button with its pad inset 3px */
  height: 31px;
  padding: 0;
  border: none;
  border-radius: var(--radius-control);
  background: transparent;
  color: var(--ink-3);
  cursor: pointer;
  transition:
    background 340ms var(--ease-out-quart),
    box-shadow 340ms var(--ease-out-quart),
    color 180ms var(--ease-out-quart),
    transform 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
}
.cs-scenes__device svg {
  display: block;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.cs-scenes__device[aria-pressed="true"] {
  background: var(--surface-base);
  color: var(--ink-1);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.09), 0 4px 10px -4px rgba(0, 0, 0, 0.14);
}
html[data-theme="dark"] .cs-scenes__device[aria-pressed="true"] {
  background: var(--surface-card);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 10px -4px rgba(0, 0, 0, 0.5);
}
html[data-theme="eink"] .cs-scenes__device[aria-pressed="true"] { box-shadow: none; }
@media (hover: hover) and (pointer: fine) {
  .cs-scenes__device[aria-pressed="false"]:hover { color: var(--ink-1); }
}
.cs-scenes__device:active { transform: scale(0.97); }
.cs-scenes__device:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 2px; }
/* The device switch's top bar: pinned at the top centre of the window,
   over the page's top-edge blur (.case-edge-blur, z 350) and under the
   dock (400). It morphs in while a scenes card is on screen (the .is-on
   class, set by initScenes) and back out when it leaves. */
.cs-scenes-nav {
  position: fixed;
  top: 10px;
  left: 50%;
  z-index: 360;
  translate: -50% 0;
  visibility: hidden;
  pointer-events: none;
  transition: visibility 0s 420ms;
}
.cs-scenes-nav.is-on {
  visibility: visible;
  pointer-events: auto;
  transition: visibility 0s;
}
/* The morph, like an iOS island: the well (the capsule) grows out of a
   small pill from the top edge, overshooting nothing, while its icons
   arrive a beat later out of a blur. Leaving runs it backwards and
   quicker: the icons go first, then the capsule draws back into the pill. */
.cs-scenes-nav .cs-scenes__devices {
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 6px 16px -8px rgba(0, 0, 0, 0.18);
  transform-origin: 50% 0;
  transform: translateY(-8px) scale(0.42, 0.5);
  opacity: 0;
  filter: blur(4px);
  transition:
    transform 340ms var(--ease-out-quart) 80ms,
    opacity 220ms var(--ease-out-quart) 140ms,
    filter 260ms var(--ease-out-quart) 80ms;
}
.cs-scenes-nav .cs-scenes__device {
  opacity: 0;
  filter: blur(3px);
  scale: 0.7;
  transition:
    opacity 140ms var(--ease-out-quart),
    filter 140ms var(--ease-out-quart),
    scale 160ms var(--ease-out-quart),
    background 340ms var(--ease-out-quart),
    box-shadow 340ms var(--ease-out-quart),
    color 180ms var(--ease-out-quart);
}
.cs-scenes-nav.is-on .cs-scenes__devices {
  transform: none;
  opacity: 1;
  filter: none;
  transition:
    transform 620ms var(--ease-out-expo),
    opacity 260ms var(--ease-out-quart),
    filter 420ms var(--ease-out-expo);
}
.cs-scenes-nav.is-on .cs-scenes__device {
  opacity: 1;
  filter: none;
  scale: 1;
  transition:
    opacity 320ms var(--ease-out-quart) 160ms,
    filter 420ms var(--ease-out-expo) 160ms,
    scale 520ms var(--ease-out-expo) 160ms,
    background 340ms var(--ease-out-quart),
    box-shadow 340ms var(--ease-out-quart),
    color 180ms var(--ease-out-quart);
}
@media (prefers-reduced-motion: reduce) {
  .cs-scenes-nav .cs-scenes__devices,
  .cs-scenes-nav .cs-scenes__device { transform: none; scale: 1; filter: none; }
  .cs-scenes-nav .cs-scenes__devices,
  .cs-scenes-nav.is-on .cs-scenes__devices { transition: opacity 160ms linear; }
  .cs-scenes-nav .cs-scenes__device,
  .cs-scenes-nav.is-on .cs-scenes__device { opacity: 1; transition: none; }
}
/* The pill family's shape (.case-hero__live, .sound-btn), in greys only.
   Every pill is a filled --surface-tint chip; the unpicked ones keep
   their type quiet (--ink-4), and the picked one deepens to a darker
   grey with full ink. Grey, not stamped ink (Oct 2026, on request): the
   pills sit under the screen and should not outweigh it. Same weight in
   both states, so a pill never changes width when it is picked. */
.cs-scenes {
  --scenes-pill:    var(--surface-tint);
  --scenes-pill-on: color-mix(in srgb, var(--ink-1) 16%, var(--surface-base));
}
/* On the card's wash, a solid tint would read as a hole: the pills and
   the device well take the card tags' ink wash (.card__meta dd) instead. */
.cs-scenes__card {
  --scenes-pill:    color-mix(in oklab, var(--ink-1) 7%, transparent);
  --scenes-pill-on: color-mix(in oklab, var(--ink-1) 14%, transparent);
}
/* Read as a playlist: each pill carries its track number, quiet until
   its scene is the one playing. */
.cs-scenes__card .cs-scenes__tabs { counter-reset: scene; }
.cs-scenes__card .cs-scenes__tab {
  counter-increment: scene;
  gap: 8px;
}
.cs-scenes__card .cs-scenes__tab::before {
  content: counter(scene, decimal-leading-zero);
  font-variant-numeric: tabular-nums;
  opacity: 0.6;
}
.cs-scenes__card .cs-scenes__tab[aria-selected="true"]::before { opacity: 1; }
.cs-scenes__tab {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 28px;
  padding: 0 12px;
  background: var(--scenes-pill);
  border: none;
  border-radius: var(--radius-control);
  color: var(--ink-4);
  font-family: var(--font-primary);
  font-weight: 400;
  font-size: var(--t-meta);
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background 160ms var(--ease-out-quart),
    color 160ms var(--ease-out-quart),
    transform 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}
.cs-scenes__tab[aria-selected="true"] {
  background: var(--scenes-pill-on);
  color: var(--ink-1);
}
@media (hover: hover) and (pointer: fine) {
  .cs-scenes__tab[aria-selected="false"]:hover { color: var(--ink-1); }
}
.cs-scenes__tab:active { transform: scale(0.97); }
.cs-scenes__tab:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 2px; }

/* Every caption shares one grid cell, so the block is always as tall as
   the longest one and the page never jumps as the pills move on. */
.cs-scenes__captions {
  display: grid;
  justify-items: center;
  text-align: center;
}
.cs-scenes__cap {
  grid-area: 1 / 1;
  max-width: 60ch;
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.55;
  color: var(--ink-3);
  text-wrap: pretty;
  opacity: 0;
  visibility: hidden;
  transition: opacity 160ms var(--ease-out-quart), visibility 0s 160ms;
}
.cs-scenes__cap.is-active {
  opacity: 1;
  visibility: visible;
  transition: opacity 280ms var(--ease-out-quart) 80ms;
}
.cs-scenes__cap:empty { display: none; }
@media (prefers-reduced-motion: reduce) {
  .cs-scenes__media,
  .cs-scenes__media.is-active,
  .cs-scenes__cap,
  .cs-scenes__cap.is-active,
  .cs-scenes__tab,
  .cs-scenes__ctl,
  .cs-scenes__device,
  .cs-scenes__controls { transition: none; }
}

/* Lead image — a supplied diagram (e.g. a user flow) framed like any
   other case image, sitting under a text section's heading before its
   prose (js/case.js buildText, `leadImage:` beside `html:`). */
.cs-lead-image { margin: 0 0 var(--space-7); }
/* The before/after slider that follows a lead-image text section (the
   Discovery page) takes a full section gap instead of the flush -36px
   pull-up, so the prose above it has room to breathe. */
.case .case-section:has(.cs-lead-image) + .case-section:has(> .case-section__body > .cs-wipe) { margin-top: var(--space-5); }

/* Handwritten notes over a lead image (`notes:` on leadImage; laid out
   by initAnnotations in js/case.js, strokes from PortfolioUI.drawNote).
   The ink layer covers the whole wrapper, including the lane the JS
   reserves above the image as padding-top, so the words sit on paper and
   the arrow crosses onto the picture. The persona marks' pen: accent
   blue, ~2px, round ends; the draw-on rules are shared with .cs-doodle
   below. Clicks fall through to the image (lightbox). */
.cs-annot { position: relative; }
.cs-annot__ink {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  z-index: 2;
  color: var(--accent-blue);
}
.cs-annot__stroke {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.25;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* The words are filled outline art (not a pen line), so they write in
   with a left-to-right wipe, linear like a hand moving across. The
   vertical overhang keeps ascenders and descenders unclipped. */
.cs-annot__words { fill: currentColor; }
.cs-annot__ink.is-armed .cs-annot__words { clip-path: inset(-40% 100% -40% 0); }
.cs-annot__ink.is-armed.is-drawn .cs-annot__words {
  clip-path: inset(-40% -2% -40% 0);
  transition: clip-path var(--dur, 900ms) linear var(--delay, 0ms);
}
@media (prefers-reduced-motion: reduce) {
  .cs-annot__ink .cs-annot__words { clip-path: none !important; transition: none !important; }
}

/* ── Mobile ─────────────────────────────────────────────────────── */
@media (max-width: 719px) {
  /* Geometry (full-bleed, bottom-locked, safe-area pad) lives in the
     earlier 719px block's !important rule — only the height cap here. */
  .msg-widget__panel {
    max-height: min(560px, calc(100dvh - 56px));
  }
  /* No hover on touch: the chevron returns as the explicit control.
     Touch targets across the widget grow toward the 44px guideline —
     hit area, not icon size, so nothing looks inflated. */
  .msg-widget__expand-btn { display: flex; min-width: 44px; min-height: 40px; }
  .msg-widget__dock { padding-right: 6px; }
  .chat-messages { max-height: min(46dvh, 340px); }
  .chat-card { max-width: 100%; }
  .chat-stats { flex-direction: column; gap: 10px; }
  /* SMALL TEXT WITHOUT iOS'S FOCUS ZOOM — the two are otherwise
     mutually exclusive. iOS magnifies the whole page the moment you
     focus an input whose COMPUTED font-size is under 16px, and the
     viewport meta cannot be relied on to stop it (a `maximum-scale=1`
     guard was tried and did not hold in iOS Chrome). So the field
     computes at 16px, which iOS accepts without zooming, and is scaled
     back down to READ at 12px — the message chip's size, the size that
     was asked for.

     The scale goes on the WRAP, never the input alone: the placeholder
     overlay and the block caret sit in that same box, and the caret is
     positioned by JS with a transform of its own (js/chatbot.js measures
     a hidden mirror). Scaling their shared parent keeps all three in one
     coordinate space, so the caret still lands on the right character;
     scaling the input by itself would desync it. The field draws no
     surface of its own (by design), so nothing visibly shrinks with it —
     only the text. The row stays fully tappable because the dock focuses
     the input on tap (js/toolbar.js), rather than relying on the
     scaled-down box's own hit area. */
  .chat-input-wrap {
    font-size: 16px;
    transform: scale(0.75);
    transform-origin: left center;
  }
  .chat-input,
  .chat-placeholder { font-size: 16px; }
  .chat-input { padding: 5px 0; }
  /* Pre-scale, so these land at the intended 7x13 once scaled by 0.75. */
  .chat-input-wrap .terminal-cursor { width: 9px; height: 17px; }
  .chat-prompt { padding: 9px 3px; }
  .chat-followup { padding: 7px 12px 6px; }

  /* The 24px blur (dropped from 52px in an earlier scroll-perf pass) reads
     legible on a mostly-static desktop panel, but on mobile — smaller
     screen, page content usually busier right behind the widget — the
     text underneath wasn't fully obscured. Mobile screens are far fewer
     pixels than desktop, so a stronger blur here is cheap regardless. */
  .msg-widget__inner {
    backdrop-filter: blur(40px) saturate(1.3);
    -webkit-backdrop-filter: blur(40px) saturate(1.3);
  }
}

/* ── Page transition (index ↔ case.html) ────────────────────────
   A gentle blur cross-dissolve. No shared-element morph (the old
   card→hero version snapshotted undecoded images and blended them
   additively — a white bloom on the dark theme). Instead the whole
   outgoing page softly blurs, scales up a touch and fades, while the
   incoming page resolves in from its own soft blur and settles — a
   slow, premium focus-pull rather than a hard cut. Same in both
   directions; both documents load this stylesheet.

   Dark-mode white flash — the real fix is NOT here but in the pre-paint
   snippet (index.html / case.html): it sets `color-scheme: dark` inline
   so the BROWSER canvas is dark from frame one. Without it the canvas
   defaults to white and shows through the cross-dissolve's midpoint (and
   in the bare instant the navigation commits) — that was the flash.
   With the canvas dark, normal-blended alpha over a matching backdrop
   never brightens past either page. */
@view-transition {
  navigation: auto;
}

/* Dark-mode white flash — two independent guards, because the cause is
   paint order AND the browser canvas.

   1) Paint order: the incoming snapshot paints ON TOP of the outgoing one.
      The old design faded BOTH at once (old 1→0, new 0→1); at the crossover
      both are ~half-transparent and the canvas shows through. The fix below
      keeps the OUTGOING page fully opaque, so it always backs the incoming
      page — nothing see-through ever stacks over the canvas.

   2) The backdrop itself: in dark mode, paint every layer of the transition
      overlay near-black. If any engine still exposes a backdrop frame (Safari
      at the navigation commit), the worst it can show is the page's own ink,
      never white. NOTE: only ever DARK here — never a white default, or the
      moment the theme selector doesn't resolve on the pseudo (it lives in the
      destination doc), that white becomes the flash. Light mode is left
      transparent: a white flash on a white page is invisible. */
html[data-theme="dark"]::view-transition,
html[data-theme="dark"]::view-transition-group(root),
html[data-theme="dark"]::view-transition-image-pair(root) {
  background-color: #101010;
}

::view-transition-image-pair(root) { isolation: auto; }

/* Outgoing page — blurs and drifts a hair larger, but stays fully OPAQUE.
   It's the solid backing the incoming page fades in over, so the canvas is
   never exposed. (Previously this also faded to 0, which was the flash.) */
::view-transition-old(root) {
  mix-blend-mode: normal;
  animation: page-out 560ms var(--ease-out-soft) both;
}
@keyframes page-out {
  from { filter: blur(0);    transform: scale(1); }
  to   { filter: blur(14px); transform: scale(1.04); }
}

/* Incoming page — resolves from a soft blur and settles in from a whisper of
   scale, fading up over the opaque outgoing page. Reaches full opacity at
   70% so the last stretch is crisp and the outgoing backing is fully hidden
   before the transition ends. */
::view-transition-new(root) {
  mix-blend-mode: normal;
  animation: page-in 560ms var(--ease-out-soft) both;
}
@keyframes page-in {
  from { opacity: 0; filter: blur(9px); transform: scale(1.02); }
  70%  { opacity: 1; }
  to   { opacity: 1; filter: blur(0);   transform: scale(1); }
}

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

/* ── GIF poster → full swap (perceived performance) ─────────────
   Case-study GIFs run several MB. The poster is the same frame at
   a fraction of the weight, so it paints instantly at the exact
   dimensions of the real GIF — swapping src later causes zero
   layout shift. data-loading marks the wait while the full
   file decodes in the background (js/case.js, js/casestudy.js).
   The filter/opacity transition is added to each image class below
   rather than declared here, so it layers with (not replaces) any
   transform transition that class already has. */
/* No blur while a poster waits for its video (retired Sept 2026). The
   poster IS the video's first frame, so the swap is already seamless;
   a blur only added a soft-to-sharp snap when the <video> replaced it. */

/* ════════════════════════════════════════════════════════════════
   FULL-PAGE CASE STUDY  (case.html → js/case.js)
   Editorial reading page. Reuses the cs-* content styles; adds its
   own hero, section rhythm, pager and footer. Ink-bleed lives only
   on the case title + footer CTA — everything else stays clean.
   ──────────────────────────────────────────────────────────────── */
.case-page { background: var(--surface-base); color: var(--ink-1); }

/* ── Top bar ─────────────────────────────────────────────────── */
.case-close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  margin-bottom: 20px;
  background: color-mix(in oklab, var(--ink-1) 7%, transparent);
  border: none;
  border-radius: var(--radius-control);
  padding: 0;
  font: inherit;
  color: var(--ink-3);
  transition: background 160ms var(--ease-out-quart), color 160ms var(--ease-out-quart), transform 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
  flex-shrink: 0;
}
.case-close:active { transform: scale(0.9); }
@media (hover: hover) and (pointer: fine) {
  .case-close:hover {
    background: color-mix(in oklab, var(--ink-1) 12%, transparent);
    color: var(--ink-1);
  }
}
@media (prefers-reduced-motion: reduce) {
  .case-close svg { transition: none; }
}

/* Persistent-navigation shell only (index.html; standalone case.html never
   sets [hidden] here). `.case`'s own `display: grid` below ties [hidden]'s
   specificity (both 0,0,1,0) — author rules always beat the UA stylesheet at
   equal specificity, so plain [hidden] alone would NOT actually hide this;
   same reasoning as the existing .cs-body[hidden]/.cs-deep-dive-body[hidden]
   overrides elsewhere in this file. #view-home has no competing display
   rule of its own, so its [hidden] needs no override — listed together
   here for visibility since js/portfolio-nav.js toggles both as a pair. */
#case-root[hidden] { display: none; }
#view-home[hidden] { display: none; }

/* Never any case chrome on the home view. The persistent shell keeps a
   whole rendered case sitting in #case-root, and its controls (the "back
   to work" close especially) are meaningless on the homepage: you are not
   closing anything, you are already home. #case-root[hidden] already
   display:none's it, but a fixed-position child (like the old floating
   close) can still paint if that container is ever hidden by anything
   other than display:none, or if the hidden flag is briefly out of sync
   during the view-transition swap. This keys off the home view being
   VISIBLE and force-hides the entire case container, so nothing inside it
   (close button, TOC, hero, anything) can ever leak over home. Standalone
   case.html has no #view-home, so this never matches there and a real
   case page is untouched. */
body:has(#view-home:not([hidden])) #case-root { display: none !important; }
/* Scoped to the case container's own close: the bio letter dialog also
   wears the canonical .case-close chip and lives on the home view, so a
   bare .case-close here would hide the letter's ✕ too. The case shell's
   close always renders inside #case-root, which the rule above already
   force-hides; this line is the belt-and-braces double lock for it. */
body:has(#view-home:not([hidden])) #case-root .case-close { display: none !important; }

/* Home/case view swap (js/portfolio-nav.js, no View Transition):
   opacity only, so no transform ever breaks the fixed dock. */
.is-view-entering { animation: view-in 320ms var(--ease-out-soft) both; }
@keyframes view-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .is-view-entering { animation: none; }
}

/* ── Reading container ───────────────────────────────────────── */
.case {
  display: grid;
  grid-template-columns: 164px 1fr;
  grid-template-areas: "toc hero" "toc body";
  column-gap: clamp(24px, 3.5vw, 48px);
  max-width: 1060px;
  margin: 0 auto;
  /* Named because .case-toc sticks at exactly this offset: the rail
     starts under this padding, so if its sticky `top` is any smaller it
     rides up the difference on the first scroll before catching (it did,
     56px, when top was a fixed 24px). One value, both places. */
  --case-pad-top: clamp(44px, 7vw, 80px);
  padding: var(--case-pad-top) clamp(20px, 5vw, 56px) 24px;
}
/* Before js/case.js fills #case-root, it is empty and collapses to its
   padding, which lets the street-footer flash into view at the top of
   the page. Reserve the viewport height while empty so the footer stays
   below the fold until the case content renders; the rule stops applying
   the instant content is injected, so the loaded page is unaffected. */
.case:empty { min-height: 100vh; }

/* ── TOC sidebar ─────────────────────────────────────────────── */
.case-toc {
  grid-area: toc;
  position: sticky;
  /* = where it starts, so it never moves: see --case-pad-top on .case */
  top: var(--case-pad-top);
  align-self: start;
}
.case-toc__list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
/* Entrance — links cascade in from the left rail edge, one beat after
   the page fade. --toc-i is stamped inline by buildTOC (js/case.js). */
@keyframes toc-enter {
  from { opacity: 0; translate: -10px 0; }
  to   { opacity: 1; translate: 0 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .case-toc__list li {
    animation: toc-enter 520ms var(--ease-out-expo) both;
    animation-delay: calc(120ms + var(--toc-i, 0) * 45ms);
  }
  .case-toc__next {
    animation: toc-enter 520ms var(--ease-out-expo) both;
    animation-delay: calc(200ms + var(--toc-count, 6) * 45ms);
  }
}
.case-toc__link {
  position: relative;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-4);
  text-decoration: none;
  display: block;
  line-height: 1.45;
  transition: color 240ms var(--ease-out-quart),
              filter 340ms var(--ease-out-quart),
              opacity 340ms var(--ease-out-quart);
}
/* Hover defocus — hovering one section lifts it into the accent and
   glides the rest back. :has() scopes the trigger to the list. The
   recede leans on a smooth opacity dim with only a whisper of blur, so
   the motion reads fluid rather than steppy, and the accent stays a
   precise type-only moment (never a fill). Hover-capable pointers only,
   so :hover never sticks on touch. */
@media (hover: hover) and (pointer: fine) {
  .case-toc__list:has(.case-toc__link:hover) .case-toc__link:not(:hover) {
    filter: blur(0.7px);
    opacity: 0.42;
  }
}
/* No tick (removed Oct 2026, on request: the dash read out of place).
   The current section is told by weight and full ink alone, and hover
   only deepens the colour; neither shifts the label sideways. */
@media (hover: hover) and (pointer: fine) {
  .case-toc__link:hover { color: var(--ink-2); }
}
.case-toc__link.is-active {
  color: var(--ink-1);
  font-weight: 700;
}
@media (prefers-reduced-motion: reduce) {
  .case-toc__link { transition: none; }
}
.case-toc__next {
  display: block;
  margin-top: 30px;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--ink-4);
  text-decoration: none;
  /* Same timing family as .case-toc__link — one rail, one rhythm. */
  transition: color 240ms var(--ease-out-quart);
}
.case-toc__next:hover { color: var(--ink-1); }
.case-toc__next-arr {
  display: inline-block;
  transition: translate 240ms var(--ease-out-quart);
}
.case-toc__next:hover .case-toc__next-arr { translate: 3px 0; }
@media (prefers-reduced-motion: reduce) {
  .case-toc__next-arr { transition: none; }
}

/* ── Hero ────────────────────────────────────────────────────────
   Two rows (July 2026): the case's short name shares a row with the
   live-link button — a name and an action belong together, the same
   pairing a card title gets with its chip-tag on the homepage — and the
   headline (the actual descriptive sentence) drops to its own full-width
   row below, where it reads as the real statement instead of being
   squeezed against a button. Category still lives in the meta strip
   below, not duplicated as an eyebrow here. */
.case-hero { grid-area: hero; margin-bottom: clamp(32px, 5vw, 56px); }
.case-hero__title-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
}
/* flex: 1 1 0% (not a bare min-width: 0) so this pane claims only the
   row's leftover width after the live-link chip — the name wraps inside
   that, instead of the row itself wrapping and dropping the chip onto
   its own line below. Named cases never wrap in practice (short by
   design), but the mechanics stay the same as the headline's for when
   they inevitably get reused. */
.case-hero__titles {
  flex: 1 1 0%;
  min-width: 0;
}
/* Name and headline are ONE typographic voice — identical family, size,
   weight, leading and tracking — separated only by ink. The name is the
   black opening (CIRCLES, OUSTRE, tarotnook, STILL) and the headline is
   the grey sentence that continues from it, so the block reads as a
   single statement whose first words happen to be the case's name,
   rather than a small tracked label sitting on top of a big heading.
   Any change to the display size belongs on this shared selector, not
   on one of them: the moment their sizes diverge the effect is gone.

   Sizing notes that apply to both:

   WEIGHT 400. Archivo Narrow's lightest cut is elegant at display
   sizes; 500 at 25px thickened into a headline that shouts.

   MEASURE. Left unbounded the headline ran ~95 characters a line at
   desktop, far past readable. 29em tracks the clamped size (~725px at
   the 25px cap), so line length stays constant as the type scales, and
   it is a no-op on mobile where the viewport is already narrower.
   Tuned by eye against the real headlines rather than to a round
   number: wider left a long first line over a stub second one.

   No text-wrap: balance — balance narrows the measure to equalise line
   lengths, which reads as text stopping short of its box rather than
   filling it. Plain wrapping fills the real width.

   Literal casing from the data on the name, same as the homepage card
   (tarotnook is deliberately lowercase brand styling; a text-transform
   here would fight that). The name is not an <h1> — the headline
   carries that, since it is the sentence describing the page. */
.case-hero__name,
.case-hero__headline {
  font-family: var(--font-primary);
  font-weight: 400;
  font-size: clamp(19px, 2.1vw, 25px);
  line-height: 1.34;
  letter-spacing: -0.008em;
}
.case-hero__name {
  display: block;
  color: var(--ink-1);
}
/* A small gap, not zero and not a full line. Zero read as the headline
   crowding the live-link CHIP that shares the name's row (a chip has
   padding and a background, so it needs more air under it than a line
   of type does); 14px overshot and broke the pair into two blocks.
   Remember both lines carry line-height 1.34, so ~8px of half-leading
   already sits between the glyphs before this margin is counted at all
   — 6px lands the visible gap near 14px, which clears the chip while
   the name and headline still read as one composed block. */
.case-hero__headline {
  margin-top: 6px;
  max-width: 29em;
  color: var(--ink-3);
}
/* Square chip, #f2f2f2, uppercase — the old corner-cluster button recipe,
   kept alive here as this button's own look. */
.case-hero__live {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  flex-shrink: 0;
  height: 28px;
  padding: 0 10px;
  background: var(--surface-deep);
  border: none;
  border-radius: var(--radius-control);
  color: var(--ink-1);
  font-family: var(--font-primary);
  font-weight: 400;
  font-size: var(--t-meta);
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition: background 160ms var(--ease-out-quart), transform 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}
.case-hero__live[hidden] { display: none; }
@media (hover: hover) and (pointer: fine) {
  .case-hero__live:hover { background: var(--surface-tint); }
}
/* Scale-on-press, not a big opacity drop — the button confirms it heard
   the tap without flickering out. */
.case-hero__live:active { transform: scale(0.96); }

/* Meta strip — flex, not grid: each row hugs its own content width and
   sits an even gap apart. Equal-width grid columns left short values
   (e.g. "Productivity") stranded in the middle of an oversized cell,
   which read as uneven, unbalanced gaps rather than a clean row.
   justify-content: space-between stretches that even spacing across
   the full row (first item flush left, last flush right) instead of
   a fixed gap that leaves the row short of the right edge; the gap
   property still sets a minimum so items never crowd on narrow rows. */
/* An 11% hairline (the system's separator, DESIGN.md's soft register)
   closes the title block and starts the facts block. Without it the
   meta strip floated under the headline as loose text, and the header
   read as three unrelated bands rather than one composed unit. */
.case-meta {
  display: grid;
  /* Four equal columns, so Role/Team/Timeline/Category line up as one
     clean row regardless of which value happens to be the longest —
     the old flex layout sized each column by its own content, which
     is what let Team (the longest value) crowd Timeline and Category
     into a lopsided remainder. */
  grid-template-columns: repeat(4, 1fr);
  gap: 16px 24px;
  margin: 26px 0 0;
  padding: 16px 0 12px;
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 11%, transparent);
}
.case-meta__row { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.case-meta__row dt {   /* eyebrow recipe; blue delta re-asserted after the group rule */
}
.case-meta__row dd {
  /* Set as the case's body text (Oct 2026): the values are read, not
     scanned, so they take the reading face and size, not the label face. */
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 400;
  line-height: 1.5;
  color: var(--ink-1);
}

/* Hero image frame. */
.case-hero__media {
  margin-bottom: clamp(24px, 3.5vw, 36px);
  border-radius: 0;
  overflow: hidden;
  /* The same light --surface-frame as the card frames and the image
     skeleton, so a hero that hasn't decoded yet sits as one quiet tone
     and the picture simply fades up onto it. */
  background: var(--surface-frame);
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
}
.case-hero__img { width: 100%; height: auto; display: block; transition: filter 260ms var(--ease-out-quart), opacity 260ms var(--ease-out-quart); }
/* A matted hero (heroBackdrop in cases-data.js): the screenshot sits at
   72% width, centred on the case's own backdrop colour, so the title row
   and live-site button stay near the fold. A rounded screenshot
   (heroRadius) keeps its curve on the image, and its transparent corners
   show the mat rather than the grey frame. */
.case-hero__media--mat {
  background: var(--hero-bg);
  padding: clamp(20px, 4.5vw, 40px) 0;
}
.case-hero__media--mat .case-hero__img { width: 72%; margin: 0 auto; }
.case-hero__img { border-radius: var(--hero-r, 0); }

/* ── Sections ────────────────────────────────────────────────── */
.case-body { grid-area: body; display: flex; flex-direction: column; gap: clamp(32px, 4.5vw, 56px); }
/* Sections are separated by whitespace alone — no line dividers. */
.case-section {
  scroll-margin-top: 84px;
  padding-top: clamp(24px, 3vw, 36px);
}
.case-section:first-child { padding-top: 0; }

/* Paired sections (Overview + Role) share one grid row, mirroring the
   case modal's intro layout: the narrower left column, the wider right.
   Each stays its own .case-section, so both align to the top of the row
   (their own padding-top is dropped). Stacks below 720px. */
.case-row {
  display: grid;
  grid-template-columns: 5fr 7fr;
  gap: clamp(26px, 3.5vw, 48px);
  align-items: start;
}
.case-row > .case-section { padding-top: 0; }
@media (max-width: 719px) {
  .case-row { display: block; }
  .case-row > .case-section + .case-section { padding-top: clamp(26px, 6vw, 34px); }
}

/* Headingless blocks (a stat band, a statement, an example image row) are
   NOT new sections — they're evidence belonging to the titled section above
   them. Drop the divider + full section padding and pull them up so they
   read as one continuous movement (prose → stats → thesis), not three
   fragments. The leading snapshot is :first-child, so it's untouched. */
.case-section:not(:first-child):not(:has(.case-section__head)) {
  border-top: none;
  padding-top: 0;
  margin-top: calc(20px - clamp(32px, 4.5vw, 56px));
}
.case-section__head { margin-bottom: 18px; }
.case-section__heading {
  /* The ROLE/TEAM meta-label recipe (.case-meta__row dt → .eyebrow):
     Archivo Narrow, small, heavily tracked, uppercase — in ink, so the
     page never turns blue across ten section heads per case.

     Differentiation comes from register, not scale. Set near body size
     it read as just-slightly-bolder prose, since 14px ink-1 body copy is
     only a hair away from 15px ink-2. At 11px with tracking opened to
     0.16em it lands in label territory instead, separating from the
     paragraph on family, size, weight, case, and tracking at once. Weight
     carries the contrast that color would otherwise: 700 against the
     body's 400 is a full three steps, which reads unmistakably at this
     size. (700 is also the ceiling — Archivo Narrow is loaded at 400-700,
     so anything heavier would render as a synthesized fake bold.)
     text-wrap:balance evens out the wider uppercase lines instead of
     orphaning. */
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  line-height: 1.3;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-1);
  text-wrap: balance;
}

/* ── Mini case study block ──────────────────────────────────────
   A self-contained problem, told as a bounded card: a numbered header
   bar with the outcome stated up front, then Problem / Approach /
   Decision rows. Circles carries three of these on one page so the
   problems read as three distinct units. Mini-cases have no default
   section heading (the bar is the heading), which would otherwise trip
   the headless-section pull-up rule above, so restore normal spacing
   here (equal specificity, declared later, wins the tie). */
.case-section.case-section--mini:not(:first-child) {
  border-top: none;
  padding-top: 0;
  margin-top: 0;
}
.cs-mini {
  border: 1px solid color-mix(in oklab, var(--ink-1) 15%, transparent);
  background: color-mix(in oklab, var(--ink-1) 3%, transparent);
}
.cs-mini__bar {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 8px 14px;
  padding: 15px 20px;
  border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 12%, transparent);
}
.cs-mini__num {
  flex-shrink: 0;
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 700;
  letter-spacing: 0.1em;
  color: var(--accent-blue);
}
.cs-mini__title {
  margin-right: auto;
  font-family: var(--font-primary);
  font-size: clamp(15px, 1.7vw, 18px);
  font-weight: 600;
  letter-spacing: -0.01em;
  line-height: 1.2;
  color: var(--ink-1);
}
.cs-mini__outcome {
  flex-shrink: 0;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  /* Stamped ink chip (paper text on ink), same inverse-video language as
     ::selection. The accent stays reserved for type and hairlines — any
     translucent accent surface drifts purple on both themes. */
  color: var(--surface-base);
  background: var(--ink-1);
  padding: 4px 9px 5px;
  white-space: nowrap;
}
.cs-mini__rows { display: flex; flex-direction: column; }
.cs-mini__row {
  display: grid;
  grid-template-columns: 88px 1fr;
  gap: 18px;
  padding: 13px 20px;
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
}
.cs-mini__row:first-child { border-top: none; }
.cs-mini__label {   /* recipe from the grouped .eyebrow rule */
  padding-top: 2px;
}
.cs-mini__text {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.6;
  color: var(--ink-2);
}
.cs-mini__text strong { color: var(--ink-1); font-weight: 700; }
@media (max-width: 599px) {
  .cs-mini__row { grid-template-columns: 1fr; gap: 5px; }
  .cs-mini__label { padding-top: 0; }
}

/* ── Explorable canvas ──────────────────────────────────────────
   A fixed frame of dot-grid paper; the world of screens pans and
   zooms inside it (js/case.js initCanvas). The frame itself never
   moves or resizes. Inert until pressed so it can't eat page scroll. */
.cs-canvas {
  position: relative;
  height: clamp(360px, 58vh, 560px);
  border: 1px solid color-mix(in oklab, var(--ink-1) 15%, transparent);
  background-color: color-mix(in oklab, var(--ink-1) 2%, var(--surface-base));
  background-image: radial-gradient(color-mix(in oklab, var(--ink-1) 16%, transparent) 1px, transparent 1px);
  background-size: 24px 24px;
  overflow: hidden;
  /* Vertical touch swipes keep scrolling the page; horizontal drags
     and pinches belong to the canvas. No activation ceremony. */
  touch-action: pan-y;
}
.cs-canvas:focus-visible { outline: 2px solid var(--ink-stamp); outline-offset: 3px; }
.cs-canvas__world {
  position: absolute;
  left: 0;
  top: 0;
  width: max-content;
  display: flex;
  align-items: flex-start;
  gap: 96px;
  padding: 72px;
  transform-origin: 0 0;
  will-change: transform;
}
.cs-canvas__item {
  display: flex;
  flex-direction: column;
  gap: 22px;
  margin: 0;
}
/* World-space type: sized for the fitted scale (~0.22), grows as you
   zoom — the FigJam behavior, labels living on the paper. */
.cs-canvas__label {
  font-family: var(--font-primary);
  font-size: 40px;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-4);
}
.cs-canvas__paper {
  background: var(--surface-base);
  /* A crisp hairline artifact, not a chunky framed mockup — matches the
     zine's flat ink-on-paper discipline. The lift is real darkness (an
     ink-token shadow would invert to a glow in the dark theme), kept
     shallow so the screen reads as sitting ON the board, not floating. */
  border: 1px solid color-mix(in oklab, var(--ink-1) 20%, transparent);
  box-shadow: 0 14px 30px -18px rgba(0, 0, 0, 0.26);
}
html[data-theme="dark"] .cs-canvas__paper {
  box-shadow: 0 16px 34px -18px rgba(0, 0, 0, 0.7);
}
.cs-canvas__img {
  display: block;
  height: auto;
  user-select: none;
  -webkit-user-drag: none;
}
/* Quiet corner hint — brightens for a beat when a plain wheel passes
   over, pointing at the modifier instead of hijacking the scroll */
.cs-canvas__hint {
  position: absolute;
  left: 10px;
  bottom: 12px;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-4);
  opacity: 0.7;
  transition: opacity 200ms var(--ease-out-quart), color 200ms var(--ease-out-quart);
  pointer-events: none;
}
.cs-canvas__hint.is-flash { opacity: 1; color: var(--ink-1); }
/* Ghost HUD — hairline chips that wake on hover */
.cs-canvas__hud {
  position: absolute;
  right: 8px;
  bottom: 8px;
  display: flex;
  gap: 3px;
  opacity: 0.55;
  transition: opacity 180ms var(--ease-out-quart);
}
.cs-canvas:hover .cs-canvas__hud,
.cs-canvas:focus-within .cs-canvas__hud { opacity: 1; }
.cs-canvas__btn {
  height: 24px;
  min-width: 24px;
  padding: 0 7px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* Frosted glass chips — same recipe as the fullscreen .canvas-zoom
     control: translucent near-white fill over a backdrop blur, hairline
     ink border, soft lift shadow. Keeps the HUD reading as glass hovering
     above the canvas rather than opaque paper stuck to it. */
  background: oklch(99% 0.001 90 / 0.72);
  border: 1px solid color-mix(in oklab, var(--ink-1) 12%, transparent);
  box-shadow:
    0 1px 2px color-mix(in oklab, var(--ink-1) 14%, transparent),
    0 4px 12px -6px color-mix(in oklab, var(--ink-1) 14%, transparent);
  -webkit-backdrop-filter: blur(8px) saturate(1.2);
          backdrop-filter: blur(8px) saturate(1.2);
  color: var(--ink-2);
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  transition: color 140ms var(--ease-out-quart), background 140ms var(--ease-out-quart), transform 140ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
}
@media (hover: hover) and (pointer: fine) {
  .cs-canvas__btn:hover {
    color: var(--ink-1);
    background: color-mix(in oklab, var(--ink-1) 8%, oklch(99% 0.001 90 / 0.82));
  }
}
.cs-canvas__btn:active { transform: scale(0.94); }
/* The readout doubles as the fit button — one less control */
.cs-canvas__zoom {
  min-width: 42px;
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.05em;
}
.cs-canvas__note { margin-top: 12px; margin-bottom: 0; }
/* Touch: the HUD can't be hover-revealed and 24px chips sit far below the
   44px guideline, so the controls stay visible and grow to finger size.
   (After the base rules — a media query adds no specificity.) */
@media (pointer: coarse) {
  .cs-canvas__hud { opacity: 1; }
  .cs-canvas__btn { height: 40px; min-width: 40px; }
}

/* ── Numbered media slots ────────────────────────────────────────
   A grey panel beside a numbered caption, alternating sides down the
   page. Taken from the reference decks, minus their rounded corners
   and drop shadows, which the zero-radius law and the monochrome rule
   rule out. With no image the panel still draws, captioned, so a gap
   reads as a slot waiting for a screen. */
/* The dot field the panels sit on. Faint enough to read as drafting
   paper rather than as decoration, and it stops at the group's edge so
   the rest of the page stays plain. */
.cs-steps-field {
  position: relative;
  padding: clamp(20px, 3.2vw, 40px);
  background-color: color-mix(in oklab, var(--ink-1) 2.5%, transparent);
  background-image: radial-gradient(color-mix(in oklab, var(--ink-1) 16%, transparent) 1px, transparent 1px);
  background-size: 16px 16px;
  background-position: 0 0;
}
.cs-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: clamp(20px, 2.6vw, 34px) clamp(18px, 2.4vw, 30px);
}
/* Half-width by default, full-width when a slot earns the room. Two
   halves tile a row and a wide one breaks it, which is the asymmetry
   the reference gets from its 01/02-then-03 arrangement. */
.cs-step {
  grid-column: span 6;
  display: grid;
  grid-template-columns: 1.5fr 1fr;
  gap: clamp(14px, 1.8vw, 22px);
  align-items: stretch;
}
.cs-step--wide { grid-column: span 12; grid-template-columns: 2.1fr 1fr; }
/* width: 100% + align-self: start is load-bearing, not decoration. The row
   is align-items: stretch, which made the media's HEIGHT definite (set by
   whichever of the two columns was taller, usually the text). aspect-ratio
   then resolved the WIDTH from that height — 162px tall x 4/3 = 216px wide
   inside a 175px column — so the panel overflowed its own grid track and,
   being position: relative, painted straight over the caption beside it.
   Pinning the width to the track and releasing the vertical stretch puts
   the ratio back in charge of the height, which is the direction it was
   always meant to run. */
.cs-step__media {
  position: relative;
  min-width: 0;
  width: 100%;
  align-self: start;
  background: var(--surface-deep);
  aspect-ratio: 4 / 3;
  overflow: visible;   /* the tag overhangs the corner */
}
.cs-step--wide .cs-step__media { aspect-ratio: 16 / 9; }
.cs-step__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.cs-step__media--empty {
  display: grid;
  place-items: center;
  border: 1px solid color-mix(in oklab, var(--ink-1) 9%, transparent);
  background: color-mix(in oklab, var(--surface-base) 72%, var(--surface-deep));
}
.cs-step__await {
  font-family: var(--font-primary);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-4);
}
/* Stamped ink, overhanging the panel's top-left corner so it reads as
   a label placed ON the artifact rather than inside a padding box.
   Chip, so it takes --radius-control like every other chip. */
.cs-step__tag {
  position: absolute;
  top: 0;
  left: 0;
  transform: translate(-7px, -9px);
  z-index: 2;
  padding: 4px 8px 5px;
  border-radius: var(--radius-control);
  background: var(--ink-1);
  color: var(--surface-base);
  font-family: var(--font-primary);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  line-height: 1;
}
/* Index at the top, caption at the foot, so the text column's baseline
   lands with the bottom of the panel beside it. */
.cs-step__body {
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 18px;
  padding-bottom: 2px;
}
.cs-step__num {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 600;
  letter-spacing: 0.16em;
  color: var(--ink-4);
}
.cs-step__foot { min-width: 0; }
.cs-step__title {
  margin: 0 0 7px;
  font-family: var(--font-primary);
  font-size: 14.5px;
  font-weight: 600;
  letter-spacing: -0.005em;
  line-height: 1.3;
  color: var(--ink-1);
}
.cs-step__text {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.55;
  color: var(--ink-3);
}
/* A pair too detailed to squeeze side by side: one full-width panel per
   row instead of a shared row. Each media box takes its own true aspect
   ratio (set inline from the item's w/h in js/case.js buildSteps), so
   the screenshot fills the whole column at full legible width rather
   than being fit into a box sized for the other one. */
.cs-steps--equal { grid-template-columns: 1fr; }
.cs-steps--equal .cs-step,
.cs-steps--equal .cs-step--wide {
  grid-column: auto;
  grid-template-columns: 1fr;
  gap: 14px;
}
.cs-steps--equal .cs-step__media,
.cs-steps--equal .cs-step--wide .cs-step__media {
  background: var(--surface-deep);
}
.cs-steps--equal .cs-step__body {
  display: block;
  padding-bottom: 0;
}
.cs-steps--equal .cs-step__num {
  display: inline;
  margin-right: 10px;
}
.cs-steps--equal .cs-step__title { display: inline; }
.cs-steps--equal .cs-step__foot { margin-top: 8px; }
.cs-steps--equal .cs-step + .cs-step { margin-top: clamp(24px, 3vw, 34px); }

@media (hover: hover) and (pointer: fine) {
  .cs-step__media { transition: transform 420ms var(--ease-out-soft); }
  .cs-step:hover .cs-step__media { transform: translateY(-3px); }
}
@media (max-width: 899px) {
  .cs-step,
  .cs-step--wide { grid-column: span 12; grid-template-columns: 1.4fr 1fr; }
}
@media (max-width: 599px) {
  .cs-steps-field { padding: 16px; }
  .cs-step,
  .cs-step--wide { grid-template-columns: 1fr; }
  .cs-step__media,
  .cs-step--wide .cs-step__media { aspect-ratio: 16 / 10; }
  .cs-step__body { gap: 10px; }
}
@media (prefers-reduced-motion: reduce) {
  .cs-step__media { transition: none; }
  .cs-step:hover .cs-step__media { transform: none; }
}

/* ── Insight cards — evidence, finding, decision ─────────────────
   Each card skims as a source label plus one finding; pressing it
   opens the decision that finding drove. The reveal animates a
   0fr/1fr grid row rather than max-height, so the panel measures its
   own content and no magic number can go stale. Zero radius (content,
   not chrome, per DESIGN.md) with hairline rules doing the separating. */
.cs-insights {
  list-style: none;
  margin: 0;
  padding: 0;
}
/* Hairlines BETWEEN rows only: closing the list top and bottom drew a
   full box around the section, which read as three cells in a crate.
   Open ends let the rows sit in the page's own flow. */
.cs-insight + .cs-insight {
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 11%, transparent);
}
.cs-insight__head {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: start;
  gap: 4px 16px;
  width: 100%;
  padding: 18px 0;
  background: none;
  border: 0;
  text-align: left;
  cursor: pointer;
  color: inherit;
  transition:
    opacity 200ms var(--ease-out-quart),
    transform 160ms var(--ease-out-quart);
}
.cs-insight__head:focus-visible { outline: 2px solid var(--ink-stamp); outline-offset: 3px; }
@media (hover: hover) and (pointer: fine) {
  .cs-insight__head:hover { opacity: 0.62; }
}
/* Press feedback: the section copy says "Press a finding", and this was
   the one pressable on the page without it. 0.99, matching the site's
   other full-width rows (.contact-row, .chat-index__row). */
.cs-insight__head:active { transform: scale(0.99); }
.cs-insight__source {
  grid-column: 1;
  color: var(--accent-blue);
}
.cs-insight__finding {
  grid-column: 1;
  font-family: var(--font-body);
  font-size: 14px;
  line-height: 1.55;
  color: var(--ink-1);
}
/* A plus that becomes a minus. One rule rotates, the other holds, so
   the state change reads without a colour or an icon swap. */
.cs-insight__cue {
  grid-column: 2;
  grid-row: 1 / span 2;
  position: relative;
  align-self: center;
  width: 11px;
  height: 11px;
}
.cs-insight__cue::before,
.cs-insight__cue::after {
  content: "";
  position: absolute;
  inset: 50% 0 auto;
  height: 1px;
  background: var(--ink-2);
  transition: transform 320ms var(--ease-out-quart);
}
.cs-insight__cue::after { transform: rotate(90deg); }
.cs-insight.is-open .cs-insight__cue::after { transform: rotate(0deg); }
.cs-insight__body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 340ms var(--ease-out-quart);
}
.cs-insight.is-open .cs-insight__body { grid-template-rows: 1fr; }
.cs-insight__inner {
  overflow: hidden;
  min-height: 0;
  /* The reveal is the grid row un-clipping; the content itself also
     fades up a few pixels, so opening reads as the decision arriving
     rather than a box changing height. */
  opacity: 0;
  transform: translateY(4px);
  transition:
    opacity 260ms var(--ease-out-quart),
    transform 300ms var(--ease-out-quart);
}
.cs-insight.is-open .cs-insight__inner {
  opacity: 1;
  transform: translateY(0);
}
.cs-insight__label {
  display: block;
  color: var(--ink-4);
  margin-bottom: 6px;
}
.cs-insight__decision {
  margin: 0 0 20px;
  padding-left: 14px;
  border-left: 1px solid var(--accent-blue);
  font-family: var(--font-body);
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--ink-2);
  max-width: 62ch;
}
@media (prefers-reduced-motion: reduce) {
  .cs-insight__body,
  .cs-insight__cue::before,
  .cs-insight__cue::after { transition: none; }
  .cs-insight__head:active { transform: none; }
  .cs-insight__inner { transition: none; transform: none; opacity: 1; }
}

/* ── Scan reveal — before/after ───────────────────────────────────
   The old page is the resting state. Hover sweeps a scan line across
   the frame from the RIGHT edge toward the left (js/case.js
   initCompare toggles .is-after); the redesign is clipped in behind
   the line as it passes, like a scanner reading the page backwards.
   A click or key press pins it, which is also the whole interaction
   on touch. One 800ms ease-out-soft transition drives the clip and
   the line together so they never drift apart — slower and gentler
   than the 620ms ease-out-quart this ran at originally, which read as
   an abrupt snap rather than a sweep. Labels sit above the frame as a
   legend and track the visible state, so nothing is stamped over the
   work. */
.cs-wipe__frame {
  position: relative;
  overflow: hidden;
  border: 1px solid color-mix(in oklab, var(--ink-1) 12%, transparent);
  cursor: pointer;
}
.cs-wipe__frame:focus-visible { outline: 2px solid var(--ink-stamp); outline-offset: 3px; }
.cs-wipe__img {
  display: block;
  width: 100%;
  height: auto;
  user-select: none;
  -webkit-user-drag: none;
}
.cs-wipe__after {
  position: absolute;
  inset: 0;
  /* Clipped from the LEFT (100%) rather than the right: the visible
     sliver of the "after" layer starts pinned to the frame's right
     edge and grows leftward as this inset shrinks toward 0, which is
     what makes the reveal travel right to left. */
  clip-path: inset(0 0 0 100%);
  transition: clip-path 800ms var(--ease-out-soft);
}
/* The after layer fills the frame regardless of the two screenshots'
   native ratios, so the reveal never exposes a gap beneath a shorter
   image. */
.cs-wipe__after .cs-wipe__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top;
}
.cs-wipe__frame.is-after .cs-wipe__after { clip-path: inset(0 0 0 0); }

/* The scan line: paper on a whisper of ink, the same edge the old drag
   wipe used for its divider, now sweeping right to left on the same
   clock as the clip above so the reveal edge and the line never
   separate. No color — an ink hairline reads as a physical edge; blue
   made it a UI element sitting on top of the comparison instead of
   part of it. */
.cs-wipe__line {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 100%;
  width: 1px;
  background: var(--surface-base);
  box-shadow: 0 0 0 1px color-mix(in oklab, var(--ink-1) 16%, transparent);
  transition: left 800ms var(--ease-out-soft);
  pointer-events: none;
}
.cs-wipe__frame.is-after .cs-wipe__line { left: 0%; }

/* Composes .eyebrow in markup (js/case.js buildSlider); this rule keeps
   only the layout deltas. The recipe used to be hand-copied here at the
   retired 700 weight, one step heavier than every other label on the
   page rendering at 600. The active side carries the one blue accent,
   so the legend doubles as the state readout for the reveal. */
.cs-wipe__legend {
  display: flex;
  justify-content: space-between;
  margin-bottom: 8px;
  line-height: 1;
}
.cs-wipe__side { transition: color 300ms var(--ease-out-quart); }
.cs-wipe__side.is-active { color: var(--accent-blue); }
.cs-wipe__note { margin-top: 6px; margin-bottom: 0; text-align: center; }

@media (prefers-reduced-motion: reduce) {
  .cs-wipe__after,
  .cs-wipe__line,
  .cs-wipe__side { transition: none; }
}

/* Reading sizes: follow homepage's restrained scale */
.case .cs-deep-text { font-size: 15px; line-height: 1.68; }
.case-section__body > .cs-deep-text + .cs-deep-text { margin-top: 0.9em; }

/* Spacing safety net: a text block must never sit flush under a component
   (table, grid, media, specimen, etc). Any text block that follows a
   sibling inside a section body gets real breathing room. Consecutive body
   paragraphs keep their tighter 0.9em rhythm via the more specific rule
   above, so this only opens up the component-to-text seam. */
.case-section__body > * + .cs-deep-paragraphs,
.case-section__body > * + .cs-deep-text,
.case-section__body > * + .cs-desc-label {
  margin-top: clamp(24px, 3vw, 34px);
}

/* cs-desc-label's tight 14px/4px rhythm (see ~line 3535) only survives
   intact when it's wrapped in .cs-landing-desc (the image-description
   usage) — that wrapper isn't a direct child of .case-section__body, so
   the safety-net rule above never sees it. A plain `text`-type section
   (e.g. Reflection & Next Steps' Key Takeaway / What I'd Do Differently /
   Next Steps labels) has no such wrapper, so its labels and paragraphs
   ARE direct children and the big component-seam gap was winning by
   specificity instead of the label's own rhythm. These two carve-outs
   restore it wherever a label and a paragraph sit directly next to each
   other, regardless of section type. */
.case-section__body > .cs-deep-text + .cs-desc-label {
  margin-top: 14px;
}
.case-section__body > .cs-desc-label + .cs-deep-text {
  margin-top: 0;
}
.case .cs-overview-para { font-size: 14px; line-height: 1.65; opacity: 1; }
.case .cs-section-label { font-size: var(--t-meta); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 10px; color: var(--ink-4); }
.case .cs-contributions-list { gap: 14px; }
.case .cs-contributions-list li { font-size: 14px; line-height: 1.65; }
.case .cs-deep-list li { font-size: 14px; line-height: 1.65; }
.case .cs-image-caption { font-size: var(--t-body); color: var(--ink-3); margin-bottom: 12px; }

/* Component grids sit flush with the text column (not inset), and take a
   single consistent gap after an intro paragraph — so every section on
   the page shares the same internal rhythm. */
.case .cs-principles-grid { padding: 0; gap: 14px; }
/* A four-card set (Page Redesign) takes a 2x2 instead of leaving the
   fourth card stranded alone on row two of a three-column grid. Three
   cards, the common case, stay on three columns. Bounded to 720px+
   because the :has() selector outweighs the mobile 1fr rule at ~5285,
   which would otherwise keep phones at two cramped columns. */
@media (min-width: 720px) {
  .case .cs-principles-grid:has(> .cs-principle-card:nth-child(4):last-child) {
    grid-template-columns: repeat(2, 1fr);
  }
}
/* Two cards (e.g. after one principle moves out into a .cs-shift focus
   shot) split the row in half instead of leaving a dead third column. */
@media (min-width: 720px) {
  .case .cs-principles-grid:has(> .cs-principle-card:nth-child(2):last-child) {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* One card left (the others moved out into .cs-shift focus shots): it
   takes the plates' 400px column instead of a stranded third. */
.case .cs-principles-grid:has(> .cs-principle-card:only-child) {
  grid-template-columns: min(400px, 100%);
}

/* Shift: one change as a focus shot. The old and new component sit on a
   grey plate (--surface-deep, content radius 0) joined by an arrow; the
   reasoning sits beside the plate, off it, in the principle card's type.
   Generous block margin so it reads as its own moment between the
   before/after pair above and the principle cards below. Stacks on
   phones, plate first. */
.case .cs-shift {
  display: grid;
  grid-template-columns: min(400px, 100%) minmax(0, 1fr);
  gap: var(--space-7);
  align-items: center;
  margin-block: var(--space-8);
}
.case .cs-shift + .cs-principles-grid { margin-top: 0; }
/* Back-to-back shifts live in sibling sections: drop the second one's
   top margin so the pair doesn't stack two margins of air. */
.case .case-section:has(.cs-shift) + .case-section:has(.cs-shift) .cs-shift { margin-top: 0; }
.case .cs-shift__plate {
  display: flex;
  align-items: stretch;
  justify-content: center;
  gap: var(--space-4);
  padding: calc(var(--space-8) + var(--space-3)) var(--space-5);
  /* --surface-tint, not -deep: deep sat only 4% off the white page and
     vanished on most screens. Tint is the impact cards' plate too. */
  background: var(--surface-tint);
  border-radius: var(--radius-content); /* see "Content corners" */
}
/* Image row flexes, label row doesn't: the two images centre on the same
   line and both labels share one baseline even when the crops differ in
   height. */
.case .cs-shift__shot {
  margin: 0;
  display: grid;
  grid-template-rows: 1fr auto;
  justify-items: center;
  align-items: center;
  gap: var(--space-4);
  flex: 1 1 0;
  min-width: 0;
  max-width: 170px;
}
.case .cs-shift__stage {
  position: relative;
  display: block;
  width: 100%;
}
.case .cs-shift__stage img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 0;
}
/* The cart screenshots in the frames morph are images, so they take the
   content corner (see "Content corners"); the layered card fragments
   above stay square so their pieces still meet. */
.case .cs-shift__stage :is(.cs-shift__screen, .cs-shift__crop, .cs-shift__carry) {
  border-radius: var(--radius-content);
}
/* Layered side: images stacked top to bottom, each with its active
   state parked exactly on top, invisible until its tap. */
.case .cs-shift__layers,
.case .cs-shift__layer { display: block; position: relative; }
.case .cs-shift__stage .cs-shift__on {
  position: absolute;
  inset: 0;
  opacity: 0;
}
.case .cs-shift__label { color: var(--ink-4); }
.case .cs-shift__arrow {
  display: flex;
  flex: none;
  align-self: center;
  color: var(--ink-1);
  /* Nudge up by the label row so the arrow centres on the images. */
  margin-bottom: var(--space-6);
}
.case .cs-shift__copy {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* ── Motion ──────────────────────────────────────────────────────
   Entrance (once, on .is-in): old card rises, the arrow draws, new card
   rises. Loop (.is-playing, 6.4s): a cursor opens the old card's own
   duration dropdown; then a cursor on the new side taps the duration
   filter once and picks the pack, and its radio fills. One cursor per
   card, one shared clock, so the two halves never drift. */
.case .cs-shift__shot,
.case .cs-shift__arrow { opacity: 0; }
.case .cs-shift__shot { transform: translateY(14px); }
.case .cs-shift.is-in .cs-shift__shot,
.case .cs-shift.is-in .cs-shift__arrow {
  opacity: 1;
  transform: none;
  transition: opacity 600ms var(--ease-out-quart), transform 800ms var(--ease-out-expo);
}
.case .cs-shift.is-in .cs-shift__arrow { transition-delay: 260ms; }
.case .cs-shift.is-in .cs-shift__shot--to { transition-delay: 620ms; }
.case .cs-shift__arrow path { stroke-dasharray: 1; stroke-dashoffset: 1; }
.case .cs-shift.is-in .cs-shift__arrow-line { stroke-dashoffset: 0; transition: stroke-dashoffset 520ms var(--ease-out-quart) 300ms; }
.case .cs-shift.is-in .cs-shift__arrow-head { stroke-dashoffset: 0; transition: stroke-dashoffset 260ms var(--ease-out-quart) 740ms; }

.case .cs-shift__fx {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
/* The track spans the image, so translate(%) on it is % of the image:
   the cursor's tip (the track's top-left) lands exactly on a tap point. */
.case .cs-shift__track {
  position: absolute;
  inset: 0;
  opacity: 0;
}
.case .cs-shift__cursor {
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  transform-origin: 0 0;
  filter: drop-shadow(0 1px 1px color-mix(in oklab, var(--ink-1) 25%, transparent));
}
.case .cs-shift__cursor svg { display: block; }
.case .cs-shift__cursor path {
  fill: var(--ink-1);
  stroke: var(--surface-base);
  stroke-width: 1.2;
  stroke-linejoin: round;
}
.case .cs-shift__ring {
  position: absolute;
  width: 26px;
  height: 26px;
  margin: -13px 0 0 -13px;
  border: 1.5px solid var(--ink-1);
  border-radius: 50%;
  opacity: 0;
}
.case .cs-shift__stage .cs-shift__pick {
  position: absolute;
  height: auto;
  translate: -50% -50%;
  opacity: 0;
  scale: 0.3;
}

.case .cs-shift.is-in .cs-shift__track,
.case .cs-shift.is-in .cs-shift__cursor,
.case .cs-shift.is-in .cs-shift__ring,
.case .cs-shift.is-in .cs-shift__pick,
.case .cs-shift.is-in .cs-shift__on {
  animation-duration: 6.4s;
  animation-iteration-count: infinite;
  animation-delay: 1.4s;
  animation-fill-mode: both;
  animation-timing-function: var(--ease-out-soft);
  animation-play-state: paused;
}
.case .cs-shift.is-playing .cs-shift__track,
.case .cs-shift.is-playing .cs-shift__cursor,
.case .cs-shift.is-playing .cs-shift__ring,
.case .cs-shift.is-playing .cs-shift__pick,
.case .cs-shift.is-playing .cs-shift__on { animation-play-state: running; }

.case .cs-shift.is-in .cs-shift__shot--from .cs-shift__track { animation-name: shift-cursor-from; }
.case .cs-shift.is-in .cs-shift__shot--to .cs-shift__track { animation-name: shift-cursor-to; }
.case .cs-shift.is-in .cs-shift__shot--from .cs-shift__cursor { animation-name: shift-press-from; }
.case .cs-shift.is-in .cs-shift__shot--to .cs-shift__cursor { animation-name: shift-press-to; }
.case .cs-shift.is-in .cs-shift__shot--from .cs-shift__ring--1 { animation-name: shift-ring-a; }
.case .cs-shift.is-in .cs-shift__shot--to .cs-shift__ring--1 { animation-name: shift-ring-b; }
.case .cs-shift.is-in .cs-shift__shot--to .cs-shift__ring--2 { animation-name: shift-ring-c; }
.case .cs-shift.is-in .cs-shift__pick { animation-name: shift-pick; }
.case .cs-shift.is-in .cs-shift__on--1 { animation-name: shift-on-1; }

/* Old side: in from the lower right, click the dropdown (~16%), linger
   as if waiting on it, leave. Seven more cards each hide the same
   dropdown, which the neighbouring "9 cards, 8 dropdowns" note says. */
@keyframes shift-cursor-from {
  0%   { opacity: 0; transform: translate(calc(var(--x1) + 22%), calc(var(--y1) + 34%)); }
  4%   { opacity: 1; }
  14%, 30% { opacity: 1; transform: translate(var(--x1), var(--y1)); }
  36%, 100% { opacity: 0; transform: translate(var(--x1), var(--y1)); }
}
@keyframes shift-press-from {
  0%, 15% { transform: scale(1); }
  17%     { transform: scale(0.82); }
  20%, 100% { transform: scale(1); }
}
@keyframes shift-ring-a {
  0%, 16% { opacity: 0; transform: scale(0.4); }
  17%     { opacity: 0.55; transform: scale(0.4); }
  27%, 100% { opacity: 0; transform: scale(1.5); }
}
/* New side: one tap on the duration filter (~50%), glide to the pack,
   pick it (~66%), the radio fills and holds, then everything resets. */
@keyframes shift-cursor-to {
  0%, 36% { opacity: 0; transform: translate(calc(var(--x1) + 18%), calc(var(--y1) + 40%)); }
  40%     { opacity: 1; }
  48%, 52% { opacity: 1; transform: translate(var(--x1), var(--y1)); }
  63%, 72% { opacity: 1; transform: translate(var(--x2), var(--y2)); }
  80%, 100% { opacity: 0; transform: translate(var(--x2), var(--y2)); }
}
@keyframes shift-press-to {
  0%, 49% { transform: scale(1); }
  51%     { transform: scale(0.82); }
  54%, 64% { transform: scale(1); }
  66%     { transform: scale(0.82); }
  69%, 100% { transform: scale(1); }
}
@keyframes shift-ring-b {
  0%, 50% { opacity: 0; transform: scale(0.4); }
  51%     { opacity: 0.55; transform: scale(0.4); }
  61%, 100% { opacity: 0; transform: scale(1.5); }
}
@keyframes shift-ring-c {
  0%, 65% { opacity: 0; transform: scale(0.4); }
  66%     { opacity: 0.55; transform: scale(0.4); }
  76%, 100% { opacity: 0; transform: scale(1.5); }
}
/* The filter's active state lands on the first tap and holds until the
   loop resets; the tick pops in on the pick, a touch past full size and
   back, then fades with everything else. */
@keyframes shift-on-1 {
  0%, 50% { opacity: 0; }
  53%, 90% { opacity: 1; }
  97%, 100% { opacity: 0; }
}
@keyframes shift-pick {
  0%, 66% { opacity: 0; scale: 0.3; }
  69%     { opacity: 1; scale: 1.15; }
  73%, 90% { opacity: 1; scale: 1; }
  97%, 100% { opacity: 0; scale: 1; }
}

/* ── Solo shift: one screenshot, live values ───────────────────────
   The picture sits in its own box (.cs-shift__pic, a size container so
   the reels' type scales with it via cqw). Reels are windows patched
   over the screenshot's own numbers, in its paper and ink; each holds a
   strip of values that slides up a line on the first tap and back on
   the second. Below --fade the picture blurs and dissolves into the
   plate, so the eye stays on the pack's title, stepper and total. */
.case .cs-shift--solo .cs-shift__shot--solo { max-width: 300px; grid-template-rows: auto; }
.case .cs-shift__pic {
  position: relative;
  display: block;
  container-type: inline-size;
  -webkit-mask-image: linear-gradient(to bottom, black var(--fade, 100%), transparent 98%);
          mask-image: linear-gradient(to bottom, black var(--fade, 100%), transparent 98%);
}
.case .cs-shift__blur {
  position: absolute;
  inset: calc(var(--fade, 100%) - 3%) 0 0;
  -webkit-backdrop-filter: blur(3px);
          backdrop-filter: blur(3px);
  -webkit-mask-image: linear-gradient(to bottom, transparent, black 40%);
          mask-image: linear-gradient(to bottom, transparent, black 40%);
  pointer-events: none;
}
.case .cs-shift__reel {
  position: absolute;
  overflow: hidden;
  background: var(--shot-paper);
  color: var(--shot-ink);
  font-family: var(--font-body);
  font-weight: 700;
  font-size: var(--reel-size);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}
.case .cs-shift__reel--qty { color: var(--shot-ink-strong); }
.case .cs-shift__reel-strip {
  display: flex;
  flex-direction: column;
  height: 200%;
}
.case .cs-shift__reel-strip > span {
  flex: 1 0 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
}
.case .cs-shift__reel--end .cs-shift__reel-strip > span { justify-content: flex-end; padding-right: 0.5cqw; }

.case .cs-shift.is-in .cs-shift__reel-strip {
  animation: shift-reel 6.4s var(--ease-out-expo) 1.4s infinite both paused;
}
.case .cs-shift.is-playing .cs-shift__reel-strip { animation-play-state: running; }
.case .cs-shift--solo.is-in .cs-shift__track { animation-name: shift-cursor-solo; }
.case .cs-shift--solo.is-in .cs-shift__cursor { animation-name: shift-press-solo; }
.case .cs-shift--solo.is-in .cs-shift__ring--1 { animation-name: shift-ring-a; }
.case .cs-shift--solo.is-in .cs-shift__ring--2 { animation-name: shift-ring-solo; }

/* The − button's active state: ring + glyph copied from the enabled +,
   laid over the screenshot's disabled − while the quantity is 2. */
.case .cs-shift__lit {
  position: absolute;
  border-radius: 50%;
  background: var(--shot-paper);
  box-shadow: inset 0 0 0 0.2cqw var(--shot-ring);
  opacity: 0;
}
.case .cs-shift__lit::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 31%;
  height: 0.2cqw;
  translate: -50% -50%;
  background: var(--shot-glyph);
}
.case .cs-shift.is-in .cs-shift__lit {
  animation: shift-lit 6.4s var(--ease-out-quart) 1.4s infinite both paused;
}
.case .cs-shift.is-playing .cs-shift__lit { animation-play-state: running; }
@keyframes shift-lit {
  0%, 17%  { opacity: 0; }
  21%, 62% { opacity: 1; }
  66%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .case .cs-shift .cs-shift__lit { animation: none; }
}

/* + at ~17%, − at ~62%; the reels roll a beat after each press. */
@keyframes shift-cursor-solo {
  0%   { opacity: 0; transform: translate(calc(var(--x1) + 6%), calc(var(--y1) + 30%)); }
  4%   { opacity: 1; }
  14%, 40% { opacity: 1; transform: translate(var(--x1), var(--y1)); }
  58%, 74% { opacity: 1; transform: translate(var(--x2), var(--y2)); }
  84%, 100% { opacity: 0; transform: translate(var(--x2), var(--y2)); }
}
@keyframes shift-press-solo {
  0%, 15% { transform: scale(1); }
  17%     { transform: scale(0.82); }
  20%, 60% { transform: scale(1); }
  62%     { transform: scale(0.82); }
  65%, 100% { transform: scale(1); }
}
@keyframes shift-ring-solo {
  0%, 61% { opacity: 0; transform: scale(0.4); }
  62%     { opacity: 0.55; transform: scale(0.4); }
  72%, 100% { opacity: 0; transform: scale(1.5); }
}
@keyframes shift-reel {
  0%, 17%  { transform: translateY(0); }
  23%, 62% { transform: translateY(-50%); }
  68%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .case .cs-shift .cs-shift__reel-strip { animation: none; }
}

/* ── Flip: copy left, plate right ─────────────────────────────── */
.case .cs-shift--flip { grid-template-columns: minmax(0, 1fr) min(400px, 100%); }
.case .cs-shift--flip .cs-shift__plate { order: 2; }

/* ── Frames shift: one layout morphing in place ───────────────────
   The screenshot's own shapes are redrawn as boxes (tiles + the cart
   panel) so they can change geometry: each holds a before box
   (--x0/--y0/--w0/--h0, % of the frame) and an after box (--x1…) and
   glides between them. On top of the glide, the classic principles
   carry the "gooey" feel, all within the frame:
   - anticipation: a small squash the instant before it sets off;
   - stretch along the direction of travel, squash on arrival, one
     small rebound, settle;
   - a rolling stagger (70ms per diagonal step) so the grid ripples
     like one soft mass instead of nine stamped boxes.
   The panel's content (from the screenshots) only ever crossfades at
   rest, so no text is ever squashed. 8s loop: hold before, morph,
   hold after, morph back. Tiles and radios are the screenshot's own
   fixed colours (--shot-*), not themed. */
.case .cs-shift--frames .cs-shift__plate { padding-inline: var(--space-5); }
.case .cs-shift--frames .cs-shift__shot--frames { max-width: none; }
.case .cs-shift__morph {
  position: absolute;
  inset: 0;
  container-type: inline-size;
  pointer-events: none;
}
.case .cs-shift__tile,
.case .cs-shift__panel {
  position: absolute;
  left: var(--x0);
  top: var(--y0);
  width: var(--w0);
  height: var(--h0);
  transform-origin: center;
}
/* One constant corner through the whole morph, sized in cqw so it's the
   same share of the frame at any width: ~18px on the 2160 canvas, the
   middle of the screenshots' own 24px (before) and 12px (after) tiles.
   The panel matches the cart's ~22px. It never animates; only the
   squash and stretch do. */
.case .cs-shift__tile {
  background: var(--shot-fill);
  border-radius: 0.83cqw;
}
.case .cs-shift__panel {
  border-radius: 1cqw;
  /* Opaque paper above the tiles, so tiles sliding under it mid-morph
     are hidden rather than showing through. The content crops sit on
     top (z-index 2) but are inset ~12px inside the panel's box in the
     data, so they can never paint over its hairline. */
  z-index: 1;
  background: var(--shot-paper);
  box-shadow: inset 0 0 0 1px var(--shot-line);
}
.case .cs-shift__radio {
  position: absolute;
  left: var(--rx0);
  top: var(--ry0);
  width: var(--dot);
  aspect-ratio: 1;
  translate: -50% -50%;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--shot-line);
}
.case .cs-shift--frames .cs-shift__stage .cs-shift__crop,
.case .cs-shift--frames .cs-shift__stage .cs-shift__carry {
  position: absolute;
  inset: 0;
}
.case .cs-shift__crop--in { opacity: 0; }
.case .cs-shift--frames .cs-shift__stage .cs-shift__crop { z-index: 2; }
.case .cs-shift__label--swap { display: grid; }
.case .cs-shift__label--swap > span { grid-area: 1 / 1; justify-self: center; }
.case .cs-shift__label-2 { opacity: 0; }

/* One clock for every part; --d staggers each part's copy of it. */
.case .cs-shift--frames.is-in :is(.cs-shift__tile, .cs-shift__panel, .cs-shift__radio, .cs-shift__crop, .cs-shift__carry, .cs-shift__label-1, .cs-shift__label-2) {
  animation-duration: 8s;
  animation-iteration-count: infinite;
  /* Negative delay: join the loop 1.6s in, so the first morph starts
     ~0.6s after the plate appears instead of after a 3.4s still hold. */
  animation-delay: calc(-1.6s + var(--d, 0ms));
  animation-fill-mode: both;
  animation-timing-function: var(--ease-out-quart);
  animation-play-state: paused;
}
.case .cs-shift--frames.is-playing :is(.cs-shift__tile, .cs-shift__panel, .cs-shift__radio, .cs-shift__crop, .cs-shift__carry, .cs-shift__label-1, .cs-shift__label-2) {
  animation-play-state: running;
}
.case .cs-shift--frames.is-in .cs-shift__tile { animation-name: morph-geo, morph-squish-x; }
.case .cs-shift--frames.is-in .cs-shift__panel { animation-name: morph-geo, morph-squish-y; }
/* The radio rides its tile's clock (same --d, inherited). */
.case .cs-shift--frames.is-in .cs-shift__radio { animation-name: morph-radio; }
.case .cs-shift--frames.is-in .cs-shift__crop--out { animation-name: frames-before-rest; }
.case .cs-shift--frames.is-in .cs-shift__crop--in { animation-name: frames-after-rest; }
.case .cs-shift--frames.is-in .cs-shift__carry { animation-name: morph-carry; }
.case .cs-shift--frames.is-in .cs-shift__label-1 { animation-name: frames-before; }
.case .cs-shift--frames.is-in .cs-shift__label-2 { animation-name: frames-after; }

/* Geometry: hold, glide (28 to 50%), hold, glide back (78 to 100%). */
@keyframes morph-geo {
  0%, 28%  { left: var(--x0); top: var(--y0); width: var(--w0); height: var(--h0); }
  50%, 78% { left: var(--x1); top: var(--y1); width: var(--w1); height: var(--h1); }
  100%     { left: var(--x0); top: var(--y0); width: var(--w0); height: var(--h0); }
}
@keyframes morph-radio {
  0%, 28%  { left: var(--rx0); top: var(--ry0); }
  50%, 78% { left: var(--rx1); top: var(--ry1); }
  100%     { left: var(--rx0); top: var(--ry0); }
}
/* Tiles travel sideways: stretch wide, land with a tall squash. */
@keyframes morph-squish-x {
  0%, 25%  { transform: scale(1, 1); }
  28%      { transform: scale(0.95, 1.05); }
  35%      { transform: scale(1.1, 0.88); }
  45%      { transform: scale(0.95, 1.06); }
  51%      { transform: scale(1.02, 0.98); }
  57%, 75% { transform: scale(1, 1); }
  78%      { transform: scale(0.95, 1.05); }
  85%      { transform: scale(1.1, 0.88); }
  95%      { transform: scale(0.96, 1.04); }
  100%     { transform: scale(1, 1); }
}
/* The panel travels up (and back down): stretch tall, land wide. */
@keyframes morph-squish-y {
  0%, 25%  { transform: scale(1, 1); }
  28%      { transform: scale(1.04, 0.95); }
  35%      { transform: scale(0.92, 1.1); }
  45%      { transform: scale(1.05, 0.96); }
  51%      { transform: scale(0.99, 1.01); }
  57%, 75% { transform: scale(1, 1); }
  78%      { transform: scale(0.96, 1.04); }
  85%      { transform: scale(0.92, 1.1); }
  95%      { transform: scale(1.04, 0.97); }
  100%     { transform: scale(1, 1); }
}
@keyframes morph-carry {
  0%, 28%  { transform: none; }
  50%, 78% { transform: var(--to); }
  100%     { transform: none; }
}
/* Panel content: visible only while its state is at rest. */
@keyframes frames-before-rest {
  0%, 26%  { opacity: 1; }
  31%, 96% { opacity: 0; }
  100%     { opacity: 1; }
}
@keyframes frames-after-rest {
  0%, 48%  { opacity: 0; }
  55%, 76% { opacity: 1; }
  80%, 100% { opacity: 0; }
}
@keyframes frames-after {
  0%, 40%  { opacity: 0; }
  46%, 88% { opacity: 1; }
  94%, 100% { opacity: 0; }
}
@keyframes frames-before {
  0%, 40%  { opacity: 1; }
  46%, 88% { opacity: 0; }
  94%, 100% { opacity: 1; }
}
/* Still (reduced motion / no observer): the after layout, at rest. */
.case .cs-shift--frames.is-still :is(.cs-shift__tile, .cs-shift__panel, .cs-shift__radio, .cs-shift__crop, .cs-shift__carry, .cs-shift__label-1, .cs-shift__label-2) { animation: none; }
.case .cs-shift--frames.is-still :is(.cs-shift__tile, .cs-shift__panel) { left: var(--x1); top: var(--y1); width: var(--w1); height: var(--h1); }
.case .cs-shift--frames.is-still .cs-shift__radio { left: var(--rx1); top: var(--ry1); }
.case .cs-shift--frames.is-still .cs-shift__carry { transform: var(--to); }
.case .cs-shift--frames.is-still :is(.cs-shift__crop--in, .cs-shift__label-2) { opacity: 1; }
.case .cs-shift--frames.is-still :is(.cs-shift__crop--out, .cs-shift__label-1) { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .case .cs-shift--frames :is(.cs-shift__tile, .cs-shift__panel, .cs-shift__radio, .cs-shift__crop, .cs-shift__carry, .cs-shift__label-1, .cs-shift__label-2) { animation: none; }
  .case .cs-shift--frames :is(.cs-shift__tile, .cs-shift__panel) { left: var(--x1); top: var(--y1); width: var(--w1); height: var(--h1); }
  .case .cs-shift--frames .cs-shift__radio { left: var(--rx1); top: var(--ry1); }
  .case .cs-shift--frames .cs-shift__carry { transform: var(--to); }
  .case .cs-shift--frames :is(.cs-shift__crop--in, .cs-shift__label-2) { opacity: 1; }
  .case .cs-shift--frames :is(.cs-shift__crop--out, .cs-shift__label-1) { opacity: 0; }
}

/* Reduced motion (and no IntersectionObserver): no entrance, no loop;
   the settled end state, with the new pack already picked. */
.case .cs-shift.is-still .cs-shift__shot,
.case .cs-shift.is-still .cs-shift__arrow { opacity: 1; transform: none; transition: none; }
.case .cs-shift.is-still .cs-shift__arrow path { stroke-dashoffset: 0; transition: none; }
.case .cs-shift.is-still .cs-shift__track,
.case .cs-shift.is-still .cs-shift__cursor,
.case .cs-shift.is-still .cs-shift__ring { animation: none; opacity: 0; }
.case .cs-shift.is-still .cs-shift__reel-strip,
.case .cs-shift.is-still .cs-shift__lit { animation: none; }
.case .cs-shift.is-still .cs-shift__pick,
.case .cs-shift.is-still .cs-shift__on { animation: none; opacity: 1; scale: 1; }
@media (prefers-reduced-motion: reduce) {
  .case .cs-shift__shot,
  .case .cs-shift__arrow { opacity: 1; transform: none; transition: none; }
  .case .cs-shift__arrow path { stroke-dashoffset: 0; }
  .case .cs-shift .cs-shift__track,
  .case .cs-shift .cs-shift__cursor,
  .case .cs-shift .cs-shift__ring { animation: none; opacity: 0; }
  .case .cs-shift .cs-shift__pick,
  .case .cs-shift .cs-shift__on { animation: none; opacity: 1; scale: 1; }
}

/* A 400px plate needs ~260px beside it for the copy; below that the
   copy stacks under the plate instead of wrapping a word per line. */
@media (max-width: 959px) {
  .case .cs-shift,
  .case .cs-shift--flip { grid-template-columns: min(400px, 100%); gap: var(--space-5); }
  .case .cs-shift--flip .cs-shift__plate { order: 0; }
}
@media (max-width: 719px) {
  .case .cs-shift { grid-template-columns: 1fr; gap: var(--space-5); margin-block: var(--space-7); }
  .case .cs-shift__plate { padding: var(--space-7) var(--space-4); gap: var(--space-3); }
  .case .cs-shift__arrow svg { width: 28px; }
  .case .cs-shift__cursor svg { width: 12px; height: 15px; }
  .case .cs-shift__ring { width: 20px; height: 20px; margin: -10px 0 0 -10px; }
}

.case .cs-deep-paragraphs + .cs-principles-grid,
.case .cs-deep-paragraphs + .cs-metrics,
.case .cs-deep-paragraphs + .cs-compare-table,
.case .cs-deep-paragraphs + .cs-contrast,
.case .cs-deep-paragraphs + .cs-persona-grid { margin-top: 22px; }

/* ── "At a glance" snapshot strip ──────────────────────────────
   The scannable TL;DR at the top of a case: label / value rows in a
   single bordered block. Reads top-to-bottom in seconds. */
.cs-snapshot {
  display: grid;
  grid-template-columns: 112px 1fr;
  border: 1px solid color-mix(in oklab, var(--ink-1) 12%, transparent);
  background: var(--surface-card);
}
.cs-snapshot__row {
  display: contents;
}
.cs-snapshot__label {   /* eyebrow recipe; blue delta re-asserted after the group rule */
  grid-column: 1;
  padding: 15px 16px;
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
}
.cs-snapshot__text {
  grid-column: 2;
  padding: 15px 18px 15px 8px;
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.55;
  color: var(--ink-1);
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
}
.cs-snapshot__row:first-child .cs-snapshot__label,
.cs-snapshot__row:first-child .cs-snapshot__text {
  border-top: none;
}

/* ── Contrast / decision fork (usual answer → the bet) ─────────────
   A reframe read as a choice with a trade-off. Redesigned August 2026:
   each side is a full-width --surface-tint plate (the visibly grey
   token; --surface-deep was too close to paper to read as filled), the
   label folds into the sentence as a bold lead ("What I shipped.")
   instead of sitting above as a separate eyebrow line, and the copy runs
   the full column instead of stopping at a reading-measure cap — a cap
   belongs on paragraphs meant to be read start to finish, not on a
   two-line decision card, where it just looked like the text got cut
   off. The rejected path recedes on ink and opacity; the shipped one
   reads at full ink with a blue lead, so "what I chose" is legible at a
   glance without a box, an arrow, or a border doing the work. */
.cs-contrast {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-top: 8px;
}
.cs-contrast__panel {
  background: var(--surface-tint);
  padding: clamp(16px, 2vw, 20px) clamp(18px, 2.2vw, 22px);
}
.cs-contrast__panel--before { opacity: 0.68; }
/* `pair: true`: the same two panels side by side, stacked again on a phone. */
.case .cs-contrast--pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
}
@media (max-width: 640px) {
  .case .cs-contrast--pair { grid-template-columns: 1fr; }
}
.cs-contrast__lead {
  font-weight: 700;
  color: var(--ink-1);
}
.cs-contrast__panel--after .cs-contrast__lead { color: var(--accent-blue); }
.cs-contrast__text {
  margin: 0;
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 400;
  line-height: 1.65;
  color: var(--ink-2);
}

/* ── Stat band (consolidated evidence) ─────────────────────────
   Each figure is its own card — a bordered, padded box (zero radius,
   like everything else) lifted off the paper by a soft drop shadow, with
   a real gap between them. The big value leads; the label sits beneath as
   a quiet caption. Separate cards, not one divided box. */
.cs-metrics {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: 12px;
  /* Room above each row for the perched ravens (16px tall). */
  row-gap: 28px;
  padding-top: 18px;
}
/* A visibly grey plate: --surface-tint fill, no border, zero radius, no
   hover motion (the birds are the only thing that moves here). Ravens from the street footer perch on its top edge (js/case.js
   perchBirds / initRavens), so the plate does not clip its children. */
.cs-metric {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0;       /* let a wide value shrink, never overflow */
  padding: clamp(18px, 2vw, 24px) clamp(16px, 1.8vw, 22px);
  background: var(--surface-tint);
  border: none;
}
/* A perched raven: feet on the plate's top edge (bottom: 100%), 1.75px
   per sprite pixel, a touch smaller than the footer's own birds. The
   sprite faces left; .is-flipped mirrors it. Frames swap by opacity:
   perched at rest; in flight js/case.js swaps the two wing frames every
   110ms, the footer's own wing beat. */
.cs-bird {
  position: absolute;
  bottom: 100%;
  width: 21px;
  height: 15.75px;
  margin-left: -10.5px;
  color: var(--ink-1);
  pointer-events: none;
  z-index: 2;
}
.cs-bird svg { display: block; width: 100%; height: 100%; }
.cs-bird.is-flipped svg { transform: scaleX(-1); }
.cs-bird__f--up, .cs-bird__f--down { opacity: 0; }
.cs-bird.is-flying .cs-bird__f--perch { opacity: 0; }
.cs-bird.is-flying .cs-bird__f--down { opacity: 1; }
.cs-bird.is-flying.is-wing-up .cs-bird__f--down { opacity: 0; }
.cs-bird.is-flying.is-wing-up .cs-bird__f--up { opacity: 1; }
.cs-metric__value {
  display: block;
  font-family: var(--font-primary);
  font-size: clamp(30px, 3.4vw, 42px);
  font-weight: 500;
  letter-spacing: -0.025em;
  line-height: 1;
  color: var(--ink-1);
  margin-bottom: 8px;
  font-variant-numeric: tabular-nums;
}
.cs-metric__label {
  display: block;
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.45;
  color: var(--ink-3);
  max-width: 22ch;
}
/* Phones: a 2x2 of plates. The old `padding: 14px 0 0` dated from before
   the plates had a fill, and left the text flush on the grey's edges. */
@media (max-width: 599px) {
  .cs-metrics { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; row-gap: 28px; }
  .cs-metric { padding: var(--space-4) var(--space-3) var(--space-4); }
  .cs-metric__value { font-size: clamp(28px, 8.5vw, 34px); }
  .cs-metric__label { max-width: none; }
}

/* Detailed variant — value + name + rationale per card, in a 2-up grid.
   Same card treatment; here the label is the metric's name (grotesque,
   ink-1) and the sentence sits below it. */
.cs-metrics--detailed {
  grid-auto-flow: row;
  grid-template-columns: 1fr 1fr;
}
.cs-metrics--detailed .cs-metric__value {
  font-size: clamp(28px, 3.1vw, 38px);
}
.cs-metrics--detailed .cs-metric__label {
  font-family: var(--font-primary);
  font-size: var(--t-body);
  font-weight: 600;
  letter-spacing: -0.005em;
  color: var(--ink-1);
}
.cs-metric__desc {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.55;
  color: var(--ink-3);
  margin-top: 8px;
}
@media (max-width: 599px) {
  .cs-metrics--detailed { grid-template-columns: 1fr; }
}

/* ── Editorial statement (thesis / how-might-we) ───────────────
   A reframing beat: the "How might we" sits above as a tracked eyebrow
   and the question continues from it below, inside a filled box that
   matches the home page's overview panel: subtle paper fill, generous
   padding, zero radius. */
.cs-statement {
  background: var(--surface-deep);
  padding: clamp(18px, 2vw, 26px) clamp(20px, 2vw, 28px);
}
/* Full-page cases only: the overview panel's fill alone reads fine on the
   home page, but the same subtle fill floated in a case body needs an edge
   to read as a box rather than just padding — same border color as
   .cs-persona-card, which carries the identical fill correctly. */
.case .cs-statement {
  border: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
}
/* Small tracked eyebrow above the question (composes .eyebrow). */
.cs-statement__label {
  display: block;
  margin-bottom: 10px;
}
.cs-statement__text {
  font-family: var(--font-body);   /* IBM Plex Sans — reads as a calm reading-register question, not a condensed label */
  font-weight: 400;
  font-size: clamp(15px, 1.1vw, 17px);
  line-height: 1.5;
  letter-spacing: 0;
  color: var(--ink-2);
  text-wrap: pretty;
  max-width: none;
}
.cs-statement__text em {
  font-style: italic;
}

/* ── Feature list (spec-sheet: bold term, tight description) ────
   A scannable two-column list separated by whitespace alone — no
   hairlines (the page already carries enough section rules). The bold
   terms give the left edge its rhythm. */
.cs-featurelist {
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.cs-featurelist__row {
  display: grid;
  grid-template-columns: 33% 1fr;
  gap: 6px 28px;
  align-items: baseline;
}
.cs-featurelist__term {
  font-family: var(--font-primary);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.4;
  color: var(--ink-1);
}
.cs-featurelist__desc {
  font-family: var(--font-body);
  font-size: 13.5px;
  line-height: 1.62;
  color: var(--ink-2);
}
@media (max-width: 599px) {
  .cs-featurelist__row { grid-template-columns: 1fr; gap: 4px; padding: 14px 0; }
}

/* ── Scroll-stack — mechanics that pin into a labeled deck ──────
   A vertical run of cards, each position:sticky at an incrementing top
   (--stack-top + --i * --stack-lip). As the page scrolls, every card
   rises, pins, and covers all but a thin labeled tab (--stack-lip, sized
   to the tab exactly) of the card before it — so the section assembles
   itself into a numbered deck, frontmost card open, the rest collapsed
   to tabs. Geometry lives here; initStack() only paints the blue
   active-accent and settles each body via --p (0 shown → 1 covered).
   Later cards paint over earlier ones by source order, so no z-index is
   needed. Below 720px the sticky is dropped for a plain, legible list. */
.cs-stack {
  --stack-top: 84px;    /* viewport offset the pile pins against */
  --stack-lip: 54px;    /* visible tab of a buried card = tab height */
  list-style: none;
  margin: 26px 0 0;
  padding: 0 0 clamp(80px, 15vh, 160px);   /* trailing room so the full pile holds before release */
}
.cs-stack__lead { margin: 26px 0 0; }
.cs-stack__lead + .cs-stack { margin-top: 22px; }

.cs-stack__card {
  position: sticky;
  top: calc(var(--stack-top) + var(--i) * var(--stack-lip));
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 132px;
  margin-bottom: 40px;   /* extra dwell between hand-offs (invisible once covered) */
  padding: 0 26px 26px;
  background: var(--surface-card);
  border: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
  /* A whisper of lift: the shadow sits just above each card's top edge, so
     when a card climbs over the one below the seam reads as a soft gradient
     instead of a hard line slicing through the buried card's text. */
  box-shadow: 0 -5px 14px -10px color-mix(in oklab, var(--ink-1) 20%, transparent);
  transition: opacity 620ms var(--ease-out-soft), transform 620ms var(--ease-out-soft);
}
.cs-stack__card:last-child { margin-bottom: 0; }
/* Entrance — cards are visible by default, so a JS or observer failure
   never hides content. initStack() adds .is-armed to opt into the fade,
   then .is-in per card as it scrolls in. A pinned card is always in
   view, so it has .is-in (transform: none) before it pins — the
   entrance transform never fights the sticky offset. */
.cs-stack.is-armed .cs-stack__card:not(.is-in) { opacity: 0; transform: translateY(16px); }

/* The tab is fixed to --stack-lip so buried cards stack flush, one tab
   apiece — it carries the number + term and is the only strip of a
   buried card left visible. Each card's own border rules the seam
   between stacked tabs, so the tab needs no divider of its own. */
.cs-stack__tab {
  display: flex;
  align-items: center;
  gap: 14px;
  height: var(--stack-lip);
}
.cs-stack__num {
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 700;
  letter-spacing: 0.08em;
  line-height: 1;
  color: var(--ink-4);
  font-variant-numeric: tabular-nums;
  transition: color 260ms var(--ease-out-quart);
}
.cs-stack__term {
  margin: 0;
  font-family: var(--font-primary);
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.3;
  color: var(--ink-1);
}
.cs-stack__desc {
  margin: 0;
  max-width: 54ch;
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-2);
  /* Front-loaded fade: the body is fully gone by the time the next card is
     ~55% of the way over it, so its edge never slices a still-visible line. */
  opacity: max(0, calc(1 - var(--p, 0) * 1.8));
  transform: translateY(calc(var(--p, 0) * -8px));
}
/* The single blue accent: only the frontmost pinned card's number. */
.cs-stack__card.is-active .cs-stack__num { color: var(--accent-blue); }

/* Mobile — no pinning; a plain vertical list, every body open. */
@media (max-width: 719px) {
  .cs-stack { padding-bottom: 0; }
  .cs-stack__card {
    position: static;
    min-height: 0;
    margin-bottom: 12px;
    padding: 0 18px 20px;
  }
  .cs-stack__desc { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .cs-stack.is-armed .cs-stack__card:not(.is-in) { opacity: 1; transform: none; }
  .cs-stack__card { transition: none; }
  .cs-stack__desc { opacity: 1; transform: none; }
}

/* ══════════════════════════════════════════════════════════════
   STILL — interactive prototype (real app, embedded via iframe)
   Renders the deployed STILL task window in its "?embed=tasks" mode,
   so the case study shows the exact production product. Sized to the
   360x480 window plus breathing room; transparent so the page shows
   through around it.
   ══════════════════════════════════════════════════════════════ */
.still-embed {
  display: flex;
  justify-content: center;
  margin-top: 4px;
}
.still-embed__stage {
  position: relative;
  width: 100%;
  max-width: 620px;
}
.still-embed__frame {
  width: 100%;
  max-width: 620px;
  height: 540px;
  border: none;
  background: transparent;
  color-scheme: light;
  display: block;
}
/* Scroll shield: the iframe swallows the mouse wheel, which stalls the
   page scroll when you reach it. This transparent layer sits over the
   frame so the wheel scrolls the page instead; a click drops it (via
   .is-active) so you can actually use the app, and it re-arms on leave. */
.still-embed__shield {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding: 0 0 18px;
  background: transparent;
  border: 0;
  cursor: pointer;
  transition: opacity 0.2s var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
}
.still-embed.is-active .still-embed__shield {
  opacity: 0;
  pointer-events: none;
}
.still-embed__hint {
  font-family: var(--font-body);
  font-size: var(--t-meta);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-2);
  background: rgba(255, 255, 255, 0.86);
  padding: 5px 10px;
  border: 1px solid color-mix(in oklab, var(--ink-1) 14%, transparent);
}
.still-app-note {
  margin: 14px auto 0;
  max-width: 440px;
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.55;
  color: var(--ink-3);
  text-align: center;
}

/* ── Placeholder block (clearly-marked gap to fill later) ──────── */
.case-section--placeholder .case-section__heading { color: var(--ink-3); }
.case-placeholder {
  border: 1.5px dashed color-mix(in oklab, var(--ink-1) 22%, transparent);
  border-radius: 0;
  padding: 22px 24px;
  background: color-mix(in oklab, var(--ink-1) 3%, transparent);
}
.case-placeholder--inline { margin-top: 22px; }
.case-placeholder__tag {
  display: inline-block;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent-blue);
  margin-bottom: 10px;
}
.case-placeholder__note {
  font-family: var(--font-body);
  font-size: 15px;
  line-height: 1.6;
  color: var(--ink-2);
  margin-bottom: 10px;
}
.case-placeholder__list { list-style: none; display: flex; flex-direction: column; gap: 7px; }
.case-placeholder__list li {
  position: relative;
  padding-left: 18px;
  font-family: var(--font-body);
  font-size: 14.5px;
  line-height: 1.55;
  color: var(--ink-3);
}
.case-placeholder__list li::before { content: "—"; position: absolute; left: 0; color: var(--ink-4); }

/* ── Stat / callout card (float-right within section body) ─────── */
.case-stat {
  float: right;
  clear: right;
  width: 190px;
  margin: 2px 0 20px 28px;
  padding: 18px;
  background: transparent;
  border: 1px solid color-mix(in oklab, var(--ink-1) 9%, transparent);
  border-radius: 0;
}
.case-stat__icon {
  display: block;
  font-family: var(--font-primary);
  font-size: 32px;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--accent-blue);
  margin-bottom: 10px;
  line-height: 1;
}

/* ── Stat digit reel (Sept 2026) ─────────────────────────────────────
   The stat card's number as an odometer: each digit spins up through a
   full loop before landing, cascading left to right (initReel in
   js/case.js). Markup: buildReel/statIconHtml — armed only when motion
   is allowed and IntersectionObserver exists; otherwise the number is
   the plain text it always was, built that way from the start rather
   than downgraded after the fact. 1400ms var(--ease-out-expo), the
   same token and duration the Circles share bar already grows in on
   (css/styles.css .cs-bars.is-armed.is-in), so a scroll-triggered
   number reads the same way everywhere on the site. */
.cs-reel {
  display: inline-flex;
  align-items: center;
  height: 32px;
  font-variant-numeric: tabular-nums;
}
.cs-reel-col {
  position: relative;
  height: 32px;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);
}
.cs-reel-strip { display: flex; flex-direction: column; will-change: transform; }
.cs-reel-digit { height: 32px; display: flex; align-items: center; justify-content: center; }
.cs-reel-col.is-spinning .cs-reel-strip { animation: cs-reel-blur 1400ms var(--ease-out-expo) both; }
@keyframes cs-reel-blur {
  0%   { filter: blur(3px); }
  55%  { filter: blur(0); }
  100% { filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
  .cs-reel-col.is-spinning .cs-reel-strip { animation: none; }
}

.case-stat__text {
  font-family: var(--font-body);
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--ink-2);
}

/* ── Prev / next pager ───────────────────────────────────────── */
.case-pager {
  grid-column: 1 / -1;
  max-width: 880px;
  width: 100%;
  margin: clamp(36px, 6vw, 64px) auto 0;
  display: flex;
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
}
.case-pager__link {
  display: flex;
  flex-direction: column;
  gap: 5px;
  flex: 1;
  padding: 20px clamp(16px, 4vw, 48px);
  transition: background 160ms var(--ease-out-quart);
}
.case-pager__link--prev {
  border-right: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
}
.case-pager__link--next {
  align-items: flex-end;
}
@media (hover: hover) and (pointer: fine) {
  .case-pager__link:hover {
    background: color-mix(in oklab, var(--ink-1) 3%, var(--surface-base));
  }
}
.case-pager__dir {   /* recipe from the grouped .eyebrow rule */
}
.case-pager__name {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-primary);
  font-size: 15px;
  font-weight: 600;
  color: var(--ink-1);
  transition: gap 200ms var(--ease-out-quart);
}
.case-pager__link:hover .case-pager__name { gap: 12px; }

/* ── Footer ──────────────────────────────────────────────────── */
.case-foot {
  grid-column: 1 / -1;
  max-width: 880px;
  width: 100%;
  margin: 0 auto;
  padding: 20px clamp(16px, 4vw, 48px) clamp(44px, 8vw, 84px);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  flex-wrap: wrap;
}
.case-foot__cta {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-4);
  filter: none;
  margin-bottom: 0;
}
.case-foot__links {
  display: flex;
  flex-wrap: wrap;
  gap: 0 24px;
}
.case-foot__links a {
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 500;
  color: var(--ink-2);
  border: none;
  padding-bottom: 0;
  transition: color 240ms var(--ease-out-quart);
}
.case-foot__links a:hover { color: var(--accent-blue); }

/* ── Not found ───────────────────────────────────────────────── */
.case-404 { text-align: center; padding: 14vh 0; }
.case-404 h1 { font-family: var(--font-primary); font-size: 32px; font-weight: 600; color: var(--ink-1); margin-bottom: 10px; }
.case-404 p { font-family: var(--font-body); color: var(--ink-3); margin-bottom: 20px; }
.case-404__back { font-family: var(--font-primary); font-weight: 600; color: var(--accent-blue); }

/* ── Case page mobile ────────────────────────────────────────── */
@media (max-width: 767px) {
  .case {
    grid-template-columns: 1fr;
    grid-template-areas: "hero" "body";
  }
  /* The close X lives in the TOC rail, and the rail is hidden at this
     width, so a mobile case had no way out but the footer link. Reuse
     that same single element rather than duplicating it: strip the rail
     down to just its close chip and pin it to the corner. Solid fill +
     hairline because the rail's translucent 7%-ink chip all but vanished
     against the near-white page. Same z-layer as the highlighter chrome
     and well below the lightbox (9999), so an open lightbox still covers
     it. Left, not right: .top-actions (the sound chip) already owns the
     top-right corner, and the hero's own live-link chip sits in that
     same quadrant, so a close X there stacked three controls into one
     corner. Top-left is otherwise empty on a mobile case page. */
  .case-toc {
    display: block;
    position: fixed;
    top: 10px;
    left: 10px;
    z-index: 400;
  }
  .case-toc__list,
  .case-toc__next { display: none; }
  /* The same chip as desktop (Oct 2026, on request): 28px, grey, no
     border or shadow. The fill is solid --surface-tint rather than the
     7% ink wash, so the chip stays the same grey when it floats over a
     case image. A transparent ::after keeps the 44px touch target. */
  .case-close {
    position: relative;
    margin-bottom: 0;
    background: var(--surface-tint);
    border: none;
    box-shadow: none;
  }
  .case-close::after { content: ''; position: absolute; inset: -8px; }
  .case-stat { float: none; width: 100%; margin: 0 0 16px 0; }
  /* Two equal columns rather than four — at phone widths a 4-up grid
     leaves Team's longer sentence wrapping into a narrow sliver. */
  .case-meta { grid-template-columns: repeat(2, 1fr); gap: 14px 20px; }
  .case-pager { flex-direction: column; }
  .case-pager__link--prev { border-right: none; border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent); }
  .case-pager__link--next { align-items: flex-start; }
}

@media (max-width: 480px) {
  /* One column on the smallest phones — two still pinches Team's
     sentence into a narrow wrap. */
  .case-meta { grid-template-columns: 1fr; }
}

/* ══════════════════════════════════════════════════════════════
   PAGE CLOUDS — a few clouds from the street scene, floated into
   the hero sky. Soft, behind content, gently adrift. Desktop only.
   ══════════════════════════════════════════════════════════════ */
.page-cloud {
  position: absolute;
  /* Under everything in the page, always: a picture, a control or a
     component it drifts across stays on top of it. -1 sits under every
     sibling, positioned or not (0 painted over any that weren't
     positioned, like the small handheld in the top line). */
  z-index: -1;
  opacity: 0.45;           /* faint: a drift may cross a text row, so keep it from ever denting contrast */
  height: auto;
  pointer-events: none;
  user-select: none;
  -webkit-user-drag: none;
  display: none;           /* shown from 1024px up */
}
/* Half natural size — matches the scale the footer renders its clouds at.
   Scattered down the page: a hero cluster, a few mid-scroll, a couple low,
   handing the sky over to the street scene below. Grey ones read as
   farther; tiny ones as farther still. */
/* Hero trio sits in the open sky up top. The rest are seated in the
   outer page margins (negative offsets clear the 120px content padding)
   and the open band below the projects, so they read as sky and only
   ever drift *behind* the cards, never awkwardly across them. */
/* CLEARANCE RULE: the drifts are horizontal, so a cloud's row is its
   row forever. The hero headline band starts ≈200px down — hero-sky
   clouds cap at top:130px (bottom edge ≤154px) so no drift path ever
   crowds the text. The street's own cloud shapes (images/clouds/). */
.page-cloud--1 { left: 7%;  top: 92px;  width: 32px; }   /* upper-left sky */
.page-cloud--2 { left: 22%; top: 54px;  width: 52px; }   /* distant grey stratus, high */
.page-cloud--3 { right: 12%; top: 106px; width: 48px; }  /* upper-right sky */
.page-cloud--4 { right: -66px; top: 26%; width: 28px; }  /* far grey speck, right margin */
.page-cloud--5 { left: -50px; top: 43%; width: 38px; }   /* left margin, mid */
.page-cloud--6 { right: -56px; top: 60%; width: 44px; }  /* right margin, lower-mid */
.page-cloud--7 { left: -44px; top: 77%; width: 22px; }   /* tiny grey, left margin low */
.page-cloud--8 { left: 40%; bottom: 20px; width: 46px; } /* open band below projects */

@media (min-width: 1024px) {
  .page-cloud { display: block; }
}
/* Same behaviour as the street sky: a slow full traversal that fades
   at both ends. The grey ones drift slowest — they read as farthest. */
@media (prefers-reduced-motion: no-preference) {
  .page-cloud--1 { animation: page-drift-1 200s linear -50s  infinite; }
  .page-cloud--2 { animation: page-drift-2 300s linear -170s infinite; }
  .page-cloud--3 { animation: page-drift-3 230s linear -90s  infinite; }
  .page-cloud--4 { animation: page-drift-2 340s linear -120s infinite; }
  .page-cloud--5 { animation: page-drift-3 260s linear -40s  infinite; }
  .page-cloud--6 { animation: page-drift-1 280s linear -200s infinite; }
  .page-cloud--7 { animation: page-drift-2 360s linear -30s  infinite; }
  .page-cloud--8 { animation: page-drift-3 240s linear -150s infinite; }
  /* JS scroll clouds (js/night.js) — same drifts, per-cloud clocks */
  .page-cloud--d1 { animation: page-drift-1 var(--cd, 260s) linear var(--cdel, 0s) infinite; }
  .page-cloud--d2 { animation: page-drift-2 var(--cd, 300s) linear var(--cdel, 0s) infinite; }
  .page-cloud--d3 { animation: page-drift-3 var(--cd, 240s) linear var(--cdel, 0s) infinite; }
}

/* Body-spanning holder for the JS scroll clouds — behind content, like
   the star field; individual clouds carry .page-cloud (day/desktop gates). */
#page-clouds {
  position: absolute;
  inset: 0;
  z-index: -1;             /* under all of the page (see .page-cloud) */
  pointer-events: none;
}
/* The body holds its own layers, so the clouds' -1 lands above its sky
   gradient and under everything else in it, rather than under the
   gradient too. */
body:has(> #page-clouds) { isolation: isolate; }
@keyframes page-drift-1 {
  0%   { transform: translateX(-160px); opacity: 0; }
  7%   { opacity: 0.45; }
  93%  { opacity: 0.45; }
  100% { transform: translateX(44vw); opacity: 0; }
}
@keyframes page-drift-2 {
  0%   { transform: translateX(-220px); opacity: 0; }
  7%   { opacity: 0.45; }
  93%  { opacity: 0.45; }
  100% { transform: translateX(38vw); opacity: 0; }
}
@keyframes page-drift-3 {
  0%   { transform: translateX(-180px); opacity: 0; }
  7%   { opacity: 0.45; }
  93%  { opacity: 0.45; }
  100% { transform: translateX(26vw); opacity: 0; }
}

/* ══════════════════════════════════════════════════════════════
   STREET FOOTER — a pixel street drawn in code (js/street.js) and
   brought to life by js/footer.js. One SVG, one pixel size, five
   tones, every one a token (--street-*), so light, dark and e-ink are
   token swaps rather than a filter over a bitmap. Layers, back to
   front: sky, far, glass, lit, near, fx, wires, life (js/street.js).
   ══════════════════════════════════════════════════════════════ */
.street-footer {
  position: relative;
  /* Always the footer's own paper: never white, never scroll-driven.
     The page's depth gradient (body, top of this file) lands on it. */
  background: var(--street-paper);
  margin-top: clamp(72px, 12vh, 160px);
  overflow: hidden;
}
.street-footer__stage { position: relative; }
.street-footer__scene {
  display: block;
  width: 100%;
  height: auto;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}

/* The five tones. Everything in the scene is one of these. */
.st-paper { fill: var(--street-paper); }
.st-shade { fill: var(--street-shade); }
.st-light { fill: var(--street-light); }
.st-soft  { fill: var(--street-soft); }
.st-ink   { fill: var(--street-ink); }

/* ── Clouds — the whole street's width, on one wind ───────────────
   Each cloud crosses from off one edge to off the other, so none ever
   fades into being mid-air. Far, mid and near clouds move at their
   own speeds (js/footer.js CLOUD_SET), which is the sky's parallax.
   Reduced motion: each holds its place along the run (--at). */
.st-cloud { transform: translateX(var(--at, 0px)); }
@media (prefers-reduced-motion: no-preference) {
  /* no will-change: a promoted layer keeps its first raster and goes
     soft when the footer is resized; the drift is slow enough not to need it */
  .st-cloud { animation: st-drift var(--dur, 600s) linear var(--delay, 0s) infinite; }
}
@keyframes st-drift {
  from { transform: translateX(var(--from, 0px)); }
  to   { transform: translateX(var(--to, 0px)); }
}

/* ── Wires and the string lights' cord ───────────────────────────
   Smooth curves (js/footer.js wirePath), one pixel wide, in ink; the
   cord is dotted, as the old street drew it. */
.st-wire,
.st-cord {
  fill: none;
  stroke: var(--street-ink);
  stroke-width: 1;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.st-cord { stroke-dasharray: 1.4 1.1; }

/* ── Bread crumbs on the pavement (js/footer.js dropCrumbs) ────── */
.st-crumb { fill: var(--street-ink); }
.st-drop { fill: var(--street-soft); }
/* The fountain's running water (js/footer.js streams). Linear: constant-rate ambient motion. */
.st-stream {
  stroke: var(--street-soft);
  stroke-width: 1;
  stroke-dasharray: 2 1;
  animation: st-flow 0.5s linear infinite;
}
@keyframes st-flow { to { stroke-dashoffset: -3; } }
@media (prefers-reduced-motion: reduce) { .st-stream { animation: none; } }
.st-crumbs { transition: opacity 1.2s var(--ease-out-soft); }
.st-crumbs.is-stale { opacity: 0; }

/* ── The library's doors: the light from inside, after dark ─────── */
.st-door-light { fill: var(--street-glow); opacity: 0.32; }
.st-doors:not(.is-open) .st-door-spill { display: none; }

/* ── The 0613's windows light up after dark ─────────────────────── */
html[data-theme="dark"] .st-bus .st-shade { fill: var(--street-glow); opacity: 0.55; }

/* ── Smoke and steam — the chimney, the coffee, the manhole ──────
   One recipe, three sources (js/footer.js plume): pixel puffs rise
   --rise and lean downwind --sx; steps() keeps them on the grid. */
.st-puff { fill: var(--street-soft); opacity: 0; }
.st-plume--coffee .st-puff { fill: var(--street-soft); }
@media (prefers-reduced-motion: no-preference) {
  .st-puff { animation: st-puff var(--dur, 6s) steps(14) var(--d, 0s) infinite; }
  /* a press: the source works hard for a moment */
  .st-plume.is-burst .st-puff { animation-duration: calc(var(--dur, 6s) * 0.3); }
}
.st-plume.is-off { display: none; }
@keyframes st-puff {
  0%   { transform: translate(0, 0); opacity: 0; }
  10%  { opacity: 0.9; }
  55%  { transform: translate(calc(var(--sx, 6px) * 0.5), calc(var(--rise, -20px) * 0.5)); opacity: 0.55; }
  100% { transform: translate(var(--sx, 6px), var(--rise, -20px)); opacity: 0; }
}

/* ── The shop bell, the book return, falling leaves ──────────────── */
@media (prefers-reduced-motion: no-preference) {
  .st-bell.is-ringing { animation: st-bell 560ms steps(1, end); }
  .st-book { animation: st-book 900ms var(--ease-out-quart) both; }
  .st-leaf { animation: st-leaf 4.6s var(--ease-out-soft) var(--dl, 0s) both; }
  /* the bakery sign swings on its two hooks, out and back, each swing
     smaller than the last. Seen face on, a board tipping toward or
     away from us only gets shorter, so it squashes from its top edge,
     which stays on the hooks. */
  .st-clip { transform-box: fill-box; transform-origin: 50% 0; }
  .st-clip.is-swinging { animation: st-clip-swing 1600ms steps(14, end); }
  /* spring petals, blown down the street on the one wind */
  .st-petal { animation: st-petal var(--pd, 8s) linear var(--pdl, 0s) both; }
}
@media (prefers-reduced-motion: reduce) {
  .st-leaf-fall, .st-petal-row { display: none; }
}
@keyframes st-clip-swing {
  0%   { transform: scaleY(1); }
  16%  { transform: scaleY(0.4); }
  32%  { transform: scaleY(1); }
  46%  { transform: scaleY(0.62); }
  60%  { transform: scaleY(1); }
  72%  { transform: scaleY(0.84); }
  84%  { transform: scaleY(1); }
  92%  { transform: scaleY(0.95); }
  100% { transform: scaleY(1); }
}
@keyframes st-petal {
  0%   { transform: translate(var(--from), 0); opacity: 0; }
  6%   { opacity: 1; }
  25%  { transform: translate(calc(var(--from) + (var(--to) - var(--from)) * 0.25), 6px); }
  50%  { transform: translate(calc(var(--from) + (var(--to) - var(--from)) * 0.5), -4px); }
  75%  { transform: translate(calc(var(--from) + (var(--to) - var(--from)) * 0.75), 8px); }
  94%  { opacity: 1; }
  100% { transform: translate(var(--to), 2px); opacity: 0; }
}
@keyframes st-bell {
  0%  { transform: translateX(-1px); }
  25% { transform: translateX(1px); }
  50% { transform: translateX(-1px); }
  75% { transform: translateX(1px); }
}
@keyframes st-book {
  0%   { transform: translate(-1px, -16px); opacity: 0; }
  18%  { opacity: 1; }
  55%  { transform: translate(0, -5px); }
  100% { transform: translate(0, 7px); }
}
/* down and downwind, swaying as it goes, then a beat on the ground */
@keyframes st-leaf {
  0%   { transform: translate(0, 0); opacity: 0; }
  6%   { opacity: 1; }
  25%  { transform: translate(calc(var(--drift) * 0.2 + 3px), calc(var(--fall) * 0.25)); }
  50%  { transform: translate(calc(var(--drift) * 0.5 - 3px), calc(var(--fall) * 0.55)); }
  75%  { transform: translate(calc(var(--drift) * 0.8 + 2px), calc(var(--fall) * 0.85)); }
  86%  { transform: translate(var(--drift), var(--fall)); opacity: 1; }
  100% { transform: translate(var(--drift), var(--fall)); opacity: 0; }
}

/* ── A crow's caw, a sleeper's Z — rise a little and fade ─────────── */
.st-say, .st-zzz { opacity: 0; }
@media (prefers-reduced-motion: no-preference) {
  .st-say { animation: st-rise 1.6s var(--ease-out-quart) forwards; }
  .st-zzz { animation: st-rise 2s var(--ease-out-quart) forwards; }
}
@keyframes st-rise {
  0%   { transform: translateY(2px); opacity: 0; }
  18%  { opacity: 1; }
  100% { transform: translateY(-8px); opacity: 0; }
}

@media (max-width: 719px) {
  /* Phones: scale by height and let the ends crop, centred on the
     scene's x 520 (the park's left half, not the middle): the
     library's doors on one side and the bakery's window on the other
     both stay in a 375px screen. 43px = (576 - 520) x 240 / 312. */
  .street-footer { display: flex; flex-direction: column; align-items: center; }
  .street-footer__stage { width: max-content; align-self: center; transform: translateX(43px); }
  .street-footer__scene { width: auto; height: 240px; max-width: none; }
}

/* ══════════════════════════════════════════════════════════════
   NIGHT MODE — the whole site after dark.
   Toggled by [data-theme="dark"] on <html> (js/night.js persists it;
   the streetlamp in the footer is the in-world switch). Monochrome
   flips to ink-on-near-black; the street art inverts to light line
   work; clouds hand over to stars, a moon and shooting stars; the
   streetlamp and a few windows glow warm — the one colour night earns.
   ══════════════════════════════════════════════════════════════ */
html[data-theme="dark"] {
  color-scheme: dark;

  /* Surfaces — near-black, stepping lighter for depth. Steps widened
     July 2026 (deep 18->1c, tint 20->26): at 3% apart the chips and
     recessed fills were vanishing into the page. */
  --surface-base: #101010;
  --surface-deep: #1c1c1c;
  --surface-tint: #262626;
  --surface-frame: oklch(30% 0 0);
  --surface-card:  oklch(24% 0 0);

  /* Ink — light hierarchy on dark. Secondary tiers lifted July 2026
     (a8->b0, 8c->94) for the same legibility pass. */
  --ink-1: #f0f0f0;
  --ink-2: #e3e3e3;
  --ink-3: #b0b0b0;
  /* 10px labels lean on this everywhere — #949494 sits ~6.3:1 on
     #101010, comfortably over AA, while staying a clear step below
     --ink-3. */
  --ink-4: #949494;
  --ink-inv: #101010;
  --selection-wash: color-mix(in oklab, var(--ink-1) 25%, transparent);

  /* Accent on black — lift toward a bright azure, NOT a pale periwinkle.
     The old #8a8aff / #a6a6ff read as lavender (R = G, high lightness).
     Keeping red below green holds it firmly blue. */
  --accent-blue: #4d8bff;
  --accent-blue-soft: #84b0ff;
  /* Paper objects stay paper at night, just less lit: the ink on them
     stays dark and the pen stays the day accent (DESIGN.md). */
  --paper-card:       #dcd9d1;
  --paper-pocket:     #c9bfab;
  --paper-pocket-in:  #b1a58e;
  --paper-light:      #f3eee4;
  --plastic-face:     #d6d2c8;
  --plastic-edge:     #b6ae9e;
  --clip-face:        #7199b5;
  --clip-edge:        #567c98;
  --clip-ink:         #163a50;
  --cooler-water:     #a8b5b9;
  --cooler-jug:       #74898f;
  --cooler-cold:      #6d8aa5;
  --sticker-back:     #ccc7bc;
  --curtain-cloth:    #d9d5cc;
  --curtain-fold:     #918a7e;
  --curtain-lace:     #d3cdc0;
  --curtain-print:    #a8a297;
  /* Highlighter inks lift with the accent: brighter, softer versions
     that stay legible as translucent washes under light text. */
  --hl-sun:  #ffdb4d;
  --hl-rose: #ff7ab0;
  --hl-mint: #3ecf7d;
  --ink-stamp: #ffffff;
  /* The street after dark: light line work on the night paper. Glass
     goes darker than the walls (an unlit room), light stays a whisper
     above the paper so clouds and stripes still read. */
  --street-shade:    #121212;
  --street-light:    #262626;
  --street-soft:     #4b4b4b;
  --street-ink:      #d9d9d9;
  --street-glow:     #f2f2f2;
  --cloud-lit:   #3b4556;
  --cloud-shade: #222a36;

  /* Glass */
  --glass-fill:      color-mix(in oklab, black 32%, transparent);
  --glass-fill-hover: color-mix(in oklab, black 32%, transparent);
  --glass-stroke:    color-mix(in oklab, black 80%, transparent);
  --glass-stroke-2:  color-mix(in oklab, var(--ink-1) 14%, transparent);
  --glass-highlight: color-mix(in oklab, black 90%, transparent);

}

/* ════════════════════════════════════════════════════════════════
   E-INK — the third display preference (light → dark → e-ink cycle,
   js/night.js). The SITE prints in ink on a cool panel grey; the WORK
   stays in colour — project imagery, case screenshots, and the pixel
   art are untouched, because a portfolio's design work has to stay
   legible as design work. Everything here is tokens plus a short
   flatness pass: no grayscale filter anywhere. The footer's scroll
   fade endpoints (js/footer.js) MUST match --surface-base /
   --surface-deep below — same seam contract as dark. */
html[data-theme="eink"] {
  /* Panel greys, pulled down from the original set. A real e-ink panel
     is never bright: its "white" is a dingy diffuse grey because the
     white pigment sits under a plastic layer, so a clean near-white
     read as an LCD imitating paper. Ink is lifted off near-black for
     the same reason — Carta panels top out around 15:1, not 21:1.

     EVERY VALUE BELOW SITS ON THE PANEL'S OWN 16-LEVEL RAMP (0, 17, 34,
     … 255). A Carta panel renders 4-bit greyscale, so a value between
     levels is one the hardware could not actually produce — it would be
     dithered there. Snapping them costs nothing visually and means the
     whole theme is expressible on the device it imitates. Keep any new
     e-ink grey on the ramp: multiples of 17.

     The snap is not blind. Two constraints override "nearest level":
     surfaces must stay ORDERED (base lighter than card/deep, which stay
     lighter than tint) or the chrome stops separating from the page —
     e-ink has no shadows, so surface value is the only separator it
     has; and no ink may lose contrast against the page. Nearest-level
     rounding collapsed deep and tint onto one value, so they are spread
     to distinct steps instead, which also buys the dock a FULL panel
     step of separation (221 vs 204) rather than the sub-step it had.
     Contrast is unchanged or better throughout: ink-1 11.7:1, ink-3
     5.5:1, and ink-4 improves from 3.55:1 to 4.23:1. */
  --surface-base: #dddddd;   /* 221 — panel grey, the diffuse "white" */
  --surface-deep: #cccccc;   /* 204 */
  --surface-tint: #bbbbbb;   /* 187 */
  --surface-card: #cccccc;   /* 204 — shares deep's level; they never abut */

  --ink-1: #222222;   /* 34  */
  --ink-2: #333333;   /* 51  */
  --ink-3: #555555;   /* 85  */
  --ink-4: #666666;   /* 102 */
  --ink-inv: #eeeeee; /* 238 */

  /* The accent prints in ink — e-ink has no blue. Same for the
     highlighter palette: four distinguishable grey levels. The accent
     pair lands on the same levels as ink-2 / ink-3, which is honest:
     they were already within one RGB unit of each other before the
     snap, so nothing that was distinguishable stopped being so. */
  --accent-blue: #333333;      /* 51  */
  --accent-blue-soft: #555555; /* 85  */
  /* Paper objects on the ramp: stock by value, every ink in grey. */
  --paper-card:       #eeeeee; /* 238 */
  --paper-pocket:     #cccccc; /* 204 */
  --paper-pocket-in:  #aaaaaa; /* 170 */
  --paper-ink:        #222222; /* 34  */
  --paper-graphite:   #666666; /* 102 */
  --paper-blue:       #333333; /* 51  */
  --paper-rule:       #999999; /* 153 */
  --paper-light:      #eeeeee; /* 238 */
  --plastic-face:     #eeeeee; /* 238 */
  --plastic-edge:     #aaaaaa; /* 170 */
  --clip-face:        #bbbbbb; /* 187 */
  --clip-edge:        #888888; /* 136 */
  --clip-ink:         #222222; /* 34 */
  --cooler-water:     #dddddd; /* 221 */
  --cooler-jug:       #aaaaaa; /* 170 */
  --cooler-cold:      #444444; /* 68 */
  --sticker-face:     #ffffff; /* 255: on a 221 page the label has to be the lightest thing there */
  --sticker-back:     #eeeeee; /* 238: the flap, a step under the face, still clear of the page */
  --curtain-cloth:    #eeeeee; /* 238 */
  --curtain-fold:     #bbbbbb; /* 187 */
  --curtain-lace:     #ffffff; /* 255: lace over a 221 page has to read lighter than it */
  --curtain-print:    #aaaaaa; /* 170 */
  --hl-sun:  #aaaaaa;  /* 170 */
  --hl-rose: #888888;  /* 136 */
  --hl-mint: #777777;  /* 119 */
  /* The street on the ramp: the day scene, each tone snapped. */
  --street-shade:     #bbbbbb; /* 187 */
  --street-light:     #eeeeee; /* 238 */
  --street-soft:      #888888; /* 136 */
  --street-ink:       #222222; /* 34  */
  --street-glow:      #eeeeee; /* 238 */
  --cloud-lit:   #eeeeee; /* 238 */
  --cloud-shade: #aaaaaa; /* 170 */

  /* MOTION IS DELIBERATELY UNTOUCHED HERE. Redefining the easing tokens
     as steps() was tried (July 2026) so transitions would repaint in
     increments the way a panel does, and it was reverted on request: the
     theme is a display PREFERENCE, not a device simulation, and
     quantising every transition on the site made it the latter. Do not
     reintroduce it. The four easings stay exactly as :root defines them,
     so motion here is identical to light and dark. */
}

/* The flatness pass: e-ink has no depth. Floating chrome separates by
   surface value alone (the bar is --surface-deep on paper — a full
   panel step darker, and visibly so without a shadow). Motion is
   untouched: this is a display preference, not a device simulation. */
html[data-theme="eink"] .tbar,
html[data-theme="eink"] .hl-chip,
html[data-theme="eink"] .tbar .msg-widget__body,
html[data-theme="eink"] .card,
html[data-theme="eink"] .card__soon-label,
html[data-theme="eink"] .chat-msg--user .chat-msg__text {
  box-shadow: none;
}
/* (The active theme segment's own no-shadow-in-e-ink lives with its
   ::before pad above.) */
/* .about-btn is one of the raw-hex stragglers (known cleanups): its
   light #fff would glare on the grey panel. */
html[data-theme="eink"] .about-btn {
  background: var(--ink-inv);
  color: var(--ink-2);
}
/* The panel grain — the micro-capsule texture an e-ink display has.
   One fixed multiply layer over EVERYTHING (a real panel textures the
   imagery too); SVG turbulence noise, quiet enough to read as material
   rather than dirt. This is the piece that makes the theme read as a
   DEVICE instead of merely a grey palette.

   Tightened: baseFrequency 0.9 → 1.4 so the speckle sits at roughly
   micro-capsule scale instead of reading as photographic film grain,
   and the tile drops 180 → 120px so the repeat is harder to catch. */
html[data-theme="eink"] body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 3000;
  pointer-events: none;
  opacity: 0.34;
  mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.4' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0.9 0 0 0 0 0.9 0 0 0 0 0.9 0 0 0 0.3 0.7'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* The bezel — the piece that turns "a grey page" into "a screen you
   are looking at". A real panel sits recessed under glass, so its
   edges catch a soft gradient of shade the middle never does. A wide,
   very low-opacity inset vignette reads as that recess; sharp enough
   at the corners to register, weak enough that no one can point at it.
   Separate element from the grain because ::after is spoken for and
   the two need different blend behaviour (shade multiplies, grain
   speckles). Sits just under the grain in z-order. */
html[data-theme="eink"] body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 2999;
  pointer-events: none;
  background:
    radial-gradient(120% 100% at 50% 50%,
      rgba(0, 0, 0, 0) 58%,
      rgba(0, 0, 0, 0.055) 88%,
      rgba(0, 0, 0, 0.1) 100%);
}
/* E-ink cannot render glass. Any backdrop blur left on floating chrome
   is the single most LCD-looking thing on the page, so it goes flat:
   the bar already separates by surface value alone. */
html[data-theme="eink"] .tbar,
html[data-theme="eink"] .tbar .msg-widget__body,
html[data-theme="eink"] .hl-chip {
  -webkit-backdrop-filter: none;
          backdrop-filter: none;
}
/* Imagery: colour e-ink (Kaleido and friends) exists, and it is
   famously washed out — a colour filter array over a greyscale panel
   costs most of the saturation. That muted-but-not-dead look is what
   sells the illusion, and it is also the honest way to keep the
   original decision here (the WORK must stay legible as design work):
   pointing at any image restores it to full colour, so nothing is ever
   locked behind the theme. Hover-only, so touch users see the muted
   default and lose nothing they cannot get by opening the lightbox. */
html[data-theme="eink"] .card__thumb,
html[data-theme="eink"] .cs-landing-img,
html[data-theme="eink"] .cs-specimen__img,
html[data-theme="eink"] .cs-canvas__img,
html[data-theme="eink"] .cs-wipe__img,
html[data-theme="eink"] .case-hero__img {
  filter: saturate(0.22) contrast(1.06);
  transition: filter 420ms var(--ease-out-quart);
}
@media (hover: hover) and (pointer: fine) {
  html[data-theme="eink"] .card:hover .card__thumb,
  html[data-theme="eink"] .cs-landing-img:hover,
  html[data-theme="eink"] .cs-specimen__img:hover,
  html[data-theme="eink"] .cs-canvas__img:hover,
  html[data-theme="eink"] .cs-wipe__img:hover,
  html[data-theme="eink"] .case-hero__img:hover {
    filter: saturate(1) contrast(1);
  }
}
@media (prefers-reduced-motion: reduce) {
  html[data-theme="eink"] .card__thumb,
  html[data-theme="eink"] .cs-landing-img,
  html[data-theme="eink"] .cs-specimen__img,
  html[data-theme="eink"] .cs-canvas__img,
  html[data-theme="eink"] .cs-wipe__img,
  html[data-theme="eink"] .case-hero__img {
    transition: none;
  }
}
/* The hero manifesto drops the ink-morph in e-ink and just fades.
   Two reasons, and the first is a real bug: hero-ink-in paints
   --surface-base behind the glyphs so contrast() has solid paper to
   threshold against, but contrast() pivots on mid-grey, so ANY paper
   above it clamps to pure white for the whole animation. Light's paper
   is #ffffff already (blows out to itself, invisible) and dark's
   #101010 clamps to black (near-invisible), but e-ink's mid-grey panel
   blew out to a glowing white plate on every load. Second: an e-ink
   panel cannot do a gooey blur morph anyway — it repaints in discrete
   steps — so a plain fade is the more honest motion here. Specificity
   (0,2,1) beats both the no-preference and reduced-motion rules that
   set this animation, and fade-in is exactly what reduced-motion
   already falls back to, so this needs no media query of its own. */
html[data-theme="eink"] .rail__currently {
  animation: fade-in 700ms var(--ease-out-quart) both;
}

/* The pen-mode marker cursor dips in ink like everything else (the
   per-ink cursors carry baked colour hexes; ties on specificity, so
   source order after them decides). */
html[data-theme="eink"] body.hl-pen-mode .case,
html[data-theme="eink"] body.hl-pen-mode .case * {
  cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cg transform='rotate(45 12 12)'%3E%3Cpolygon points='12,21.5 9.8,16.5 14.2,16.5' fill='%23555555' stroke='%23111111' stroke-width='1.2' stroke-linejoin='round'/%3E%3Crect x='9.8' y='7' width='4.4' height='9.6' fill='%23ffffff' stroke='%23111111' stroke-width='1.2'/%3E%3Crect x='9.8' y='3.4' width='4.4' height='3.6' fill='%23111111' stroke='%23111111' stroke-width='1.2'/%3E%3C/g%3E%3C/svg%3E") 5 19, text;
}

/* ── Hardcoded light surfaces → night equivalents ─────────────── */
/* .overview-box needs no dark override — its base rule already reads
   --surface-deep, which IS #1c1c1c at night. Dropping the raw-hex
   duplicate keeps the plate and the letter (same token) from ever
   drifting apart in one theme only. */
html[data-theme="dark"] .overview-fade {
  background: linear-gradient(to bottom, rgba(28, 28, 28, 0), #1c1c1c);
}
html[data-theme="dark"] .about-btn {
  background: #1c1c1c;
  color: #e8e8e8;
}
html[data-theme="dark"] .case-hero__live { background: #1e1e1e; }
@media (hover: hover) and (pointer: fine) {
  html[data-theme="dark"] .case-hero__live:hover { background: #292929; }
}

/* Screenshots stay light by nature — dim them so they sit into the
   dark page like photos at night rather than glaring out of it. */
html[data-theme="dark"] .card__thumb,
html[data-theme="dark"] .contact-row__thumb {
  /* 0.82 was the single biggest "hard to see at night" offender —
     thumbnails ARE the cards. A whisper of dim keeps the glare off. */
  filter: brightness(0.92) contrast(1.02);
}

/* Case-study imagery gets the same treatment — hero, body frames and
   phone captures are mostly light UI screenshots, the brightest thing
   on the night page by far. (The lightbox stays full brightness: once
   zoomed, the reader asked to inspect it.) The popup's component stills
   and live demo stages are light pages too, so they dim the same way,
   and so do the scenes stage's recordings of one. */
html[data-theme="dark"] .cs-scenes__stage,
html[data-theme="dark"] .case-hero__img,
html[data-theme="dark"] .cs-landing-img,
html[data-theme="dark"] .cs-phone-img,
html[data-theme="dark"] .cs-canvas__img,
html[data-theme="dark"] .cs-wipe__img,
html[data-theme="dark"] .cs-hero-img,
html[data-theme="dark"] .cs-panel--split .cs-piece__still,
html[data-theme="dark"] .cs-panel--split .cs-piece__video {
  filter: brightness(0.92) contrast(1.02);
}

/* The paper-slip callout keeps its sticker character at night, but
   pure #fff glares on #101010 — knock it back to lamplit paper, and
   swap the ink-1-based shadows (light in this theme = halo) for real
   darkness underneath. */
html[data-theme="dark"] .cs-deep-text--callout {
  background: #e4e1da;
  box-shadow:
    0 1px 2px  rgba(0, 0, 0, 0.5),
    0 3px 8px -1px rgba(0, 0, 0, 0.35),
    inset 0 -1px 0 rgba(0, 0, 0, 0.12);
}
html[data-theme="dark"] .cs-deep-text--callout:hover {
  box-shadow:
    0 3px 4px  rgba(0, 0, 0, 0.5),
    0 7px 16px -3px rgba(0, 0, 0, 0.35),
    inset 0 -1px 0 rgba(0, 0, 0, 0.12);
}

/* ── Mini case-study popup, dark mode ───────────────────────────────
   The modal glass was authored as light-only (oklch 97-99% panels, white
   rim-lights, #fff buttons, a leftover espresso link-btn), so at night it
   floated as a glaring white slab with washed-out text. Reskin the whole
   surface family to the same dark milk-glass as the chat widget, in one
   place. Text/ink tokens already flip, so only the surfaces need it. */
html[data-theme="dark"] .cs-panel {
  background: oklch(21% 0.004 255 / 0.9);
  box-shadow:
    inset 0 1px 0 color-mix(in oklab, white 12%, transparent),
    0 40px 100px -28px rgba(0, 0, 0, 0.7),
    0 10px 28px -10px rgba(0, 0, 0, 0.5);
}
html[data-theme="dark"] .cs-panel .cs-sidebar {
  background: oklch(26% 0.004 255 / 0.6);
  box-shadow:
    inset 0 1px 0 color-mix(in oklab, white 10%, transparent),
    0 2px 10px -4px rgba(0, 0, 0, 0.4);
}
html[data-theme="dark"] .cs-panel .cs-view-btn.is-active {
  background: oklch(30% 0.004 255 / 0.8);
  box-shadow: 0 1px 4px -1px rgba(0, 0, 0, 0.5);
}
html[data-theme="dark"] .cs-panel .cs-principle-card {
  background: color-mix(in oklab, var(--ink-1) 6%, transparent);
  box-shadow:
    0 2px 6px -1px rgba(0, 0, 0, 0.4),
    0 1px 3px rgba(0, 0, 0, 0.28);
}
/* The live-site button is a stamped chip: invert it (light stamp, dark
   ink) so it reads as the one bright CTA on the dark panel, and drop the
   dead espresso text color. */
html[data-theme="dark"] .cs-panel .cs-link-btn {
  background: #1e1e1e;
  color: var(--ink-1);
  box-shadow: none;
}
@media (hover: hover) and (pointer: fine) {
  html[data-theme="dark"] .cs-panel .cs-link-btn:hover { background: #292929; }
}

/* The chat's paper-slip send button — same treatment: its ink-1-based
   shadows turn into a light halo at night, so swap in real shadow. At
   night the LCD inverts to light, so the white slip also dims a step. */
html[data-theme="dark"] .chat-send {
  background: #e4e1da;
  box-shadow:
    0 1px 2px  rgba(0, 0, 0, 0.4),
    0 3px 8px -1px rgba(0, 0, 0, 0.28),
    inset 0 -1px 0 rgba(0, 0, 0, 0.12);
}

/* ── The street after dark ──────────────────────────────────────
   The tones flip with the theme (--street-* in the dark block), so
   the art needs no filter. What is drawn over glass (bars, books,
   loaves, a railing) goes to silhouette against a lit window. */
html[data-theme="dark"] .st-art--bars .st-ink,
html[data-theme="dark"] .st-art--bars .st-paper { fill: var(--street-paper); }
/* After dark the near clouds go; the far ones pass over the stars. */
html[data-theme="dark"] .st-cloud--near,
html[data-theme="dark"] .st-cloud--mid { display: none; }
html[data-theme="dark"] .st-cloud--far { opacity: 0.75; }

/* ── Clouds ↔ stars swap ──────────────────────────────────────── */
html[data-theme="dark"] .page-cloud { display: none; }

/* Night-only elements hide in daylight */
.st-night   { display: none; }
#page-stars { display: none; }
html[data-theme="dark"] .st-night   { display: inline; }
html[data-theme="dark"] #page-stars { display: block; }

/* ── Stars — pixel dots that breathe ──────────────────────────── */
.st-star         { fill: var(--street-glow); }
.st-star--dim    { fill: var(--street-soft); }
.st-star--bright { fill: var(--street-glow); }
/* Constellation hairline — half a pixel, never louder than its stars */
.st-const {
  fill: none;
  stroke: var(--street-soft);
  stroke-width: 0.5;
}
/* The moon: its lit side shines, its dark side shows faintly (earthshine). */
.st-moon .st-light { fill: var(--street-glow); }
.st-moon .st-soft  { fill: var(--street-soft); opacity: 0.5; }
/* the skyline's windows, after dark */
.st-citylight { fill: var(--street-glow); opacity: 0.55; }
@media (prefers-reduced-motion: no-preference) {
  .st-citylight--blink { animation: star-twinkle var(--t, 3s) ease-in-out var(--d, 0s) infinite; }
}
.st-glow-stop { stop-color: var(--street-glow); }
#page-stars {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}
/* Case pages mount the field on <body> so it spans the whole scroll, not
   just a hero. Lift the reading content and the footer above it: the
   content sits over the (transparent) sky, and the footer's own opaque
   sky takes over the cluster where the page field ends. */
.case-page .case,
.case-page .street-footer {
  position: relative;
  z-index: 1;
}
.page-star {
  position: absolute;
  background: #e2e2e2;
  /* Per-star brightness cap (set inline on case pages so stars near the
     top stay whisper-faint and only reach full strength by the footer).
     Also the static value when motion is reduced. Defaults to full. */
  opacity: var(--star-peak, 1);
}
.page-star--plus {
  background: transparent;
  width: 7px !important;
  height: 7px !important;
  /* four-point sparkle drawn with two crossing bars */
  background:
    linear-gradient(#e8e8e8, #e8e8e8) 50% 0 / 1px 100% no-repeat,
    linear-gradient(#e8e8e8, #e8e8e8) 0 50% / 100% 1px no-repeat;
}
@media (prefers-reduced-motion: no-preference) {
  .st-star, .page-star {
    animation: star-twinkle var(--t, 3s) ease-in-out var(--d, 0s) infinite;
  }
}
@keyframes star-twinkle {
  0%, 100% { opacity: calc(var(--star-peak, 1) * 0.25); }
  50%      { opacity: var(--star-peak, 1); }
}

/* ── Shooting stars ───────────────────────────────────────────────
   Footer streaks are animated frame-by-frame in js/footer.js (a comet
   head with a trail that stretches then burns out); only their paint
   lives here. Hero streaks are CSS: a bright-headed bar that darts
   along its own axis and fades. */
.st-shoot line { stroke: var(--street-glow); stroke-width: 1; stroke-linecap: round; }
.st-shoot rect { fill: var(--street-glow); }

.page-shoot {
  position: absolute;
  width: 90px;
  height: 2px;
  background: linear-gradient(to right, #f5f5f5, rgba(245, 245, 245, 0));
  opacity: 0;
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  .page-shoot { animation: page-shoot 0.9s cubic-bezier(0.2, 0.6, 0.4, 1) both; }
}
/* rotate(-30deg) tips the bar's leading (left) end DOWNWARD, so the
   negative translateX along its own axis reads as falling down-left —
   the +30deg version sent streaks climbing uphill, which no meteor does. */
@keyframes page-shoot {
  0%   { transform: rotate(-30deg) translateX(0) scaleX(0.35); opacity: 0; }
  12%  { opacity: 0.95; }
  60%  { transform: rotate(-30deg) translateX(-190px) scaleX(1); opacity: 0.8; }
  100% { transform: rotate(-30deg) translateX(-290px) scaleX(0.5); opacity: 0; }
}

/* ── Night light — panes, lamps, the clock, the bakery ───────────
   Each pane js/street.js cut out has a light behind it (js/footer.js
   sets --lit per pane: who is home is decided once per visit). Light
   reads by brightness, never by colour: the site is monochrome. */
.st-pane {
  fill: var(--street-glow);
  opacity: var(--lit, 0);
  transition: opacity 240ms var(--ease-out-quart);
}
.st-pane.is-off { opacity: 0; }
.st-laptop .st-ink,
.st-walker .st-ink { fill: var(--street-paper); }
.st-laptop .st-light { fill: var(--street-glow); }
.st-walker { opacity: 0.85; }
/* the hands go dark against the lit face */
html[data-theme="dark"] .st-hands .st-ink { fill: var(--street-paper); }
.st-beam { opacity: 0.45; }
.st-spill { opacity: 0.6; }
@media (prefers-reduced-motion: no-preference) {
  .st-glow { animation: st-breathe 5s ease-in-out infinite; }
  .st-pane--tv:not(.is-off) { animation: st-tv 2.4s steps(1, end) infinite; }
  /* dusk: the lamps come on one after another, each with a warm-up flicker */
  .st-pane--lamp.is-waking,
  .st-lamp-glow.is-waking { animation: st-wake 1s steps(1, end) calc(var(--i, 0) * 280ms) both; }
}
@keyframes st-breathe {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.86; }
}
@keyframes st-wake {
  0%   { opacity: 0; }
  22%  { opacity: 0.7; }
  30%  { opacity: 0.08; }
  46%  { opacity: 0.9; }
  56%  { opacity: 0.35; }
  70%  { opacity: 1; }
  100% { opacity: 1; }
}
/* a television: hard, arrhythmic jumps between levels */
@keyframes st-tv {
  0%   { opacity: 0.26; }
  15%  { opacity: 0.14; }
  28%  { opacity: 0.3; }
  40%  { opacity: 0.18; }
  55%  { opacity: 0.34; }
  63%  { opacity: 0.12; }
  78%  { opacity: 0.28; }
  90%  { opacity: 0.2; }
  100% { opacity: 0.3; }
}

/* ── The string lights after dark: each bulb on its own temper ──── */
.st-bulb__core { fill: var(--street-glow); }
@media (prefers-reduced-motion: no-preference) {
  .st-bulb--soft  { animation: st-bulb-soft  var(--t, 3s) ease-in-out var(--d, 0s) infinite; }
  .st-bulb--fast  { animation: st-bulb-fast  var(--t, 1s) linear      var(--d, 0s) infinite; }
  .st-bulb--loose { animation: st-bulb-loose var(--t, 5s) linear      var(--d, 0s) infinite; }
}
/* a filament that never sits perfectly still */
@keyframes st-bulb-soft {
  0%, 100% { opacity: 1; }
  20%      { opacity: 0.82; }
  33%      { opacity: 0.96; }
  55%      { opacity: 0.7; }
  70%      { opacity: 0.9; }
  84%      { opacity: 0.78; }
}
/* a loose filament, nervous */
@keyframes st-bulb-fast {
  0%, 100% { opacity: 0.92; }
  12%      { opacity: 0.55; }
  18%      { opacity: 0.95; }
  27%      { opacity: 0.6; }
  41%      { opacity: 1; }
  52%      { opacity: 0.48; }
  63%      { opacity: 0.9; }
  77%      { opacity: 0.62; }
  89%      { opacity: 1; }
}
/* a bad connection: steady, then a stutter out and back */
@keyframes st-bulb-loose {
  0%, 44%  { opacity: 1; }
  46%      { opacity: 0.12; }
  48%      { opacity: 0.9; }
  49%      { opacity: 0.18; }
  52%      { opacity: 1; }
  100%     { opacity: 1; }
}

/* ── Catch-a-comet sparkle — pixel bits fly outward and fade ─────── */
.st-sparkle rect {
  fill: var(--street-glow);
  transform-box: fill-box;
  transform-origin: center;
}
.st-sparkle__flash { fill: var(--street-glow); }
@media (prefers-reduced-motion: no-preference) {
  .st-sparkle rect   { animation: st-sparkle-bit var(--t, 500ms) var(--ease-out-quart) forwards; }
  .st-sparkle__flash { animation: st-sparkle-flash 320ms var(--ease-out-quart) forwards; }
}
@keyframes st-sparkle-bit {
  0%   { transform: translate(0, 0) scale(1); opacity: 1; }
  100% { transform: translate(var(--dx, 0), var(--dy, 0)) scale(0.2); opacity: 0; }
}
@keyframes st-sparkle-flash {
  0%   { transform: scale(0.2); opacity: 0.9; }
  100% { transform: scale(2.4); opacity: 0; }
}

/* ── Fireflies — position rides a rAF transform (js/footer.js); the
   blink lives here, each on its own clock. ─────────────────────── */
.st-firefly__core { fill: var(--street-glow); }
.st-firefly__pulse { opacity: 0; }
@media (prefers-reduced-motion: no-preference) {
  .st-firefly__pulse {
    animation: st-firefly-blink var(--t, 2.4s) ease-in-out var(--d, 0s) infinite;
  }
}
/* a soft swell, a beat of full glow, then dark for most of the cycle */
@keyframes st-firefly-blink {
  0%, 34%   { opacity: 0; }
  46%       { opacity: 1; }
  58%       { opacity: 0.85; }
  70%, 100% { opacity: 0; }
}

/* == RESTORED DEPLOYED MODAL LAYOUT (scoped to .cs-panel) == */
.cs-overlay {
  position: fixed;
  inset: 0;
  z-index: 500;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(16px, 3vw, 40px);
  background: oklch(18% 0.002 90 / 0.28);
  backdrop-filter: blur(14px) saturate(1.2);
  -webkit-backdrop-filter: blur(14px) saturate(1.2);
  opacity: 0;
  pointer-events: none;
  transition: opacity 320ms var(--ease-out-soft);
  
  overscroll-behavior: none;
}
.cs-overlay.is-open {
  opacity: 1;
  pointer-events: auto;
}
.cs-panel {
  position: relative;
  width: 100%;
  max-width: 1080px;
  height: min(85vh, 760px);
  border-radius: 0;
  
  overflow: hidden; 
  overflow: clip;
  
  background: oklch(97% 0.002 90 / 0.88);
  backdrop-filter: blur(60px) saturate(1.5) brightness(1.06);
  -webkit-backdrop-filter: blur(60px) saturate(1.5) brightness(1.06);
  box-shadow:
    inset 0 1px 0 color-mix(in oklab, white 88%, transparent),
    0 40px 100px -28px color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 10px 28px -10px color-mix(in oklab, var(--ink-1) 14%, transparent);
  opacity: 0;
  transform: translateY(40px) scale(0.94);
  transition:
    transform 450ms var(--ease-out-expo),
    opacity 300ms var(--ease-out-quart);
}
.cs-overlay.is-open .cs-panel {
  opacity: 1;
  transform: translateY(0) scale(1);
}
.cs-panel .cs-shape {
  position: absolute;
  inset: 0;
  background: transparent;
  border-radius: 0;
  pointer-events: none;
  z-index: 0;
}
.cs-panel .cs-layout {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: row;
  height: 100%;
  padding: 10px;
  gap: 8px;
  box-sizing: border-box;
}
.cs-panel .cs-sidebar {
  width: 230px;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  padding: 20px 18px;
  border-radius: 0;
  background: oklch(99% 0.001 90 / 0.72);
  backdrop-filter: blur(56px) saturate(1.3);
  -webkit-backdrop-filter: blur(56px) saturate(1.3);
  box-shadow:
    inset 0 1px 0 color-mix(in oklab, white 80%, transparent),
    0 2px 10px -4px color-mix(in oklab, var(--ink-1) 10%, transparent);
  overflow: hidden;
}
.cs-panel .cs-close {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  background: none;
  border: none;
  font-family: var(--font-primary);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  padding: 8px 10px;
  margin: -8px -10px 8px;
  cursor: pointer;
  transition: color 160ms var(--ease-out-quart);
  flex-shrink: 0;
}
.cs-panel .cs-view-toggle {
  display: flex;
  background: color-mix(in oklab, var(--ink-1) 12%, transparent);
  border-radius: 0;
  padding: 3px;
  gap: 2px;
  margin-bottom: 20px;
  flex-shrink: 0;
}
.cs-panel .cs-view-btn {
  flex: 1;
  padding: 5px 6px;
  border: none;
  border-radius: 0;
  font-family: var(--font-primary);
  font-size: var(--t-micro);
  font-weight: 600;
  letter-spacing: 0.05em;
  color: var(--ink-3);
  background: transparent;
  cursor: pointer;
  transition: background 140ms var(--ease-out-quart), color 140ms var(--ease-out-quart), box-shadow 140ms var(--ease-out-quart);
  white-space: nowrap;
}
.cs-panel .cs-view-btn.is-active {
  background: oklch(99% 0.001 90 / 0.72);;
  color: var(--ink-1);
  box-shadow: 0 1px 4px -1px color-mix(in oklab, var(--ink-1) 12%, transparent);
}
.cs-panel .cs-view-btn--locked {
  position: relative;
  opacity: 0.45;
  cursor: not-allowed;
}
.cs-panel .cs-view-btn--locked .cs-view-btn-label, .cs-panel .cs-view-btn--locked .cs-view-btn-locked {
  transition: opacity 180ms var(--ease-out-quart);
}
.cs-panel .cs-view-btn--locked .cs-view-btn-locked {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
}
.cs-panel .cs-view-btn--locked:hover {
  opacity: 0.65;
}
.cs-panel .cs-view-btn--locked:hover .cs-view-btn-label {
  opacity: 0;
}
.cs-panel .cs-view-btn--locked:hover .cs-view-btn-locked {
  opacity: 1;
}
.cs-panel .cs-close:hover { color: var(--ink-1); }
.cs-panel .cs-close:focus-visible { outline: none; }
.cs-panel .cs-sidebar-body {
  flex: 1;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.cs-panel .cs-title {
  font-family: var(--font-primary);
  font-size: clamp(18px, 2vw, 24px);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: -0.012em;
  color: var(--ink-1);
  margin-bottom: 22px;
  word-break: break-word;
}
.cs-panel .cs-meta-label {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-4);
  margin-bottom: 3px;
}
.cs-panel .cs-meta-list {
  display: flex;
  flex-direction: column;
  margin-bottom: 4px;
  margin: 0;
  padding: 0;
}
.cs-panel .cs-meta-row {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 10px 0;
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
  margin: 0;
}
.cs-panel .cs-meta-row:first-child {
  border-top: none;
  padding-top: 0;
}
.cs-panel .cs-meta-value {
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 500;
  line-height: 1.4;
  color: var(--ink-1);
  margin: 0;
  padding: 0;
}
.cs-panel .cs-sidebar-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 16px;
}
.cs-panel .cs-link-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 5;

  display: inline-flex;
  align-items: center;
  gap: 7px;
  flex-shrink: 0;
  height: 28px;
  padding: 0 10px;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;

  /* Flat stamped chip matching the full-page .case-hero__live: light
     surface, ink text, --radius-control like every chip control, no
     blur / shadow / tilt. */
  background: var(--surface-deep);
  border: none;
  border-radius: var(--radius-control);
  color: var(--ink-1);
  font-family: var(--font-primary);
  font-weight: 400;
  font-size: var(--t-meta);
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;

  filter: none;
  box-shadow: none;
  transition: background 160ms var(--ease-out-quart), transform 160ms var(--ease-out-quart);
}
@media (hover: hover) and (pointer: fine) {
  /* Not --surface-tint: the modal panel is a translucent grey that lands
     on almost exactly that value, so the chip vanished on hover. Ink
     mixed into its own resting fill always reads darker than the panel. */
  .cs-panel .cs-link-btn:hover { background: color-mix(in oklab, var(--ink-1) 10%, var(--surface-deep)); }
}
.cs-panel .cs-link-btn:active {
  transform: scale(0.96);
  transition-duration: 80ms;
}
.cs-panel .cs-link-btn--sidebar {
  display: none;
  position: static;
  flex-shrink: 0;
}
.cs-panel .cs-meta-toggle {
  
  display: none;
  align-items: center;
  gap: 5px;
  padding: 4px 8px 4px 9px;

  
  background: transparent;
  border: 1.2px solid color-mix(in oklab, var(--ink-1) 34%, transparent);
  border-radius: 0;
  filter: url(#distress);

  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  line-height: 1;
  white-space: nowrap;
  color: var(--ink-2);

  flex-shrink: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition:
    border-color 220ms var(--ease-drawer),
    background   220ms var(--ease-drawer),
    color        220ms var(--ease-drawer),
    transform    120ms var(--ease-drawer);
}
.cs-panel .cs-meta-toggle:hover {
  border-color: color-mix(in oklab, var(--ink-1) 64%, transparent);
  background: color-mix(in oklab, var(--ink-1) 5%, transparent);
  color: var(--ink-1);
}
.cs-panel .cs-meta-toggle:active {
  translate: 0 1px;
  opacity: 0.6;
  transition-duration: 60ms;
}
.cs-panel .cs-meta-toggle__label {
  
  display: block;
}
.cs-panel .cs-meta-toggle__chevron {
  flex-shrink: 0;
  transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1); 
}
.cs-panel .cs-meta-toggle[aria-expanded="true"] .cs-meta-toggle__chevron {
  transform: rotate(180deg);
}
.cs-panel .cs-meta-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.cs-panel .cs-content-area {
  flex: 1;
  min-width: 0;
  min-height: 0; 
  position: relative;
  display: flex;
  flex-direction: column;
}
.cs-panel .cs-content-wrap {
  flex: 1;
  min-height: 0;
  position: relative;
  display: flex;
  flex-direction: column;
  overflow: hidden; 
  overflow: clip;   
  border-radius: 0;
}
.cs-panel .cs-content {
  
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
  scrollbar-color: color-mix(in oklab, var(--ink-1) 18%, transparent) transparent;
  display: flex;
  flex-direction: column;
  
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  touch-action: pan-y;
}
.cs-panel .cs-sidebar-body, .cs-panel .cs-content-wrap {
  opacity: 0;
  transition: opacity 180ms var(--ease-out-quart);
}
.cs-overlay.is-open .cs-sidebar-body {
  opacity: 1;
  transition: opacity 380ms var(--ease-out-quart) 220ms;
}
.cs-overlay.is-open .cs-content-wrap {
  opacity: 1;
  transition: opacity 380ms var(--ease-out-quart) 300ms;
}
.cs-panel .cs-hero {
  position: relative;
  width: 100%;
  flex-shrink: 0;
  height: 280px;
  overflow: hidden;
}
.cs-panel .cs-hero-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
}
.cs-panel .cs-hero-placeholder {
  width: 100%;
  height: 100%;
  background: linear-gradient(
    135deg,
    oklch(88% 0.008 240) 0%,
    oklch(82% 0.012 240) 100%
  );
}
.cs-panel .cs-body {
  padding: 28px 32px 32px;
  display: grid;
  grid-template-columns: 5fr 7fr;
  gap: 40px;
  align-items: start;
}
.cs-panel .cs-section-label {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-4);
  margin-bottom: 12px;
}
.cs-panel .cs-overview-text {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-1);
  opacity: 0.88;
  font-weight: 400;
}
.cs-panel .cs-overview-para {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-1);
  opacity: 0.88;
  font-weight: 400;
  margin-bottom: 20px;
}
.cs-panel .cs-contributions-list {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.cs-panel .cs-contributions-list li {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.65;
  color: var(--ink-2);
  padding-left: 40px;
  position: relative;
}
.cs-panel .cs-contributions-list li::before {
  content: attr(data-n);
  position: absolute;
  left: 0;
  top: 0;
  color: var(--ink-1);
  font-family: var(--font-primary);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.13em;
  line-height: 1.65;
  opacity: 0.38;
}
.cs-panel .cs-deep-dive-body {
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 9%, transparent);
  padding: 32px 32px 48px;
  display: flex;
  flex-direction: column;
  gap: 40px;
}
.cs-panel .cs-deep-dive-body[hidden] { display: none; }
.cs-panel .cs-body[hidden] { display: none !important; }
.cs-panel .cs-deep-section {
  display: flex;
  flex-direction: column;
  gap: 14px;
  opacity: 0;
  transform: translateY(12px);
  transition:
    opacity  480ms var(--ease-out-expo),
    transform 480ms var(--ease-out-expo);
}
.cs-panel .cs-deep-section.is-visible {
  opacity: 1;
  transform: translateY(0);
}
.cs-panel .cs-deep-heading {
  font-family: var(--font-primary);
  font-weight: 400;
  font-size: clamp(14px, 1.5vw, 18px);
  line-height: 1.12;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  color: var(--ink-2);
  filter: url(#distress-strong);
}
.cs-panel .cs-deep-heading::after {
  content: '';
}
.cs-panel .cs-deep-paragraphs {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.cs-panel .cs-deep-text {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-1);
  font-weight: 400;
}
.cs-panel em {
  font-style: normal;
}
.cs-panel .cs-deep-text--callout em {
  text-decoration: underline;
  text-underline-offset: 2px;
}
.cs-panel .cs-deep-text--callout {
  display: block;
  width: fit-content;
  margin: 14px 0;
  padding: 6px 15px 7px 12px;
  background: #fff;
  border: none;
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 700;
  letter-spacing: -0.07em;
  text-transform: uppercase;
  line-height: 1;
  color: #2d241c;
  filter: blur(0.4px);
  box-shadow:
    0 1px 2px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 3px 8px -1px color-mix(in oklab, var(--ink-1) 11%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
  transform: rotate(-2deg);
  transition:
    transform  400ms var(--ease-out-soft),
    box-shadow 400ms var(--ease-out-soft);
  transform-origin: center center;
  will-change: transform;
}
.cs-panel .cs-deep-text--callout:hover {
  transform: rotate(2.5deg) scale(1.02);
  box-shadow:
    0 3px 4px  color-mix(in oklab, var(--ink-1) 28%, transparent),
    0 7px 16px -3px color-mix(in oklab, var(--ink-1) 11%, transparent),
    inset 0 -1px 0 color-mix(in oklab, var(--ink-1) 8%, transparent);
}
.cs-panel .cs-compare-table {
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.cs-panel .cs-compare-row {
  display: grid;
  grid-template-columns: 28fr 36fr 36fr;
  align-items: center;
  gap: 8px;
}
.cs-panel .cs-compare-row--head {
  padding: 0 13px 7px;
}
.cs-panel .cs-compare-row:not(.cs-compare-row--head) {
  padding: 9px 13px;
  border-radius: 0;
  background: color-mix(in oklab, var(--ink-1) 4%, transparent);
}
.cs-panel .cs-compare-row--highlight:not(.cs-compare-row--head) {
  background: color-mix(in oklab, var(--ink-1) 8%, transparent);
}
.cs-panel .cs-compare-cell {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.4;
  color: var(--ink-1);
  opacity: 0.6;
}
.cs-panel .cs-compare-cell--header {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  opacity: 0.38;
}
.cs-panel .cs-compare-row--highlight .cs-compare-cell {
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 700;
  opacity: 1;
  letter-spacing: -0.01em;
}
.cs-panel .cs-principles-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  padding: 10px 6px;
}
.cs-panel .cs-principle-card {
  padding: 16px 14px;
  border-radius: 0;
  background: rgba(245, 245, 245, 0.38);
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  display: flex;
  flex-direction: column;
  gap: 8px;
  box-shadow:
    0 2px 6px -1px color-mix(in oklab, var(--ink-1) 14%, transparent),
    0 1px 3px color-mix(in oklab, var(--ink-1) 8%, transparent);
  transform: rotate(var(--card-rotate, 0deg));
}
.cs-panel .cs-principle-card:nth-child(1) { --card-rotate: -2deg; --card-rotate-hover: -3.5deg; }
.cs-panel .cs-principle-card:nth-child(2) { --card-rotate: 1.2deg; --card-rotate-hover: 2.8deg; }
.cs-panel .cs-principle-card:nth-child(3) { --card-rotate: -1deg; --card-rotate-hover: -2.5deg; }
.cs-panel .cs-principle-icon {
  display: flex;
  color: var(--ink-1);
  opacity: 0.6;
  margin-bottom: 4px;
}
.cs-panel .cs-principle-title {
  font-family: var(--font-primary);
  font-size: var(--t-body);
  font-weight: 700;
  color: var(--ink-1);
  letter-spacing: -0.01em;
  line-height: 1.3;
}
.cs-panel .cs-principle-body {
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-1);
  font-weight: 500;
}
.cs-panel .cs-landing-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
}
.cs-panel .cs-landing-item {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.cs-panel .cs-landing-side {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  gap: 14px;
}
.cs-panel .cs-landing-side .cs-landing-row {
  flex: 0 0 62%;
  min-width: 0;
  display: flex;
  flex-direction: column;
}
.cs-panel .cs-landing-side .cs-landing-item {
  flex: 1;
  display: flex;
  flex-direction: column;
}
.cs-panel .cs-landing-side .cs-landing-row .cs-landing-frame {
  flex: 1;
  display: flex;
  flex-direction: column;
}
.cs-panel .cs-landing-side .cs-landing-row .cs-landing-img {
  flex: 1;
  object-fit: cover;
  min-height: 0;
  height: 100%;
}
.cs-panel .cs-landing-side .cs-landing-desc {
  flex: 1;
  margin-top: 0;
  padding-top: 0;
}
.cs-panel .cs-landing-label {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-4);
  opacity: 0.55;
  text-align: center;
}
.cs-panel .cs-landing-desc {
  margin-top: 0;
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  color: var(--ink-1);
  font-weight: 400;
}
.cs-panel .cs-landing-desc .cs-deep-text {
  margin-top: 0;
  margin-bottom: 0;
}
.cs-panel .cs-desc-label {
  display: block;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-4);
  margin-top: 14px;
  margin-bottom: 4px;
}
.cs-panel .cs-desc-label:first-child {
  margin-top: 0;
}
.cs-panel .cs-desc-label--section {
  margin-top: 14px;
}
.cs-panel .cs-deep-list {
  list-style: none;
  padding: 0;
  margin: 14px 0 0 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.cs-panel .cs-deep-list li {
  position: relative;
  padding: 14px 16px;
  border-radius: 0;
  background: color-mix(in oklab, var(--ink-1) 3%, transparent);
  border: 1px solid color-mix(in oklab, var(--ink-1) 9%, transparent);
}
.cs-panel .cs-dl-block {
  display: grid;
  grid-template-columns: 38% 1fr;
  row-gap: 10px;
  column-gap: 16px;
  margin-top: 10px;
  align-items: baseline;
}
.cs-panel .cs-dl-term {
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 700;
  color: var(--ink-1);
  opacity: 0.88;
  line-height: 1.62;
}
.cs-panel .cs-dl-desc {
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 400;
  color: var(--ink-1);
  opacity: 0.88;
  line-height: 1.62;
  margin: 0;
}
.cs-panel .cs-persona-grid {
  display: flex;
  gap: 14px;
  margin-top: 14px;
}
.cs-panel .cs-persona-card {
  flex: 1;
  border: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
  border-radius: 0;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  background: color-mix(in oklab, var(--ink-1) 3%, transparent);
}
.cs-panel .cs-persona-header {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
}
.cs-panel .cs-persona-num {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-1);
  opacity: 0.22;
}
.cs-panel .cs-persona-name {
  display: block;
  font-family: var(--font-primary);
  font-size: 16px;
  font-weight: 600;
  letter-spacing: -0.02em;
  color: var(--ink-1);
  line-height: 1.15;
}
.cs-panel .cs-persona-loc {
  display: inline-block;
  flex-shrink: 0;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-1);
  opacity: 0.55;
  background: color-mix(in oklab, var(--ink-1) 7%, transparent);
  border-radius: 0;
  padding: 2px 7px 3px;
}
.cs-panel .cs-persona-demo {
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 400;
  line-height: 1.62;
  color: var(--ink-1);
  margin: 0;
  padding-bottom: 14px;
  border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
}
.cs-panel .cs-persona-block {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.cs-panel .cs-persona-label {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-1);
  opacity: 0.35;
}
.cs-panel .cs-persona-text {
  font-family: var(--font-body);
  font-size: var(--t-body);
  font-weight: 400;
  line-height: 1.62;
  color: var(--ink-1);
  margin: 0;
}

.cs-panel .cs-font-grid {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-top: 14px;
}
.cs-panel .cs-font-box {
  flex: 1;
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  border-radius: 0;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 5px;
}
.cs-panel .cs-font-specimen {
  display: block;
  font-size: 26px;
  line-height: 1;
  color: var(--ink-1);
  opacity: 0.82;
  margin-bottom: 4px;
}
.cs-panel .cs-font-name {
  display: block;
  font-family: var(--font-primary);
  font-size: 8px;
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.cs-panel .cs-font-desc {
  font-family: var(--font-body);
  font-size: var(--t-meta);
  font-weight: 500;
  line-height: 1.5;
  color: var(--ink-1);
  margin: 0;
}
.cs-panel .cs-landing-row--full {
  grid-template-columns: 1fr;
}
.cs-panel .cs-wip-notice {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px;
  border-radius: 0;
  border: 1px dashed color-mix(in oklab, var(--ink-1) 18%, transparent);
}
.cs-panel .cs-wip-badge {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-1);
  opacity: 0.4;
  animation: wip-pulse 2.8s ease-in-out infinite;
}
@keyframes wip-pulse {
  0%, 100% { opacity: 0.4; }
  50%       { opacity: 0.7; }
}
.cs-panel .cs-landing-img {
  transition: transform 400ms var(--ease-out-expo);
}
.cs-panel .cs-landing-frame:hover .cs-landing-img {
  transform: scale(1.03);
}
.cs-panel .cs-landing-frame:hover {
  transform: translateY(-2px);
  box-shadow:
    0 8px 20px -6px rgba(0, 0, 0, 0.22),
    0 3px 6px -1px rgba(0, 0, 0, 0.14);
}
.cs-panel .cs-principle-card {
  transition:
    box-shadow 320ms var(--ease-out-soft),
    transform  320ms var(--ease-out-soft);
}
.cs-panel .cs-principle-card:hover {
  transform: rotate(var(--card-rotate-hover, 0deg));
  box-shadow:
    0 4px 14px -3px color-mix(in oklab, var(--ink-1) 20%, transparent),
    0 2px 4px color-mix(in oklab, var(--ink-1) 10%, transparent);
}
.cs-panel .cs-principle-card:active {
  transform: rotate(var(--card-rotate-hover, 0deg)) scale(0.99);
  transition-duration: 100ms;
}
.cs-panel .cs-persona-card {
  transition:
    background 200ms var(--ease-out-soft),
    transform  200ms var(--ease-out-soft),
    border-color 200ms var(--ease-out-soft);
}
.cs-panel .cs-persona-card:hover {
  background: color-mix(in oklab, var(--ink-1) 6%, transparent);
  transform: translateY(-2px);
}
.cs-panel .cs-persona-card:active {
  transform: translateY(0) scale(0.99);
  transition-duration: 100ms;
}
.cs-panel .cs-font-box {
  transition:
    background    200ms var(--ease-out-soft),
    border-color  200ms var(--ease-out-soft),
    transform     200ms var(--ease-out-soft);
}
.cs-panel .cs-font-box:hover {
  background: color-mix(in oklab, var(--ink-1) 4%, transparent);
  border-color: color-mix(in oklab, var(--ink-1) 20%, transparent);
  transform: translateY(-1px);
}
.cs-panel .cs-deep-list li {
  transition:
    transform   240ms var(--ease-out-soft),
    background  200ms var(--ease-out-quart);
}
.cs-panel .cs-deep-list li:hover {
  transform: translateY(-2px);
  background: color-mix(in oklab, var(--ink-1) 6%, transparent);
  border-color: color-mix(in oklab, var(--ink-1) 14%, transparent);
}
.cs-panel .cs-compare-row:not(.cs-compare-row--head) {
  transition:
    background 160ms var(--ease-out-quart),
    transform  160ms var(--ease-out-soft);
}
.cs-panel .cs-compare-row:not(.cs-compare-row--head):hover {
  background: color-mix(in oklab, var(--ink-1) 7%, transparent);
  transform: translateX(2px);
}
@keyframes cs-item-in {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}
.cs-overlay.is-open .cs-hero {
  animation: cs-item-in 420ms var(--ease-out-soft) 160ms both;
}
.cs-overlay.is-open .cs-body .cs-section:nth-child(1) {
  animation: cs-item-in 380ms var(--ease-out-soft) 240ms both;
}
.cs-overlay.is-open .cs-body .cs-section:nth-child(2) {
  animation: cs-item-in 380ms var(--ease-out-soft) 330ms both;
}
@media (prefers-reduced-motion: reduce) {
  .cs-panel .cs-deep-section {
    opacity: 1;
    transform: none;
    transition: none;
  }
  .cs-panel .cs-landing-img, .cs-panel .cs-landing-frame, .cs-panel .cs-principle-card, .cs-panel .cs-persona-card, .cs-panel .cs-font-box, .cs-panel .cs-close svg, .cs-panel .cs-contributions-list li, .cs-panel .cs-contributions-list li::before, .cs-panel .cs-deep-list li, .cs-panel .cs-compare-row:not(.cs-compare-row--head) {
    transition: none;
  }
  .cs-panel .cs-wip-badge {
    animation: none;
  }
  .cs-overlay.is-open .cs-hero, .cs-overlay.is-open .cs-body .cs-section {
    animation: none;
  }
}
.cs-panel .cs-caption-image {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.cs-panel .cs-landing-frame {
  position: relative;
  border: 6px solid #F5F5F5;
  border-radius: 0;
  overflow: hidden;
  box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.18);
  transition:
    box-shadow 300ms var(--ease-out-soft),
    transform  300ms var(--ease-out-soft);
}
.cs-panel .cs-landing-frame::before {
  content: '';
  position: absolute;
  inset: 0;
  box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.14);
  z-index: 2;
  pointer-events: none;
}
.cs-panel .cs-landing-img {
  display: block;
  width: 100%;
  border-radius: 0;
  cursor: zoom-in;
}
.cs-panel .cs-phone-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
}
.cs-panel .cs-phone-item {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}
.cs-panel .cs-phone-frame {
  border: 6px solid #F5F5F5;
  border-radius: 0;
  overflow-y: auto;
  overflow-x: hidden;
  height: clamp(380px, 60vh, 600px);
  box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.18);
  -webkit-overflow-scrolling: touch;
  scrollbar-width: thin;
  scrollbar-color: color-mix(in oklab, var(--ink-1) 22%, transparent) transparent;
}
.cs-panel .cs-phone-frame::-webkit-scrollbar { width: 6px; }
.cs-panel .cs-phone-frame::-webkit-scrollbar-thumb {
  background: color-mix(in oklab, var(--ink-1) 22%, transparent);
  border-radius: 0;
}
.cs-panel .cs-phone-img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 0;
}
@media (max-width: 719px) {
  .cs-panel .cs-phone-row { grid-template-columns: 1fr; gap: 16px; }
  .cs-panel .cs-phone-frame { height: clamp(340px, 64vh, 540px); }
}
@media (max-width: 719px) {
  .cs-overlay {
    padding: 16px;
    align-items: center;
  }
  .cs-panel {
    height: 88dvh;
    max-height: 88dvh;
    border-radius: 0;
    width: 100%;
  }
  .cs-panel .cs-layout { flex-direction: column; padding: 6px; gap: 5px; }
  .cs-panel .cs-sidebar {
    width: 100%;
    border-radius: 0;
    padding: 12px 14px;
    flex-shrink: 0;
    display: grid;
    grid-template-columns: 1fr auto;
    grid-template-rows: auto auto;
    gap: 10px 8px;
    align-items: center;
  }
  .cs-panel .cs-close {
    grid-column: 1;
    grid-row: 1;
    margin: 0;
    padding: 8px 0;
  }
  .cs-panel .cs-view-toggle {
    display: none;
  }
  .cs-panel .cs-link-btn--sidebar.is-active {
    display: inline-flex;
    grid-column: 2;
    grid-row: 1;
    align-self: center;
  }
  .cs-panel #cs-link { display: none !important; }
  .cs-panel .cs-sidebar-body {
    grid-column: 1 / -1;
    grid-row: 2;
    overflow: visible;
  }
  .cs-panel .cs-title {
    margin-bottom: 0;
    font-size: 16px;
  }
  .cs-panel .cs-meta-header {
    flex-direction: column;
    align-items: stretch;
    gap: 0;
  }
  .cs-panel .cs-meta-toggle {
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: 100%;
    padding: 6px 0 2px;
    border-radius: 0;
    border: none;
    border-top: 1px dashed color-mix(in oklab, var(--ink-1) 20%, transparent);
    background: transparent;
    filter: none; 
    font-size: var(--t-meta);
    letter-spacing: 0.14em;
    margin-top: 8px;
  }
  .cs-panel .cs-meta-toggle:hover {
    background: transparent;
    border-color: color-mix(in oklab, var(--ink-1) 40%, transparent);
  }
  .cs-panel .cs-meta-list {
    display: grid;
    grid-template-rows: 0fr;
    overflow: hidden;
    opacity: 0;
    margin-top: 0;
    transition:
      grid-template-rows 320ms var(--ease-drawer),
      opacity            240ms var(--ease-drawer),
      margin-top         320ms var(--ease-drawer);
  }
  .cs-panel .cs-meta-rows {
    min-height: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
  }
  .cs-panel .cs-meta-list.is-open {
    grid-template-rows: 1fr;
    opacity: 1;
    margin-top: 8px;
  }
  .cs-panel .cs-meta-row {
    flex-direction: row;
    align-items: baseline;
    justify-content: space-between;
    gap: 8px;
    padding: 5px 0;
    border-top: 1px solid color-mix(in oklab, var(--ink-1) 8%, transparent);
    border-bottom: none;
  }
  .cs-panel .cs-meta-row:first-child {
    border-top: none;
    padding-top: 0;
  }
  .cs-panel .cs-meta-value {
    text-align: right;
  }
  .cs-panel .cs-sidebar-tags {
    margin-top: 8px;
  }
  .cs-panel .cs-hero { height: 140px; }
  .cs-panel .cs-body {
    grid-template-columns: 1fr;
    padding: 16px;
    gap: 20px;
  }
  .cs-panel .cs-deep-dive-body {
    padding: 20px 16px 32px;
    gap: 28px;
  }
  .cs-panel .cs-principles-grid {
    grid-template-columns: 1fr;
  }
  .cs-panel .cs-compare-row:not(.cs-compare-row--head) { padding: 8px 10px; }
  .cs-panel .cs-compare-cell { font-size: var(--t-meta); }
  .cs-panel .cs-compare-row--head { padding: 0 10px 6px; }
}
@media (max-width: 900px) {
  .cs-panel .cs-landing-side {
    flex-direction: column;
  }
  .cs-panel .cs-landing-side .cs-landing-row {
    flex: none;
    width: 100%;
  }
  .cs-panel .cs-landing-side .cs-landing-desc {
    width: 100%;
  }
  .cs-panel .cs-font-grid {
    grid-template-columns: 1fr;
  }
}
/* Same visual-viewport fix as #cs-lightbox-case (see its comment in
   the full-page rules above) — top/left/width/height driven by
   --vv-* instead of inset: 0, kept in sync with window.visualViewport
   by js/casestudy.js so a pinch-zoomed view still centers the image
   on what's actually visible. */
.cs-panel #cs-lightbox {
  position: fixed;
  top: var(--vv-top, 0px);
  left: var(--vv-left, 0px);
  width: var(--vv-width, 100vw);
  height: var(--vv-height, 100vh);
  z-index: 9999;
  background: rgba(0, 0, 0, 0.72);
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 220ms var(--ease-out-quart);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}
.cs-panel #cs-lightbox.is-open {
  opacity: 1;
  pointer-events: all;
}
/* casestudy.js appends #cs-lightbox to <body>, not inside .cs-panel, so
   the base state above never matches it: its fixed-position close button
   floated on every page. It only showed in dark mode, where the button's
   translucent white border reads against a dark ground (on white it
   vanished). Hide the overlay, and with it the button, whenever it is not
   open — wherever in the DOM it sits. Distinct id from the full-page
   #cs-lightbox-case system, so that overlay is untouched. */
#cs-lightbox:not(.is-open) { display: none; }
.cs-panel .cs-lightbox-inner {
  max-width: min(64vw, 760px);
  max-height: 78vh;
  transform: scale(0.94);
  transition: transform 260ms var(--ease-out-soft);
}
.cs-panel #cs-lightbox.is-open .cs-lightbox-inner {
  transform: scale(1);
}
.cs-panel .cs-lightbox-frame {
  position: relative;
  width: fit-content;
  max-width: 100%;
  margin: 0 auto;
  border: 6px solid #F5F5F5;
  border-radius: 0;
  overflow: hidden;
  max-height: calc(88vh - 12px);
  box-shadow:
    0 32px 80px rgba(0, 0, 0, 0.38),
    0 8px 24px rgba(0, 0, 0, 0.18);
}
.cs-panel .cs-lightbox-frame::before {
  content: '';
  position: absolute;
  inset: 0;
  box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.14);
  z-index: 2;
  pointer-events: none;
  border-radius: 0;
}
/* width/height: auto — see the full-page .cs-lightbox-img comment;
   same shrink-to-fit chain, same fix. */
.cs-panel .cs-lightbox-img {
  display: block;
  width: auto;
  height: auto;
  max-width: min(82vw, 1100px);
  max-height: calc(88vh - 12px);
  object-fit: contain;
  border-radius: 0;
}
/* absolute, not fixed — see .cs-lightbox-close's comment in the
   full-page rules; #cs-lightbox is its containing block either way. */
.cs-panel .cs-lightbox-close {
  position: absolute;
  top: 22px;
  right: 26px;
  width: 34px;
  height: 34px;
  border-radius: var(--radius-control);
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.1);
  color: rgba(255, 255, 255, 0.75);
  font-size: 12px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 180ms var(--ease-out-quart), border-color 180ms var(--ease-out-quart);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}
.cs-panel .cs-lightbox-close:hover {
  background: rgba(255, 255, 255, 0.18);
  border-color: rgba(255, 255, 255, 0.32);
  color: #fff;
}
.cs-panel .cs-landing-img {
  cursor: zoom-in;
}
@media (max-width: 768px) {
  .cs-panel .cs-lightbox-inner {
    max-width: 96vw;
    max-height: 90dvh;
  }
  .cs-panel .cs-lightbox-frame {
    max-height: calc(90dvh - 12px);
  }
  .cs-panel .cs-lightbox-img {
    max-width: 96vw;
    max-height: calc(90dvh - 12px);
  }
}

/* == MODAL: toggle removed (scoped to .cs-panel, never touches full-page cases) == */
.cs-overlay .cs-view-toggle { display: none !important; }

/* == MODAL close = full-page .case-close chip (icon only, hover redraw) == */
.cs-overlay .cs-panel .cs-close {
  width: 28px; height: 28px; min-width: 0; padding: 0; gap: 0;
  background: none;
  border: 0; color: var(--ink-3); font-size: 0;
  display: inline-flex; align-items: center; justify-content: center;
  transition: background 160ms var(--ease-out-quart), color 160ms var(--ease-out-quart), transform 160ms var(--ease-out-quart);
}
.cs-overlay .cs-panel .cs-close:active { transform: scale(0.9); }
@media (hover: hover) and (pointer: fine) {
  .cs-overlay .cs-panel .cs-close:hover { color: var(--ink-1); }
}

/* == MODAL fixes: close top-left in sidebar flow, section headers in accent blue == */
.cs-overlay .cs-panel .cs-close { position: static; top: auto; right: auto; align-self: flex-start; }
.cs-overlay .cs-panel .cs-section-label { color: var(--accent-blue); }

/* ── Image skeleton (Sept 2026; js/imgload.js) ─────────────────────
   Waiting: a flat --surface-frame skeleton block with a soft paper-toned
   sheen sweeping left to right. It fades up only after a 160ms grace,
   so a fast or cached image never flashes it. Arrived: the picture
   fades in over the block, opacity only: no scale, no blur. */
img.img-wait {
  background-color: var(--surface-frame);
  background-image: linear-gradient(90deg,
    transparent 0%,
    color-mix(in srgb, var(--surface-base) 70%, transparent) 50%,
    transparent 100%);
  background-size: 50% 100%;
  background-repeat: no-repeat;
  animation:
    img-plate-in 320ms var(--ease-out-soft) 160ms both,
    img-sheen 1400ms var(--ease-out-quart) 160ms infinite;
}
@keyframes img-plate-in { from { background-color: transparent; background-size: 0% 100%; } }
@keyframes img-sheen {
  from { background-position: -100% 0; }
  to   { background-position: 200% 0; }
}
img.img-develop {
  animation: img-develop 420ms var(--ease-out-quart) both;
}
@keyframes img-develop {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  img.img-wait { animation: img-plate-in 1ms linear 160ms both; background-image: none; }
  img.img-develop { animation: img-develop-fade 200ms linear both; }
  @keyframes img-develop-fade { from { opacity: 0; } }
}

/* ── Close redraw (Sept 2026; replaces the quarter-turn spin) ───────
   Every × on the site is two stroke paths with pathLength="1", so one
   dash value fits any size. On hover the strokes re-ink one after the
   other, 70ms apart, like a pen going over the mark again. It plays
   once per hover and then sits still. */
.case-close path,
.tbar__chat-close path,
.cs-panel .cs-close path,
.cs-lightbox-close path {
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
}
@media (hover: hover) and (pointer: fine) {
  .case-close:hover path,
  .tbar__chat-close:hover path,
  .cs-panel .cs-close:hover path,
  .cs-lightbox-close:hover path {
    animation: close-redraw 480ms var(--ease-out-expo) backwards;
  }
  .case-close:hover path + path,
  .tbar__chat-close:hover path + path,
  .cs-panel .cs-close:hover path + path,
  .cs-lightbox-close:hover path + path { animation-delay: 70ms; }
}
@keyframes close-redraw { from { stroke-dashoffset: 1; } }
@media (prefers-reduced-motion: reduce) {
  .case-close path,
  .tbar__chat-close path,
  .cs-panel .cs-close path,
  .cs-lightbox-close path { animation: none !important; }
}

/* ════════════════════════════════════════════════════════════════
   BORROW CARD (Sept 2026, lab page: borrow.html). A library book card
   in its pocket: slide it out, sign the next line, stamp the date due,
   slip it back. Markup: renderBorrowCard in js/components.js.
   Behaviour: js/borrow-card.js. See DESIGN.md.

   A physical object, so it wears the --paper-* material tokens (the
   documented exception): a warm white card in an off-white beige
   pocket, lit from the top left. Every shadow is layered and falls down
   and a little right: a hairline contact, a tight near shadow, and a
   long soft ambient one, which is what makes paper read as sitting ON
   the screen rather than printed into it. Everything inside the card
   and pocket is sized in cqi so the object scales as one sheet.
   ════════════════════════════════════════════════════════════════ */
.bc {
  /* Small on purpose (a card you hold, not a poster), and 36svh keeps
     it and its pocket well inside one screen. */
  --bc-w: min(290px, calc(100vw - 2 * var(--space-4)), 35svh);
  /* Room above the stage for the card to rise into; the hero spends
     less of it and lets the rise borrow the page's own margin. */
  --bc-headroom: 0.4;
  --bc-print: color-mix(in srgb, var(--paper-ink) 86%, transparent);
  --bc-rule: var(--paper-rule);
  --bc-edge: color-mix(in srgb, var(--paper-shadow) 9%, transparent);
  /* Paper texture: a whisper of tooth only. A heavier speckle and a
     pulp mottling read as grime; with none at all the stock read as
     plastic. This grain is fine (1.1) and faint (about half the old
     density), just enough for the surface to catch light like card. */
  --bc-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .05 0 0 0 -.012'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
  --bc-fibre: none;
  /* Shadows: two layers each, falling straight down, low and soft (a
     tight contact under the edge and one wide ambient). The earlier
     four-layer, sideways-offset stacks read as smudge, not depth. */
  --bc-sh-pocket:
    0 1px 2px color-mix(in srgb, var(--paper-shadow) 5%, transparent),
    0 16px 36px -16px color-mix(in srgb, var(--paper-shadow) 16%, transparent);
  --bc-sh-card:
    0 0 0 0.5px color-mix(in srgb, var(--paper-shadow) 5%, transparent),
    0 1px 2px color-mix(in srgb, var(--paper-shadow) 6%, transparent);
  /* In flight: held up off the pocket, one long faint shadow. */
  --bc-sh-flying:
    0 26px 44px -22px color-mix(in srgb, var(--paper-shadow) 16%, transparent);
  /* Resting on the pocket: close to it, short and soft. */
  --bc-sh-resting:
    0 1px 2px color-mix(in srgb, var(--paper-shadow) 5%, transparent),
    0 10px 24px -12px color-mix(in srgb, var(--paper-shadow) 12%, transparent);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-4);
}
.bc-defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.bc-sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* The stage is as tall as the card laid on its pocket; the margin above
   it is most of the headroom the card rises into on its way out (the
   top of the arc borrows the page's own space above). */
.bc-stage {
  position: relative;
  width: var(--bc-w);
  height: calc(var(--bc-w) * 1.52);
  margin-top: calc(var(--bc-w) * var(--bc-headroom));
}

/* ── Pocket ── */
.bc-pocket { position: absolute; left: 0; right: 0; bottom: 0; }
.bc-pocket--back {
  /* The back panel, rising above the front with rounded shoulders. Its
     visible strip is the pocket's inside: lit at the rim, falling into
     shade where it drops behind the front. */
  height: calc(var(--bc-w) * 0.87);
  z-index: 1;
  border-radius: calc(var(--bc-w) * 0.06) calc(var(--bc-w) * 0.06) 4px 4px;
  background:
    linear-gradient(to bottom, transparent 0, color-mix(in srgb, var(--paper-shadow) 5%, transparent) 9%),
    var(--paper-pocket-in);
  box-shadow: 0 1px 2px color-mix(in srgb, var(--paper-shadow) 5%, transparent);
}
.bc-pocket--front {
  height: calc(var(--bc-w) * 0.8);
  z-index: 3;
  border-radius: 1px 1px 4px 4px;
  container-type: inline-size;
  box-shadow: var(--bc-sh-pocket);
}
.bc-pocket__face {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background:
    /* One soft light across the sheet, brighter at the top. */
    linear-gradient(175deg, color-mix(in srgb, var(--paper-light) 30%, transparent) 0%, transparent 55%,
      transparent 80%, color-mix(in srgb, var(--paper-shadow) 3%, transparent) 100%),
    var(--bc-grain), var(--paper-pocket);
  /* The cut top edge catches the light. */
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--paper-light) 45%, transparent);
  /* Thumb notch: the half-moon cut a pocket has so you can pinch the card. */
  -webkit-mask: radial-gradient(circle at 50% 0, transparent 5.2cqi, black calc(5.2cqi + 1px));
  mask: radial-gradient(circle at 50% 0, transparent 5.2cqi, black calc(5.2cqi + 1px));
}
/* Letterpress, soft: ink pressed into the stock rather than printed on
   top of it. The ink is let down toward the paper's own tone and
   multiplied, so the fibres show through the letters; the bruise reads
   from a light catch under each letter and a faint shade over it. */
.bc-pocket__from {
  position: absolute;
  left: 0; right: 0; top: 44%;
  margin: 0;
  padding-left: 0.24em;   /* cancels the trailing tracking so it centres */
  text-align: center;
  font-family: var(--font-primary);
  font-weight: 500;
  font-size: max(var(--t-eyebrow), 4.3cqi);
  letter-spacing: 0.24em;
  line-height: 1;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--paper-ink) 62%, var(--paper-pocket));
  mix-blend-mode: multiply;
  text-shadow:
    0 1px 1px color-mix(in srgb, var(--paper-light) 70%, transparent),
    0 -0.5px 1px color-mix(in srgb, var(--paper-shadow) 14%, transparent);
}

/* ── The owner's hand (baked path art, see BORROW_HAND) ── */
.bc-hand { overflow: visible; }
.bc-hand path { fill: color-mix(in srgb, var(--paper-ink) 90%, transparent); }
.bc-hand--pocket {
  position: absolute;
  left: 50%; top: 53%;
  width: 46cqi;
  transform: translateX(-50%) rotate(-3deg);
}
.bc-hand--row {
  position: absolute;
  left: 6%; top: 50%;
  width: 64%;
  transform: translateY(-62%) rotate(-1.5deg);
}
.bc-hand--row path { opacity: 0.9; }

/* ── Card ── */
.bc-card {
  position: absolute;
  left: 50%;
  bottom: calc(var(--bc-w) * 0.035);
  width: calc(var(--bc-w) * 0.84);
  aspect-ratio: 3 / 5;
  z-index: 2;
  transform: translateX(-50%);
  transform-origin: 50% 100%;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  cursor: grab;
  outline: none;
  /* The card is the container: the paper's own padding is in cqi too. */
  container-type: inline-size;
}
.bc-card.is-above { z-index: 4; }
.bc[data-state="drag"] .bc-card { cursor: grabbing; }
.bc[data-state="out"] .bc-card { touch-action: pan-y; }
/* Two shadows, cross-faded: a long faint one while the card is held up
   in flight, a short soft one once it lies on the pocket. */
.bc-card::before,
.bc-card::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: 5px;
  opacity: 0;
  transition: opacity 460ms var(--ease-out-quart);
}
.bc-card::before { box-shadow: var(--bc-sh-flying); }
.bc-card::after { box-shadow: var(--bc-sh-resting); }
.bc-card.is-flying::before,
.bc-card.is-resting::after { opacity: 1; }
/* Mid-turn the shadow narrows with the paper as it goes edge-on, and
   the long flight shadow comes up as the card lifts off the pocket
   (--fx and --lift, set per frame by setTurn in js/borrow-card.js). */
.bc-card::before,
.bc-card::after { transform: scaleX(var(--fx, 1)); }
.bc-card.is-turning::before { opacity: var(--lift, 0); transition: none; }
.bc-card.is-turning::after { opacity: calc(1 - var(--lift, 0) * 0.7); transition: none; }
.bc.is-viewing[data-state="out"] .bc-card { cursor: grab; touch-action: pan-y; }
.bc.is-viewing[data-state="out"] .bc-card:active { cursor: grabbing; }
/* Turning the card over: the two faces share one 3D turn on the card's
   long edge. Perspective lives on the card so only this turn is 3D. */
.bc-card { perspective: 1600px; }
.bc-card__flip {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
}
.bc-card__paper,
.bc-card__back {
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
}
.bc-card__back {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: 11cqi 8cqi 2.6cqi;
  transform: rotateY(180deg);
  border-radius: 5px;
  background:
    linear-gradient(195deg,
      color-mix(in srgb, var(--paper-light) 50%, transparent) 0%,
      transparent 40%),
    var(--bc-grain), var(--paper-card);
  box-shadow: var(--bc-sh-card);
}
/* The note: pencil, a loose hand, lines drifting like real writing. */
/* Scribbled small in the bottom-right corner, the way you'd jot on the
   back of something, not centred like a letter. */
.bc-note { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; align-items: flex-end; gap: 1.6cqi; padding-bottom: 2cqi; }
.bc-note__line { display: block; transform: rotate(-3deg); transform-origin: right center; }
.bc-hand--note { display: block; height: 7.6cqi; width: auto; max-width: 100%; }
.bc-hand--note path { fill: color-mix(in srgb, var(--paper-graphite) 92%, transparent); filter: url(#bc-graphite-hand); }
.bc-note__line:last-child { margin-right: 4cqi; }
.bc-card__foot { display: flex; justify-content: space-between; align-items: baseline; }
.bc-card__paper {
  --bc-type: max(var(--t-micro), 4.4cqi);
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: 6cqi 6cqi 2.6cqi;
  background:
    linear-gradient(165deg,
      color-mix(in srgb, var(--paper-light) 55%, transparent) 0%,
      transparent 40%),
    var(--bc-grain), var(--paper-card);
  border-radius: 5px;
  box-shadow: var(--bc-sh-card);
  color: var(--bc-print);
  transition: translate 320ms var(--ease-out-quart);
}
@media (pointer: coarse) {
  /* 16px or iOS zooms the page when the name field takes focus. */
  .bc-card__paper { --bc-type: max(16px, 4.4cqi); }
  /* Signed names only need to read, not dodge iOS zoom: 14px fits 20 characters on a phone line. */
  .bc-card__paper .bc-typed { font-size: max(14px, 3.8cqi); }
  /* The field holds 16px so iOS never zooms, then draws at 14px
     (0.875) to match the signed names; widened to keep its full line. */
  .bc-card__paper .bc-input {
    font-size: 16px;
    right: auto;
    width: calc(100% / 0.875);
    scale: 0.875;
    transform-origin: left center;
  }
}
@media (hover: hover) and (pointer: fine) {
  .bc[data-state="idle"] .bc-card:hover .bc-card__paper { translate: 0 calc(var(--bc-w) * -0.025); }
}
.bc[data-state="idle"] .bc-card:focus-visible .bc-card__paper {
  translate: 0 calc(var(--bc-w) * -0.025);
  outline: 2px solid var(--ink-stamp);
  outline-offset: 3px;
}
.bc[data-state="drag"] .bc-card__paper { translate: none; transition: none; }
/* Until it's touched, the card tugs at its pocket now and then. */
.bc:not(.has-touched)[data-state="idle"] .bc-card__paper {
  animation: bc-peek 6s var(--ease-out-quart) 2.4s infinite;
}
@media (hover: hover) and (pointer: fine) {
  .bc[data-state="idle"] .bc-card:hover .bc-card__paper { animation: none; }
}
@keyframes bc-peek {
  0%, 84%, 100% { transform: translateY(0); }
  88% { transform: translateY(-2.2%); }
  93% { transform: translateY(0); }
  96% { transform: translateY(-0.7%); }
}
/* A stamp coming down jolts the card a hair. */
.bc-card__paper.is-thud { animation: bc-thud 240ms var(--ease-out-quart); }
@keyframes bc-thud { 30% { transform: translateY(1.5px) scale(0.997); } }

/* Typewriter: every strike its own density and a hair off the line
   (set per letter by bcTyped), with the ribbon's faint bleed. */
.bc-head,
.bc-field__value,
.bc-typed,
.bc-input {
  font-family: var(--font-mono);
  font-weight: 400;
  text-shadow: 0 0 0.6px color-mix(in srgb, var(--paper-ink) 55%, transparent);
}
.bc-t { display: inline-block; transform: translateY(var(--dy)); opacity: var(--o); }
.bc-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  font-size: var(--bc-type);
  line-height: 1.25;
}
.bc-call { display: flex; flex-direction: column; }
.bc-call > span, .bc-copy { white-space: nowrap; }
.bc-field { margin-top: 2.6cqi; }
.bc-field__value {
  display: block;
  padding-left: 8cqi;
  font-size: var(--bc-type);
  line-height: 1.55;
  white-space: nowrap;
}
.bc-field__label,
.bc-row--head,
.bc-card__foot {
  font-family: var(--font-primary);
  font-weight: 600;
  text-transform: uppercase;
  line-height: 1;
}
.bc-field__label {
  display: block;
  padding-top: 0.9cqi;
  border-top: 1px solid var(--bc-rule);
  font-size: max(var(--t-micro), 2.9cqi);
  letter-spacing: 0.14em;
}

/* ── The borrowers' table ── */
.bc-table {
  flex: 1;
  position: relative;
  display: grid;
  grid-template-rows: auto repeat(var(--bc-rows), 1fr);
  margin-top: 4.4cqi;
  border-top: 1.5px solid var(--bc-rule);
}
.bc-table::before {
  content: '';
  position: absolute;
  top: 0; bottom: 0; left: 29%;
  border-left: 1px solid var(--bc-rule);
  pointer-events: none;
}
.bc-row {
  position: relative;
  display: grid;
  grid-template-columns: 29% 1fr;
  min-height: 0;
  border-bottom: 1px solid var(--bc-rule);
}
.bc-row--head {
  align-items: center;
  padding: 1.9cqi 0 1.5cqi;
  border-bottom: 3px double var(--bc-rule);
  font-size: max(var(--t-micro), 2.9cqi);
  letter-spacing: 0.12em;
  text-align: center;
}
.bc-cell { position: relative; min-width: 0; }
.bc-card__foot {
  margin-top: 1.4cqi;
  font-size: max(var(--t-micro), 2.3cqi);
  letter-spacing: 0.1em;
  opacity: 0.72;
}

/* Rubber date stamp: pad ink, a tilt, a drift, and a density set by
   how hard it came down. Fresh ink sits heavy, then dries. */
.bc-stamp {
  position: absolute;
  left: 3%; top: 0;
  width: 94%; height: 100%;
  overflow: visible;
  transform: translate(var(--sx), var(--sy)) rotate(var(--rot));
  opacity: var(--press);
  transition: opacity 1400ms var(--ease-out-soft);
}
.bc-stamp text {
  font-family: var(--font-primary);
  font-weight: 700;
  font-size: 19px;
  letter-spacing: 0.02em;
  fill: var(--paper-ink);
}
.bc-stamp.is-fresh { opacity: 1; transition: none; }
/* A misfire's second mark: the bounce prints lighter, the smear softer. */
.bc-stamp .bc-stamp__ghost { opacity: 0.38; }
.bc-stamp .bc-stamp__smudge { opacity: 0.45; }

/* Handwriting: filled outlines whose width follows the pen, spilling
   past the line like a real signature. Each line is its own pen (see
   PENS in js/borrow-card.js): the colour lives here, the nib there. */
.bc-ink,
.bc-pad {
  position: absolute;
  left: 0; top: -45%;
  width: 100%; height: 190%;
  overflow: visible;
}
.bc-ink { pointer-events: none; }
/* All black, never one black: a ballpoint's paste writes a touch grey,
   a biro's leans blue-black, a fountain pen's ink is warm, a felt tip
   and a fineliner are the true black. */
.bc-ink--ballpoint path { fill: color-mix(in srgb, var(--paper-ink) 80%, var(--paper-card)); }
.bc-ink--biro path { fill: color-mix(in srgb, var(--paper-ink) 90%, var(--paper-blue)); }
.bc-ink--fountain path { fill: color-mix(in srgb, var(--paper-ink) 86%, var(--paper-pocket-in)); }
.bc-ink--felt path,
.bc-ink--fineliner path { fill: var(--paper-ink); }
.bc-ink--felt { opacity: 0.9; }
.bc-ink--pencil path { fill: var(--paper-graphite); }
.bc-ink--pencil { opacity: 0.88; }
.bc-pad {
  z-index: 2;
  touch-action: none;
  /* A pen cursor, drawn like the highlighter's marker. */
  cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cg transform='rotate(45 12 12)'%3E%3Cpath d='M12 22 10.6 17.6h2.8Z' fill='%23111111' stroke='%23111111' stroke-width='1' stroke-linejoin='round'/%3E%3Crect x='10.6' y='5' width='2.8' height='12.6' rx='.6' fill='%23ffffff' stroke='%23111111' stroke-width='1'/%3E%3Crect x='10.6' y='2.4' width='2.8' height='2.6' fill='%23111111'/%3E%3C/g%3E%3C/svg%3E") 5 19, crosshair;
}
.bc-typed,
.bc-input { font-size: var(--bc-type); }
/* Phones and tablets type only (js/borrow-card.js TYPE_ONLY): no pen
   pad over the line, so a tap lands on the field itself. */
.bc-pad--off { display: none; }
.bc-typed {
  position: absolute;
  left: 9%; top: 50%;
  translate: 0 -50%;
  max-width: 88%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--bc-print);
}

/* The open line: a sign-here X, the name field, the pad over both. */
.bc-row.is-active:focus-within { box-shadow: inset 0 -1.5px 0 color-mix(in srgb, var(--paper-ink) 70%, transparent); }
.bc-input {
  position: absolute;
  inset: 0;
  width: 100%;
  padding: 0 3% 0 9%;
  border: 0;
  border-radius: 0;
  background: transparent;
  color: var(--bc-print);
  caret-color: var(--paper-ink);
  outline: none;
}
.bc-signhere {
  position: absolute;
  left: 1%; top: 50%;
  width: 7.5%;
  aspect-ratio: 1;
  translate: 0 -50%;
  pointer-events: none;
  transition: opacity 200ms var(--ease-out-quart);
}
.bc-x { width: 100%; height: 100%; overflow: visible; }
.bc-x path {
  fill: none;
  stroke: color-mix(in srgb, var(--paper-ink) 80%, transparent);
  stroke-width: 9;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1 2;
  stroke-dashoffset: 1;
  animation: bc-write var(--dur) linear calc(var(--delay) + 160ms) forwards;
}
@keyframes bc-write { to { stroke-dashoffset: 0; } }
.bc-row.has-content .bc-signhere { opacity: 0; }

/* ── Buttons (page chrome): the close chip's language, with words ── */
.bc-actions {
  display: flex;
  gap: var(--space-2);
  opacity: 0;
  translate: 0 var(--space-2);
  transition: opacity 180ms var(--ease-out-quart), translate 240ms var(--ease-out-quart);
}
.bc[data-state="out"] .bc-actions {
  opacity: 1;
  translate: 0 0;
  transition-duration: 260ms, 520ms;
  transition-timing-function: var(--ease-out-quart), var(--ease-out-expo);
}
.bc-btn {
  padding: 7px 12px;
  border: 1px solid transparent;
  border-radius: var(--radius-control);
  background: transparent;
  color: var(--ink-3);
  font-family: var(--font-primary);
  font-weight: 500;
  font-size: var(--t-micro);
  letter-spacing: 0.16em;
  line-height: 1;
  text-transform: uppercase;
  cursor: pointer;
  transition:
    background 160ms var(--ease-out-quart),
    color 160ms var(--ease-out-quart),
    border-color 160ms var(--ease-out-quart),
    opacity 160ms var(--ease-out-quart),
    transform 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
}
/* Done is the one that matters: a hairline, not a slab of ink. */
.bc-btn--ink { color: var(--ink-1); border-color: color-mix(in oklab, var(--ink-1) 18%, transparent); }
@media (hover: hover) and (pointer: fine) {
  .bc-btn:not(:disabled):hover { color: var(--ink-1); background: color-mix(in oklab, var(--ink-1) 5%, transparent); }
  .bc-btn--ink:not(:disabled):hover { border-color: color-mix(in oklab, var(--ink-1) 32%, transparent); }
}
.bc-btn:active:not(:disabled) { transform: scale(0.96); }
.bc-btn:disabled { opacity: 0.35; cursor: default; }
.bc-actions { align-items: center; }
/* Redo and Done: the close chip's siblings (.case-close), an undo arrow
   and a tick for its x. */
.bc-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 0;
  border-radius: var(--radius-control);
  background: var(--surface-tint);   /* solid, not a wash: the chips sit over paper and the veil, Oct 2026 */
  color: var(--ink-3);
  cursor: pointer;
  transition: background 160ms var(--ease-out-quart), color 160ms var(--ease-out-quart), transform 160ms var(--ease-out-quart);
  -webkit-tap-highlight-color: transparent;
}
.bc-chip path { stroke-dasharray: 1; stroke-dashoffset: 0; }
.bc-chip:active:not(:disabled) { transform: scale(0.9); }
.bc-chip:disabled { opacity: 0.35; cursor: default; }
.bc-chip:focus-visible { outline: 2px solid var(--ink-stamp); outline-offset: 2px; }
@media (hover: hover) and (pointer: fine) {
  .bc-chip:not(:disabled):hover { background: color-mix(in srgb, var(--surface-tint), var(--ink-1) 6%); color: var(--ink-1); }
  .bc-chip:not(:disabled):hover path { animation: close-redraw 480ms var(--ease-out-expo) backwards; }
  .bc-chip:not(:disabled):hover path + path { animation-delay: 70ms; }
}
@media (prefers-reduced-motion: reduce) { .bc-chip path { animation: none !important; } }
.bc-btn:focus-visible { outline: 2px solid var(--ink-stamp); outline-offset: 2px; }

/* ── The stamp in hand ──
   A see-through preview of the exact impression, the size of the date
   box, floating over the page. It hovers a little large with a loose
   shadow; snapped onto your line it darkens and squares up; pressed,
   it shrinks onto the paper as the shadow tightens under it; then it
   lifts away and fades. Position and lean are driven per frame by
   js/borrow-card.js; everything else is these classes. */
.bc.is-stamping .bc-stage,
.bc.is-stamping .bc-stage * { cursor: none !important; }
@media (hover: none), (pointer: coarse) {
  .bc.is-stamping .bc-stage,
  .bc.is-stamping .bc-stage * { cursor: auto !important; }
}
.bc-ghost {
  position: fixed;
  left: 0; top: 0;
  z-index: 60;
  pointer-events: none;
  opacity: 0;
  transition: opacity 180ms var(--ease-out-quart);
}
.bc-ghost.is-on { opacity: 1; }
.bc-ghost.is-on.is-away { opacity: 0; }
.bc-ghost__shadow {
  position: absolute;
  inset: 12% 6%;
  border-radius: 6px;
  background: color-mix(in srgb, var(--paper-shadow) 22%, transparent);
  filter: blur(8px);
  translate: 8px 14px;
  opacity: 0.6;
  transition: translate 160ms var(--ease-out-quart), filter 160ms var(--ease-out-quart), opacity 160ms var(--ease-out-quart);
}
.bc-ghost__body {
  position: absolute;
  inset: 0;
  border-radius: 4px;
  background: color-mix(in srgb, var(--paper-card) 30%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--paper-ink) 14%, transparent);
  scale: 1.07;
  transition: scale 160ms var(--ease-out-quart), box-shadow 160ms var(--ease-out-quart), translate 300ms var(--ease-out-expo);
}
.bc-ghost__body svg { position: absolute; left: 3%; top: 0; width: 94%; height: 100%; overflow: visible; }
.bc-ghost__body text {
  font-family: var(--font-primary);
  font-weight: 700;
  font-size: 19px;
  letter-spacing: 0.02em;
  fill: var(--paper-ink);
  opacity: 0.26;
  transition: opacity 160ms var(--ease-out-quart);
}
.bc-ghost.is-snapped .bc-ghost__body { scale: 1.04; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--paper-ink) 22%, transparent); }
.bc-ghost.is-snapped text { opacity: 0.5; }
.bc-ghost.is-snapped .bc-ghost__shadow { translate: 5px 9px; filter: blur(6px); }
/* Touch: it waits over the box, breathing, until the tap. */
.bc-ghost.is-waiting .bc-ghost__body { animation: bc-hover 1.4s var(--ease-out-soft) infinite alternate; }
@keyframes bc-hover { to { scale: 1.01; } }
.bc-ghost.is-pressing .bc-ghost__body { scale: 0.97; transition-duration: 110ms; }
.bc-ghost.is-pressing .bc-ghost__shadow { translate: 1px 2px; filter: blur(2px); opacity: 0.9; transition-duration: 110ms; }
.bc-ghost.is-lifting { opacity: 0; transition: opacity 300ms var(--ease-out-quart); }
.bc-ghost.is-lifting .bc-ghost__body { scale: 1.08; translate: 0 -6px; }
.bc-ghost.is-lifting .bc-ghost__shadow { translate: 10px 18px; filter: blur(10px); opacity: 0; }
/* Not here: a small shake, like a hand correcting its aim. */
.bc-ghost.is-nudge .bc-ghost__body { animation: bc-nudge 300ms var(--ease-out-quart); }
@keyframes bc-nudge {
  25% { translate: -3px 0; }
  55% { translate: 3px 0; }
  80% { translate: -1px 0; }
}

.bc-hint {
  margin: 0;
  min-height: 1.5em;
  font-family: var(--font-body);
  font-size: var(--t-small);
  color: var(--ink-4);
  text-align: center;
}

/* E-ink has no depth: paper separates by value alone. */
html[data-theme="eink"] .bc-pocket--front,
html[data-theme="eink"] .bc-pocket--back,
html[data-theme="eink"] .bc-card::before,
html[data-theme="eink"] .bc-card::after,
html[data-theme="eink"] .bc-ghost__shadow,
html[data-theme="eink"] .bc-card__paper { box-shadow: none; }

@media (prefers-reduced-motion: reduce) {
  .bc-card__paper,
  .bc:not(.has-touched)[data-state="idle"] .bc-card__paper { animation: none; transition: none; }
  .bc-x path { stroke-dashoffset: 0; animation: none; }
  .bc-stamp { transition: none; }
  .bc-actions { translate: none; }
  .bc-card::before, .bc-card::after { transform: none; }
  .bc-ghost__body, .bc-ghost__shadow { transition: none; animation: none !important; }
}

/* ── Homepage hero (Sept 2026): a sentence with objects in it ──
   One display sentence in --font-primary, sentence case, with two real
   objects set into the line where words would go: the bread clip after
   the name (js/bread-clip.js at text size) and, bigger, a little library
   pocket closing the sentence, which grows into the full borrow card on
   the desk (js/hero.js). Both are sized in em, so they keep the line's
   rhythm at every width. The tail of the sentence greys off. */
.hero { padding: clamp(56px, 11vh, 128px) 0 clamp(40px, 7vh, 88px); }   /* more air above and below, Oct 2026 */
/* Bigger, Oct 2026 (review feedback: "make it way bigger and move stuff
   down"): on wider screens the sentence owns the first screen, centred
   in it, and the bio and work start below the fold line. */
@media (min-width: 900px) {
  .hero {
    min-height: 68svh;   /* leaves the bio peeking above the fold */
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 0;
  }
}
.hero__text {
  max-width: none;
  /* Headroom (padding handed back by the margin) so the arrival's mask,
     which clips to this box, never shaves the clip standing proud of
     the first line. */
  margin: -0.4em auto 0;
  padding: 0.4em 0 0.3em;
  font-family: var(--font-primary);
  font-size: var(--t-hero);
  font-weight: 500;
  line-height: 1.16;
  letter-spacing: -0.012em;
  color: var(--ink-1);
  text-align: center;
  text-wrap: balance;
}
.hero__tail { color: var(--ink-4); }

/* The bread clip, at text size, set like a glyph: a cap-height slot on
   the baseline holds its place in the line, and the clip's 3D stage
   floats centred on that slot (absolutely, so it can never stretch the
   line box). The clip itself is ~1.32em tall, a little more than the
   line, so it reads as an object set into the sentence rather than a
   glyph, still centred on the letters. */
/* Sized on .hero__clip itself, not .hero__clip.clip: js/bread-clip.js
   adds .clip when it mounts, and keying the size on that left the slot
   empty on first paint, so the line jumped when the clip arrived. */
.hero__clip,
.hero__clip.clip {
  --clip-w: 1.2em;                 /* a touch under the words (1.36em until Oct 2026) */
  position: relative;
  display: inline-block;
  width: calc(var(--clip-w) * 1.04);
  height: 0.64em;
  margin: 0 0.12em 0 0.1em;
  vertical-align: baseline;
  cursor: zoom-in;
}
.hero__clip .clip__stage {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
}
.hero__clip.is-away { visibility: hidden; }
.hero__clip .clip__hint { display: none; }

/* The library card, set between the words: on wider screens the first
   two lines run whole and the last two split around the card's column
   (left words right-aligned to it, right words left-aligned from it).
   The left words are a single word each ("designer / products") and the
   right ones long, so the card lands left of centre, and the block
   reads loose and placed rather than symmetrical. Phones flatten the
   grid back into one flowing paragraph. */
.hero__line { display: block; white-space: nowrap; }
/* The objects keep their own scale when the sentence's type grows. */
.hero__clip, .hero .sticker { font-size: var(--t-hero-obj); }
.hero__split {
  display: grid;
  grid-template-columns: auto auto auto;
  justify-content: center;
  grid-template-rows: auto auto;
  column-gap: 0.26em;
  align-items: baseline;
}
.hero__seg { white-space: nowrap; }
.hero__seg--l1 { grid-area: 1 / 1; justify-self: end; }
.hero__seg--r1 { grid-area: 1 / 3; justify-self: start; }
.hero__seg--l2 { grid-area: 2 / 1; justify-self: end; }
.hero__seg--r2 { grid-area: 2 / 3; justify-self: start; }
.hero__card {
  /* A little taller than the two lines it stands in (2.9em against
     2 x 1.16em), so the print and signatures still read; the negative
     block margins hand the overhang back, so the rows keep their leading
     and the card just stands proud of them, top and bottom. */
  --m: calc(2.9em / 1.52);
  grid-area: 1 / 2 / 3 / 3;
  align-self: center;
  position: relative;
  width: var(--m);
  height: calc(var(--m) * 1.52);
  margin: calc((2.32em - 2.9em) / 2) 0.06em;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;                   /* buttons don't inherit size; the em math needs the sentence's */
  font-size: var(--t-hero-obj);    /* the objects keep their scale when the type grows */
  cursor: pointer;
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
  transition: scale 160ms var(--ease-out-quart);
}
@media (max-width: 899px) {
  /* Phones (Oct 2026): the desktop's own set, scaled down. The two whole
     lines, then the two split around the library card, exactly as on a
     wide screen; the type steps down until the widest row (the split,
     about 14.7em with the card) fits the screen, and the objects keep
     their desktop proportion to the words (0.95em) instead of their
     own floor. */
  .hero { padding: 96px 0 var(--space-7); }
  /* Lighter on phones (Oct 2026): at phone size the 500 weight sat
     heavy on a narrow screen. */
  .hero__text {
    font-size: min(var(--t-hero), calc((100vw - 2 * var(--space-5)) / 15));
    font-weight: 400;
  }
  .hero__clip, .hero .sticker, .hero__card { font-size: 0.95em; }
  /* The sticker's print sits at its floor here and outgrew the words,
     standing the label two lines taller than "LA-based": let it step a
     little under the floor so the label stays close to the word. */
  .hero .sticker__print { font-size: max(calc(var(--t-micro) * 0.85), 0.22em); }
}
.hero__card:active { scale: 0.97; }
.hero__card:focus, .hero__card:focus-visible { outline: none; }   /* no ring on the hero objects, on request */
.hero__card.is-away { visibility: hidden; }
.hero__card > .hero__real { position: absolute; left: 0; top: 0; pointer-events: none; }
/* The real card, at text size: a copy of the desk card's stage, laid out
   at the desk card's own width (--bc-desk-w) and shrunk with one scale
   (--mini-s), both set by js/hero.js. So the copy is the desk card's
   exact render in miniature: same type floors, same px hairlines and
   shadows, same everything, and the FLIP between them has nothing to
   change but size. No headroom: the copy is exactly the stage. The
   .bc's own idle tug (data-state="idle") still runs on the copy. */
.hero__real.bc {
  --bc-w: var(--bc-desk-w, 340px);
  --bc-headroom: 0;
  display: block;
  gap: 0;
  width: var(--bc-w);
  transform-origin: 0 0;
  scale: var(--mini-s, 0.25);
  /* The page's base type, the same the desk card inherits: inside the h1
     the copy would otherwise inherit the display sentence (56px narrow,
     1.16, centred, balanced), and everything in the card sized against
     the text (the pocket's print, the name) lands somewhere else. */
  font-family: var(--font-body);
  font-size: 16px;
  font-weight: 400;
  line-height: 1.5;
  text-align: start;
  text-wrap: wrap;
}
.hero__real .bc-stage { margin: 0; }
.hero__real .bc-card { cursor: inherit; }
@media (hover: hover) and (pointer: fine) {
  .hero__card:hover .hero__real .bc-card__paper { translate: 0 calc(var(--bc-w) * -0.05); animation: none; }
}

/* Arrival (Oct 2026, fourth pass): one soft wipe. The sentence comes
   up through a feathered mask that sweeps down and across the block
   like ink drying on the page, while it settles a few px and sharpens
   from a light blur. One motion for the whole block (per-line blur and
   per-line slot reveals were tried and cut as showy). The clip and the
   card have no entrance of their own: they ride the same wipe as the
   words, so no object slot ever sits empty on first load. Fill is
   backwards, so nothing stays held afterwards (the card's press
   scale and the desk FLIP's translate are free), and the mask lives
   only in the keyframes, so at rest nothing clips the sticker's peel or
   the clip's 3D overhang. The mask reads only alpha; --ink-1 is just opaque. */
.hero__text { animation: hero-wipe 1300ms var(--ease-out-quart) 80ms backwards; }
@keyframes hero-wipe {
  from {
    -webkit-mask-image: linear-gradient(170deg, var(--ink-1) 40%, transparent 60%);
            mask-image: linear-gradient(170deg, var(--ink-1) 40%, transparent 60%);
    -webkit-mask-size: 100% 300%;
            mask-size: 100% 300%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-position: 0 100%;
            mask-position: 0 100%;
    translate: 0 0.12em;
    filter: blur(6px);
  }
  to {
    -webkit-mask-image: linear-gradient(170deg, var(--ink-1) 40%, transparent 60%);
            mask-image: linear-gradient(170deg, var(--ink-1) 40%, transparent 60%);
    -webkit-mask-size: 100% 300%;
            mask-size: 100% 300%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-position: 0 0;
            mask-position: 0 0;
    translate: 0 0;
    filter: blur(0);
  }
}
html.is-return .hero__text { animation: none; }

/* Objects pop in, words make room (Oct 2026, after Mobbin's hero): the
   sentence arrives with its object slots shut, so the words sit closed
   up; then each slot opens and the words slide apart to make room while
   the object springs in (a small overshoot built from keyframes, so the
   easing stays a token). The slots shut with NEGATIVE inline margins
   equal to the object's width, never by animating width: js/hero.js
   reads the card's computed width to fit the mini card. Clip first,
   then the card. The sticker (tape) keeps its own entrance. */
@keyframes hero-pop {
  0%   { scale: 0; opacity: 0; }
  55%  { scale: 1.1; opacity: 1; }
  78%  { scale: 0.97; }
  100% { scale: 1; opacity: 1; }
}
@keyframes hero-clip-slot {
  from { margin-left: calc(var(--clip-w) * -0.52); margin-right: calc(var(--clip-w) * -0.52); }
}
@keyframes hero-card-slot {
  from { margin-left: calc(var(--m) / -2); margin-right: calc(var(--m) / -2); }
}
@media (prefers-reduced-motion: no-preference) {
  html:not(.is-return) .hero__clip {
    animation:
      hero-clip-slot 700ms var(--ease-out-expo) 520ms backwards,
      hero-pop 620ms var(--ease-out-quart) 600ms backwards;
  }
  html:not(.is-return) .hero__card { animation: hero-pop 620ms var(--ease-out-quart) 820ms backwards; }
}
/* Phones (Oct 2026): the hero hands over to the bio sooner, and the
   arrival is quicker: a 1.3s wipe left a phone screen blank too long, so
   it starts at once and runs 700ms, and the clip and card pop as it
   lands. */
@media (max-width: 899px) {
  .hero { padding-bottom: var(--space-5); }
}
@media (prefers-reduced-motion: no-preference) and (max-width: 899px) {
  .hero__text { animation-duration: 700ms; animation-delay: 0ms; }
  html:not(.is-return) .hero__clip {
    animation:
      hero-clip-slot 600ms var(--ease-out-expo) 260ms backwards,
      hero-pop 560ms var(--ease-out-quart) 320ms backwards;
  }
  html:not(.is-return) .hero__card { animation: hero-pop 560ms var(--ease-out-quart) 460ms backwards; }
}
@media (prefers-reduced-motion: no-preference) and (min-width: 900px) {
  html:not(.is-return) .hero__card {
    animation:
      hero-card-slot 760ms var(--ease-out-expo) 740ms backwards,
      hero-pop 620ms var(--ease-out-quart) 820ms backwards;
  }
}

/* ── The handheld, small, in the homepage's top line (Oct 2026) ──
   Pictures of the console (images/handheld/mini-off.webp, the screen
   dark, and mini.webp, the screen on; their own baked corners) that open
   the scene over the page: the overlay below grows out of it and shrinks
   back into it, so it is hidden while the scene is up (.is-away), like
   the library card in the sentence. At rest the screen is off; a hover
   or keyboard focus wakes it, the screen-on picture fading up over the
   dark one, and it goes dark again when the pointer leaves. The line
   itself lets clicks through; only the console takes them. */
.page-play { height: 36px; top: 36px; }
.page-play__btn {
  pointer-events: auto;
  position: relative;
  display: block;
  width: 82px;
  height: 36px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: scale 160ms var(--ease-out-quart);
}
.page-play__img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
.page-play__img--on { opacity: 0; transition: opacity 320ms var(--ease-out-quart); }
.page-play__btn:focus-visible .page-play__img--on,
.page-play__btn.is-away .page-play__img--on { opacity: 1; transition-duration: 160ms; }   /* it grows out awake */
@media (hover: hover) and (pointer: fine) {
  .page-play__btn:hover .page-play__img--on { opacity: 1; transition-duration: 160ms; }
}
.page-play__btn:active { scale: 0.97; }
.page-play__btn:focus, .page-play__btn:focus-visible { outline: none; }   /* like the hero objects */
.page-play__btn.is-away { visibility: hidden; }
@media (prefers-reduced-motion: reduce) {
  .page-play__btn, .page-play__img--on { transition: none; }
}

/* ── The handheld, over the page (js/toolbar.js, from the dock's Play) ──
   The same paper veil the library card sits on, with the scene in a
   frame over it (handheld.html?embed, which paints no page of its own).
   Under the dock (400), so the Play button it grows out of stays put. */
.play-desk { position: fixed; inset: 0; z-index: 390; }
.play-desk[hidden] { display: none; }
.play-desk__veil {
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--surface-base) 92%, transparent);
  opacity: 0;
  transition: opacity 420ms var(--ease-out-quart);
}
.play-desk.is-open .play-desk__veil { opacity: 1; }
.play-desk__frame {
  position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: transparent;
  /* the scene's page is light: a frame whose scheme differs from its page
     gets an opaque white backing, so match it and stay see-through in dark */
  color-scheme: light;
}
.play-desk.is-waiting .play-desk__frame { visibility: hidden; }
/* while the scene builds: a small ring on the veil, where the console will be */
.play-desk__wait {
  position: absolute; left: 50%; top: 50%; width: 22px; height: 22px; margin: -11px 0 0 -11px;
  border-radius: 50%; border: 2px solid color-mix(in srgb, var(--ink-1) 14%, transparent); border-top-color: var(--ink-2);
  opacity: 0; transition: opacity 240ms var(--ease-out-quart);
}
.play-desk.is-waiting .play-desk__wait { opacity: 1; transition-delay: 160ms; animation: play-wait 900ms linear infinite; }
@keyframes play-wait { to { transform: rotate(1turn); } }
/* the page's close chip: it drops in from above the screen as the overlay
   opens and goes back up as it closes, apart from the console's grow */
.play-desk__close {
  position: fixed; top: var(--space-5); left: var(--space-5); margin: 0; z-index: 1;
  transform: translateY(calc(-100% - var(--space-5) - 8px));
  transition: transform 320ms var(--ease-out-quart), background 160ms var(--ease-out-quart), color 160ms var(--ease-out-quart);
}
.play-desk.is-open .play-desk__close { transform: none; transition: transform 560ms var(--ease-out-expo) 220ms, background 160ms var(--ease-out-quart), color 160ms var(--ease-out-quart); }
@media (prefers-reduced-motion: reduce) {
  .play-desk__veil { transition: none; }
  .play-desk.is-waiting .play-desk__wait { animation-duration: 2.4s; }
  .play-desk__close, .play-desk.is-open .play-desk__close { transition: none; }
}

/* ── The desk: the full borrow card, grown out of the sentence ── */
.hero-desk {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
}
.hero-desk[hidden] { display: none; }
.hero-desk__veil {
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--surface-base) 92%, transparent);
  opacity: 0;
  transition: opacity 420ms var(--ease-out-quart);
}
.hero-desk.is-open .hero-desk__veil { opacity: 1; }
.hero-desk__card { position: relative; }
/* Centred as a group: the chips and the caption hang below the stage
   (absolute, below), so the stage is lifted by half of that overhang
   (gap + 28px chip + gap + one caption line) and the whole set, card,
   pocket, chips and caption, sits in the middle of the screen. */
.hero-desk .bc {
  --bc-w: min(380px, calc(100vw - 48px), 44svh);
  --bc-headroom: 0;
  /* An offset, never a transform: the stamp in hand (.bc-ghost) is
     position: fixed inside this .bc, and any transform here would make
     the .bc its containing block, so the stamp would draw away from the
     pointer and miss the date box. */
  position: relative;
  top: calc((var(--space-5) + 28px + var(--space-4) + 30px) / -2);
}
.hero-desk .bc-stage { transform-origin: 50% 50%; }
.hero-desk .bc-actions {
  position: absolute;
  left: 0; right: 0;
  top: calc(100% + var(--space-5));
  justify-content: center;
}
.hero-desk .bc-hint {
  position: absolute;
  left: 50%;
  translate: -50% 0;
  top: calc(100% + var(--space-5) + 28px + var(--space-4));
  white-space: nowrap;
}
/* ── The bread clip, zoomed out of the line ── */
.hero-zoom {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  align-items: center;
  justify-content: center;
}
.hero-zoom[hidden] { display: none; }
/* The same 92% paper veil as the library card's desk, so both objects
   come up out of the sentence onto the same ground. */
.hero-zoom__veil {
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--surface-base) 92%, transparent);
  opacity: 0;
  transition: opacity 420ms var(--ease-out-quart);
  cursor: zoom-out;
}
.hero-zoom.is-open .hero-zoom__veil { opacity: 1; }
.hero-zoom__clip { position: relative; }
.hero-zoom__clip .clip__float { transform-origin: 50% 50%; }
/* Its caption arrives after the zoom has landed. */
.hero-zoom__clip .clip__hint { opacity: 0; transition: opacity 260ms var(--ease-out-quart); }
.hero-zoom.is-open .hero-zoom__clip .clip__hint { opacity: 1; transition-delay: 520ms; }

/* The dock steps aside while a card is out on the desk. */
body.is-borrowing .tbar { opacity: 0; pointer-events: none; }
@media (max-width: 719px) {
  /* Phones: a card big enough to sign with a finger (rows ~31px, the
     pen pad ~60px). */
  .bc { --bc-w: min(320px, calc(100vw - 48px)); }
}
/* The captions live in the homepage's top line (the borrow card moves
   its hint into [data-bc-hint]) and ride onto the desk while it's open:
   body type, sentence case, the caption's own voice. */
.page-locale--hint {
  font-family: var(--font-body);
  font-size: var(--t-home);   /* the homepage's reading size, like the bio, work and info below */
  font-weight: 400;
  letter-spacing: normal;
  text-transform: none;
}
@media (max-width: 719px) {
  /* Clear the sound chip on the right. */
  /* Centred like the desktop line; the sound chip sits clear of it. */
  .page-locale--hint { justify-content: center; padding: 0 var(--space-4); text-align: center; }
}

/* ── Play offer (js/play-offer.js, markup PortfolioUI.playOffer) ──
   The top line is a little game menu, "Open to ▸Work and Play", the way
   the handheld's own menus read. The whole line fades in with the hero
   sentence. Once the sentence has settled, the arrow appears beside
   Work and the room for it opens as it comes (is-ready), then it bobs
   twice (is-bobbing). The current option is black ink with the arrow;
   the other is the line's grey. Choosing moves the arrow: the old option's room
   closes as the new one's opens. While the handheld loads, the arrow
   blinks (is-waiting). Both options are the line's own type, each
   with a dotted underline in its own colour so both read as pressable (Oct 2026:
   without it, Play in the line's grey read as the end of the sentence). */
/* The line's plain "Open to work" (the no-script text) never shows: the
   menu replaces it before it would paint, and fades in itself. A
   <noscript> rule in index.html brings it back without script. */
.page-locale--hint[data-play-offer]:not(:has(.offer)) { visibility: hidden; }
.offer {
  --offer-on: var(--ink-1);    /* the current option, and the arrow: black ink, not the accent (Oct 2026) */
  --offer-dim: var(--ink-4);   /* the other: the line's own grey */
  --offer-gutter: 0.8em;   /* the arrow's room */
  white-space: nowrap;
  animation: offer-in 700ms var(--ease-out-quart) 520ms backwards;   /* with the sentence's wipe */
}
@keyframes offer-in { from { opacity: 0; translate: 0 4px; } }
.offer__and { margin: 0 0.28em; }
.offer__opt {
  position: relative;
  margin: -6px 0;   /* hit area, given back so the line keeps its height */
  padding: 6px 0;
  border: 0;
  background: none;
  font: inherit;
  color: var(--offer-dim);
  text-decoration: underline dotted 1px;   /* currentColor, so it follows the option's ink */
  text-underline-offset: 3px;
  cursor: pointer;
  pointer-events: auto;
  -webkit-tap-highlight-color: transparent;
  transition: color 240ms var(--ease-out-quart), padding-left 380ms var(--ease-out-expo), scale 160ms var(--ease-out-quart);
}
.offer__opt[aria-checked="true"] { color: var(--offer-on); cursor: default; }
.offer.is-ready .offer__opt[aria-checked="true"] { padding-left: var(--offer-gutter); }
@media (hover: hover) and (pointer: fine) {
  .offer__opt:hover { color: var(--offer-on); }
}
.offer__opt:focus, .offer__opt:focus-visible { outline: none; }   /* no ring, on request: the arrow already marks where you are */
.offer__opt[aria-checked="false"]:active { scale: 0.97; }

/* The arrow: one small solid triangle in each option's room, shown with
   the room. It comes in from a hair to the left, as if slid there. */
.offer__opt::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  width: 0.4em;
  height: 0.5em;
  margin-top: -0.29em;
  background: var(--offer-on);
  clip-path: polygon(0 0, 100% 50%, 0 100%);
  opacity: 0;
  translate: -0.3em 0;
  transition: opacity 160ms var(--ease-out-quart), translate 380ms var(--ease-out-expo);
  pointer-events: none;
}
.offer.is-ready .offer__opt[aria-checked="true"]::before {
  opacity: 1;
  translate: 0.08em 0;
  transition: opacity 240ms var(--ease-out-quart) 80ms, translate 420ms var(--ease-out-expo) 80ms;
}
.offer.is-bobbing .offer__opt[aria-checked="true"]::before { animation: offer-bob 1000ms var(--ease-out-quart); }
@keyframes offer-bob {
  0%, 50%, 100% { transform: none; }
  25%, 75%      { transform: translateX(0.22em); }
}
/* loading the handheld: the arrow blinks, as a game's does while it waits */
.offer.is-waiting .offer__opt[aria-checked="true"]::before { animation: offer-wait 640ms steps(1) infinite; }
@keyframes offer-wait { 50% { opacity: 0; } }

/* Already there (a 'set' greeting): nothing plays on the first frame. */
.offer.is-instant,
.offer.is-instant *,
.offer.is-instant *::before { transition: none !important; }
@media (prefers-reduced-motion: reduce) {
  .offer, .offer *, .offer *::before { transition: none !important; animation: none !important; }
}

/* ── The handheld in the hero's place (js/play-offer.js) ──
   Not an overlay: a frame of the scene (handheld.html?embed, inline mode)
   laid over the hero section, so the page around it scrolls and works as
   always. The width of the screen, so a phone's console can run off its
   edges as on the scene's own page; at least as tall as the console's
   stage (--hp-h), centred on the hero. The sentence fades out under it
   and comes back as the console shrinks into the top line. */
.hero { position: relative; }
/* the top line stays over the hero (on phones the hero's padding reaches
   up under it) and over the frame, so its links always take the tap */
.page-locale--hint { z-index: 3; }
.hero-play {
  position: absolute;
  top: var(--hp-cy, 50%);   /* the sentence's middle (js/play-offer.js) */
  left: 50%;
  z-index: 2;
  width: 100vw;
  height: var(--hp-h, 100%);   /* the console's stage, shadow included */
  translate: -50% -50%;
  pointer-events: none;
}
.hero-play[hidden] { display: none; }
.hero-play__frame {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  background: transparent;
  color-scheme: light;   /* the scene's page is light: match it, or the frame gets an opaque backing */
  pointer-events: auto;
}
.hero__text { transition: opacity 360ms var(--ease-out-quart) 160ms, filter 360ms var(--ease-out-quart) 160ms, scale 360ms var(--ease-out-quart) 160ms; }
.hero.is-playing .hero__text {
  opacity: 0;
  filter: blur(4px);
  scale: 0.98;
  pointer-events: none;
  transition-duration: 280ms;
  transition-delay: 0ms;
}
@media (prefers-reduced-motion: reduce) {
  .hero__text, .hero.is-playing .hero__text { transition: opacity 200ms linear; filter: none; scale: none; }
}
@media (prefers-reduced-motion: reduce) {
  .hero__text { animation: none; }
  .hero-desk__veil { transition: none; }
}

/* ── The bio, ruled like the card (Sept 2026) ──
   No plate and no "View more" (the letter is retired): the whole short
   bio sits open, its entries parted by the same 11% hairlines as the
   Info rows beside it, so both sides of the card read as one ruled
   index. */
.overview-box { background: none; padding: 0; }

.overview-about { margin-top: 0; padding-top: 4px; }
/* Each rail heading sits close over its first line (Oct 2026): a 6px
   gap, plus 4px of lead on the first line, so Bio, Work and Info all
   start their words ~10px under the label. */
.rail__grid .rail__section { gap: 6px; }
.rail__grid .contact-row:first-child,
.rail__grid .work__row:first-child { padding-top: 4px; }
.overview-about p { font-size: var(--t-home); line-height: 1.55; color: var(--ink-3); }

/* ── Review page (borrow-admin.html), internal ── */
.bca { max-width: 760px; margin: 0 auto; padding: var(--space-7) var(--space-4); display: flex; flex-direction: column; gap: var(--space-6); }
.bca__title { font-family: var(--font-primary); font-size: var(--t-card-title); font-weight: 600; margin-top: var(--space-1); }
.bca__key { display: flex; gap: var(--space-2); margin-top: var(--space-4); }
.bca__key[hidden] { display: none; }
.bca__key input { flex: 1; max-width: 280px; padding: 9px 12px; border: 1px solid color-mix(in oklab, var(--ink-1) 20%, transparent); border-radius: var(--radius-control); background: var(--surface-base); color: var(--ink-1); font: 400 16px var(--font-body); }
.bca__note { margin-top: var(--space-2); font-size: var(--t-small); color: var(--ink-3); min-height: 1.5em; }
.bca__list { margin-top: var(--space-3); display: flex; flex-direction: column; }
.bca__row { display: grid; grid-template-columns: 64px 64px 1fr auto; grid-template-rows: auto auto; column-gap: var(--space-4); align-items: center; padding: var(--space-4) var(--space-3); border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 11%, transparent); background: var(--paper-card); color: var(--paper-ink); }
.bca__no, .bca__due { font-family: var(--font-primary); font-weight: 600; font-size: var(--t-small); letter-spacing: 0.06em; }
.bca__ink { position: relative; display: block; height: 34px; }
.bca__typed { position: static; translate: none; font-size: 15px; }
.bca__meta { grid-column: 3; font-size: var(--t-micro); color: var(--ink-4); margin-top: var(--space-1); }
.bca__acts { grid-column: 4; grid-row: 1 / span 2; display: flex; gap: var(--space-2); }
.bca__row--log { padding-block: var(--space-3); }
.bca__empty { padding: var(--space-4) 0; font-size: var(--t-small); color: var(--ink-4); }

/* ── Lab page (borrow.html) ── */
.bc-lab__main {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: var(--space-5) var(--space-4) var(--space-7);
}
.bc-lab__tag { position: fixed; top: var(--space-4); left: var(--space-4); margin: 0; }

/* ── The component library (lab.html, Oct 2026) and its lab pages ──
   The index is the homepage's project grid, card for card (.projects,
   .card), so the objects are presented exactly like the work. Each lab
   page links back with its corner tag. */
.lab__main { padding: var(--space-8) var(--pad-page-x) var(--space-8); }
.lab__head { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-6); max-width: 34em; }
.lab__title {
  font-family: var(--font-primary);
  font-size: var(--t-hero);
  font-weight: 500;
  line-height: 1.16;
  letter-spacing: -0.012em;
  color: var(--ink-1);
  text-wrap: balance;
}
.lab-back { text-decoration: none; color: var(--ink-3); }
@media (hover: hover) and (pointer: fine) { .lab-back:hover { color: var(--accent-blue); } }
/* A lab page sets its object in the hero's own sentence voice. */
.lab-sentence {
  font-family: var(--font-primary);
  font-size: var(--t-hero);
  font-weight: 500;
  line-height: 1.16;
  letter-spacing: -0.012em;
  color: var(--ink-1);
  text-align: center;
}
.lab-hint { margin-top: var(--space-5); font-size: var(--t-small); color: var(--ink-4); text-align: center; }
.lab-actions { display: flex; justify-content: center; margin-top: var(--space-5); }
.lab-btn { cursor: pointer; background: transparent; }
.lab-btn:disabled { opacity: 0.4; cursor: default; }
.lab-plate { display: flex; flex-direction: column; gap: 6px; width: min(420px, 100%); }
@media (max-width: 719px) { .lab__main { padding: var(--space-7) var(--space-4); } }

/* ════════════════════════════════════════════════════════════════
   BREAD CLIP (Sept 2026, lab page: bread-clip.html). A plastic
   bread-bag clip you can turn over. Markup: renderBreadClip in
   js/components.js. Behaviour: js/bread-clip.js. See DESIGN.md.

   The depth is real CSS 3D: the clip's outline is stacked in ten SVG
   slices along Z, so turning it shows a solid edge instead of a card
   with no thickness. The top slice carries the print and a sheen that
   slides with the angle; the bottom one carries the moulded mark.
   ════════════════════════════════════════════════════════════════ */
.clip {
  /* The borrow card's --bc-w: objects in the library share one scale. */
  --clip-w: min(290px, calc(100vw - 2 * var(--space-4)), 35svh);
  --clip-t: calc(var(--clip-w) * 0.0034);   /* one slice's thickness */
  --sheen-x: 0;
  --shadow-w: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-4);
}
.clip__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.clip__stage {
  position: relative;
  width: calc(var(--clip-w) * 1.4);
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  perspective: 900px;
  cursor: grab;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}
.clip.is-held .clip__stage { cursor: grabbing; }
.clip__float {
  width: var(--clip-w);
  aspect-ratio: 200 / 182;
  transform-style: preserve-3d;
}
.clip__body {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  outline: none;
}
.clip[data-motion="land"] .clip__body { transition: transform 900ms var(--ease-out-expo); }
.clip[data-motion="lean"] .clip__body { transition: transform 500ms var(--ease-out-quart); }
/* The one-time introduction: a full turn that eases long into rest. */
.clip[data-motion="spin"] .clip__body { transition: transform 1400ms var(--ease-out-expo); }
.clip[data-motion="spin"] .clip__sheen { transition: transform 1400ms var(--ease-out-expo); }
.clip[data-motion="spin"] .clip__shade { transition: opacity 1400ms var(--ease-out-expo); }
.clip[data-motion="spin"] .clip__shadow { transition: transform 1400ms var(--ease-out-expo); }
/* No focus ring on the clip, on request (Sept 2026): it is still
   keyboard reachable and turns with Enter / arrows, but the object is
   never outlined. */
.clip__body:focus, .clip__body:focus-visible { outline: none; }
.clip__layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  transform: translateZ(calc(var(--clip-t) * var(--z)));
}
.clip__layer use { fill: var(--plastic-face); }
.clip__layer--edge use { fill: var(--clip-edge-fill, url(#bclip-edge)); }   /* per-clip id, set by js/bread-clip.js */
.clip__edge-hi  { stop-color: color-mix(in srgb, var(--plastic-edge) 70%, var(--plastic-light)); }
.clip__edge-mid { stop-color: var(--plastic-edge); }
.clip__edge-lo  { stop-color: color-mix(in srgb, var(--plastic-edge) 90%, var(--plastic-shadow)); }
.clip__layer--back { transform: translateZ(calc(var(--clip-t) * var(--z))) rotateY(180deg); }
/* The finish (see renderBreadClip): light, grain, shade, bevel. */
/* The clip's stock: its own blue, not the cream plastic it shares a
   name with (see --clip-face in :root). */
.clip {
  --plastic-face: var(--clip-face);
  --plastic-edge: var(--clip-edge);
  --plastic-ink:  var(--clip-ink);
}
.clip__grain { fill: var(--plastic-shadow); opacity: 0.16; }   /* matte tooth (Oct 2026, was 0.13) */
.clip__shade {
  fill: var(--plastic-shadow);
  opacity: var(--face-shade, 0);
}
.clip .clip__bevel { fill: none; stroke-width: 2.6; }
.clip .clip__bevel--hi { stroke: var(--plastic-light); opacity: 0.3; }   /* a soft rounded lip, not a glint (Oct 2026, was 0.5) */
.clip .clip__bevel--lo { stroke: var(--plastic-shadow); opacity: 0.1; }
/* Thermal print, in the borrow card's print face (Archivo Narrow),
   broken up unevenly by the #bclip-ink filter. */
.clip__print text {
  font-family: var(--font-primary);
  fill: var(--plastic-ink);
}
.clip__price { font-weight: 500; letter-spacing: -0.01em; }
/* The mark: printed in the clip's dark blue like the rest of its
   lettering (Oct 2026; it was pressed in, uninked, on the cream clip),
   still lit from above. */
.clip__mark text {
  font-family: var(--font-primary);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-anchor: middle;
  fill: var(--plastic-ink);
  stroke: var(--plastic-light);
  stroke-width: 0.5;
  paint-order: stroke;
}
.clip__stop { stop-color: var(--plastic-light); }
.clip__sheen {
  mix-blend-mode: normal;
  transform: translateX(calc(var(--sheen-x) * 1px));
}
.clip[data-motion="land"] .clip__sheen { transition: transform 900ms var(--ease-out-expo); }
.clip[data-motion="lean"] .clip__sheen { transition: transform 500ms var(--ease-out-quart); }
.clip[data-motion="land"] .clip__shade { transition: opacity 900ms var(--ease-out-expo); }
.clip[data-motion="lean"] .clip__shade { transition: opacity 500ms var(--ease-out-quart); }
.clip__shadow {
  position: absolute;
  left: 50%;
  top: 76%;
  width: calc(var(--clip-w) * 0.8);
  height: calc(var(--clip-w) * 0.08);
  border-radius: 50%;
  /* The borrow card's shadow recipe, laid on the floor: a tight contact
     (--bc-sh-card's 5 to 6%) under a long faint ambient (--bc-sh-flying's
     16%), both falling straight down. */
  background:
    radial-gradient(closest-side, color-mix(in srgb, var(--plastic-shadow) 6%, transparent), transparent 55%),
    radial-gradient(closest-side, color-mix(in srgb, var(--plastic-shadow) 16%, transparent), transparent);
  filter: blur(8px);
  transform: translateX(-50%) scaleX(var(--shadow-w));
  pointer-events: none;
}
.clip[data-motion="land"] .clip__shadow { transition: transform 900ms var(--ease-out-expo); }
.clip__hint {
  margin: 0;
  min-height: 1.5em;
  font-family: var(--font-body);
  font-size: var(--t-small);
  color: var(--ink-4);
  text-align: center;
}
@media (prefers-reduced-motion: reduce) {
  .clip[data-motion] .clip__body,
  .clip[data-motion] .clip__sheen,
  .clip[data-motion] .clip__shade,
  .clip[data-motion] .clip__shadow { transition-duration: 0ms; }
}

/* E-ink has no depth, same as the borrow card: the clip separates by
   value alone, so no sheen, bevel, shade or floor shadow. */
html[data-theme="eink"] .clip__shadow,
html[data-theme="eink"] .clip__sheen,
html[data-theme="eink"] .clip__bevel,
html[data-theme="eink"] .clip__shade,
html[data-theme="eink"] .clip__grain { display: none; }

/* ════════════════════════════════════════════════════════════════
   STICKER (Oct 2026). A plain white label stuck over a word, the
   hero's "LA-based", with "Open for relocation" printed small in its
   top corner, like an address label. It rests half peeled. Markup:
   renderSticker in js/components.js. Behaviour: js/sticker.js. See
   DESIGN.md.

   The word stays real text in the sentence; the label is a button laid
   over it (centred on it, a touch bigger), so it never moves the line.
   Two sheets of one size: the face (in flow, it sizes the label) and
   the back, which js/sticker.js clips, turns over and foreshortens
   across the fold. The stock is thin: the word ghosts through it.
   ════════════════════════════════════════════════════════════════ */
.sticker-spot { position: relative; display: inline-block; }
.sticker {
  --sticker-tilt: -1deg;     /* read by js/sticker.js: stuck down by hand, a hair off square */
  --lift: 0;                 /* how far the flap is up, 0 to 1, set by js/sticker.js */
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 1;
  display: block;
  width: max-content;
  min-width: calc(100% + 0.16em);   /* covers its word, just */
  padding: 0;
  border: 0;
  background: none;
  appearance: none;
  font: inherit;
  color: var(--sticker-ink);
  /* Centred on the word and tilted about its own centre. The origin is
     that centre, so an arrival's scale (the hero's) grows from it. */
  transform-origin: 0 0;
  transform: rotate(var(--sticker-tilt)) translate(-50%, -50%);
  cursor: grab;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
  /* Stuck flat, it barely casts: a contact line and a breath of
     ambient, both falling straight down. */
  filter: drop-shadow(0 0.5px 1px color-mix(in srgb, var(--sticker-shadow) 12%, transparent));
}
.sticker.is-held { cursor: grabbing; }
.sticker.is-stuck-side { cursor: default; }   /* the stuck half under the print doesn't peel (js/sticker.js SIDE) */
.sticker:focus, .sticker:focus-visible { outline: none; }   /* like the clip and the card; focus lifts the curl instead */
.sticker__sheet {
  display: flex;
  align-items: center;      /* the print sits in the middle of the label */
  justify-content: center;
  min-height: 1em;
  padding: 0.12em 0.16em;
  border-radius: 0.03em;
  /* The objects' light, a wash from the top, gone by 40%, over thin
     stock that lets 12% of the word under it through. */
  background:
    linear-gradient(165deg, color-mix(in srgb, var(--sticker-light) 50%, transparent), transparent 40%),
    color-mix(in srgb, var(--sticker-face) 88%, transparent);
  box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--sticker-shadow) 8%, transparent);
}
.sticker__face { position: relative; }
.sticker__lift {
  position: absolute;
  inset: 0;
  /* The flap's shadow: it stands further off the page the more of it
     is up, so the shadow drops and softens with --lift. */
  /* One shadow, not a stack: it redraws every frame of a peel. */
  filter: drop-shadow(0 calc(1px + var(--lift) * 3px) calc(1px + var(--lift) * 4px) color-mix(in srgb, var(--sticker-shadow) 16%, transparent));
}
.sticker__back {
  position: absolute;
  inset: 0;
  transform-origin: 0 0;
  background: color-mix(in srgb, var(--sticker-back) 96%, transparent);
}
.sticker__back .sticker__print { opacity: 0.09; }   /* the front's print, backwards through the stock */
.sticker__crease,
.sticker__shade {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
}
/* The small print: centred on the label, both ways, its two lines
   centred on each other (the resting curl over "LA" stops short of it,
   js/sticker.js reads its left edge), leaving the rest of
   the label bare, in the body face at the micro floor (on request: it is
   a printed detail, not reading text), sentence case,
   the way an address label is set. */
.sticker__print {
  display: flex;
  flex-direction: column;
  align-items: center;
  font-family: var(--font-body);
  font-size: max(var(--t-micro), 0.22em);
  font-weight: 400;
  line-height: 1.2;
  letter-spacing: 0;
  text-align: center;
  white-space: nowrap;
  color: color-mix(in srgb, var(--sticker-ink) 84%, transparent);
}

/* E-ink has no depth, same as the card and the clip: the flap
   separates from the face by value and hairline alone. */
html[data-theme="eink"] .sticker,
html[data-theme="eink"] .sticker__lift { filter: none; }
html[data-theme="eink"] .sticker__crease,
html[data-theme="eink"] .sticker__shade { display: none; }
/* Without shadows the label and its flap need an edge to exist at all:
   a solid hairline on both sheets, and the stock opaque so the page's
   grey doesn't wash it back toward the page. */
html[data-theme="eink"] .sticker__sheet {
  background: var(--sticker-face);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sticker-shadow) 40%, transparent);
}
html[data-theme="eink"] .sticker__back { background: var(--sticker-back); }

/* ── Work (Oct 2026): under the bio, one line per role ──
   Set in the Info rows' value text (.contact-row__value): company and
   role as one phrase on the left, the years on the right in meta ink. On two columns the two cells are the same
   height (the grid stretches them) and Work sits at the foot of its
   cell, so the bio and work block always ends level with Info. */
.work__list { list-style: none; margin: 0; padding: 0; }
.work__row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 1px 2px;   /* line by line, like a list, not a table */
}
.work__role {
  flex: 1;
  font-family: var(--font-body);
  font-size: var(--t-home);
  color: var(--ink-2);
  white-space: nowrap;
}
.work__year {
  font-family: var(--font-body);
  font-size: var(--t-home);
  color: var(--ink-4);
  white-space: nowrap;
}
@media (min-width: 900px) {
  .rail__grid { align-items: stretch; }
  .rail__cell--col1 .work { margin-top: auto; padding-top: var(--space-5); }
  /* Whichever column runs taller, the other meets it: the Info rows
     share out any spare height, so both blocks end on the same line. */
  .rail__cell--col2,
  .rail__cell--col2 .rail__cell--info { flex: 1; display: flex; flex-direction: column; }
  .rail__cell--col2 .contact-list { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
}

/* The homepage's section heads (Bio, Work, Info) and the Info labels
   (Email, LinkedIn, Resume) are set as small case titles (Oct 2026): the
   narrow face, uppercase, the titles' tight 0.04em tracking, at 500 so
   the rail stays quiet beside the work. One title style
   for everything heading-like, instead of a third, wide-tracked one. */
.rail__grid .rail__eyebrow,
.rail__grid .contact-row__label {
  font-family: var(--font-primary);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-2);
}
.rail__grid .rail__eyebrow { font-size: 13px; }
.rail__grid .contact-row__label { font-size: 12px; }
/* the Info values at the homepage's reading size, with the bio and work
   (Oct 2026); .contact-row__value elsewhere keeps --t-small */
.rail__grid .contact-row__value { font-size: var(--t-home); }

/* Case-study labels, one title style with the homepage (Oct 2026): the
   section heads, the contents list, figure and meta labels all set like
   the rail's heads, narrow, uppercase, 500, tight 0.04em tracking, 12px.
   Colours are untouched (section heads stay in the accent). Scoped to
   both case systems on purpose, one step heavier (600) than the
   homepage's (Oct 2026, on request) so the case's heads hold above its
   body text. The contents list keeps its current entry heavier still
   so you can still see where you are. */
.case .case-toc__link,
.case .case-section__heading,
.case .case-meta__row dt,
.case .cs-meta-label,
.case .cs-tl__tag,
.case .cs-persona-label,
.case .cs-landing-label,
.case .cs-shift__label,
.case .cs-wipe__side,
.case .cs-principles-flow__num,
.case .cs-desc-label,
.case .cs-mini__label,
.case .cs-snapshot__label,
.case .cs-section-label,
.case .case-pager__dir,
.cs-panel .case-toc__link,
.cs-panel .case-section__heading,
.cs-panel .case-meta__row dt,
.cs-panel .cs-meta-label,
.cs-panel .cs-tl__tag,
.cs-panel .cs-persona-label,
.cs-panel .cs-landing-label,
.cs-panel .cs-shift__label,
.cs-panel .cs-wipe__side,
.cs-panel .cs-principles-flow__num,
.cs-panel .cs-desc-label,
.cs-panel .cs-mini__label,
.cs-panel .cs-snapshot__label,
.cs-panel .cs-section-label,
.cs-panel .case-pager__dir {
  font-weight: 500;
  letter-spacing: 0.04em;
  font-size: 13px;
}
.case .case-toc__link.is-active,
.cs-panel .case-toc__link.is-active { font-weight: 600; }
/* Section heads match the homepage's Bio/Work heads: ink, never blue. */
.case .case-section__heading,
.cs-panel .case-section__heading,
.case .cs-section-label,
.cs-overlay .cs-panel .cs-section-label,
.case .cs-meta-label,
.cs-panel .cs-meta-label,
.case .cs-snapshot__label,
.cs-panel .cs-snapshot__label { color: var(--ink-2); }

/* The controls' labels join the same title style (Oct 2026): the dock's
   skip pill, the case contents' Next link and both tooltip chips. */
.tbar__skip,
.tbar__skip-label,
.case-toc__next,
.case-toc__next-arr,
.cursor-tip,
.tbar__tip { font-weight: 500; letter-spacing: 0.04em; }

/* Phones (Oct 2026): the music chip is just its waveform, a square icon
   button. The button's aria-label names it, so the hidden label costs
   nothing to a screen reader. */
@media (max-width: 719px) {
  .sound-btn { width: 36px; padding: 0; justify-content: center; }
  .sound-btn .sound-label { display: none; }
}

/* Phones (Oct 2026): Redo and Done are thumb-sized. 44px, the touch
   minimum, instead of the desktop's 28px chips; same look otherwise. */
@media (max-width: 719px) {
  .bc-chip { width: 44px; height: 44px; }
  .bc-chip svg { width: 20px; height: 20px; }
  /* The desk hangs its caption under the chips and centres the group
     with their height in the sum: 44px here, not 28. */
  .hero-desk .bc { top: calc((var(--space-5) + 44px + var(--space-4) + 30px) / -2); }
  .hero-desk .bc-hint { top: calc(100% + var(--space-5) + 44px + var(--space-4)); }
}

/* Case meta strip (Role/Team/Timeline/Category), set like the homepage
   rail (Oct 2026): labels match the Bio/Work heads, values match the
   Work rows. No accent, no top hairline. Covers both case systems. */
.case .case-meta,
.cs-panel .case-meta { border-top: 0; padding-top: 0; }
.case .case-meta__row dt,
.cs-panel .case-meta__row dt {
  font-family: var(--font-primary);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-2);
}
.case .case-meta__row dd,
.cs-panel .case-meta__row dd {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.55;
  color: var(--ink-2);
}

/* ── Popup case, split layout (Oct 2026) ────────────────────────
   ok-bye and Fairy-tale UI: a column of soft cards on the left (name + year,
   highlights, details) under the case page's close chip, and on the
   right one main image, the two text columns, then two images sharing
   a row. Same panel size as before. Cards and media stay square like
   all case content; labels follow the homepage rail. */
.cs-overlay .cs-panel--split,
html[data-theme="dark"] .cs-overlay .cs-panel--split {
  background: var(--surface-base);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: 0 30px 80px -30px color-mix(in oklab, var(--ink-1) 22%, transparent);
  border-radius: var(--radius-dock);
}
.cs-split {
  display: grid;
  grid-template-columns: 236px minmax(0, 1fr);
  height: 100%;
}
.cs-split__side {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-4) var(--space-3) var(--space-4) var(--space-4);
  overflow-y: auto;
  scrollbar-width: none;
}
.cs-split__side .case-close { margin-bottom: var(--space-2); }
.cs-card { padding: var(--space-3) 0; }
.cs-card__title {
  font-family: var(--font-primary);
  font-size: 22px;
  font-weight: 400;
  line-height: 1.2;
  letter-spacing: -0.008em;
  color: var(--ink-1);
  margin: 0 0 var(--space-4);
}
.cs-card__head,
.cs-card__rows dt {
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin: 0;
}
.cs-card__head { margin-bottom: var(--space-2); color: var(--ink-1); }
.cs-card__rows { display: flex; flex-direction: column; gap: var(--space-3); margin: 0; }
.cs-card__rows dd,
.cs-card__text {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.55;
  color: var(--ink-2);
  margin: 2px 0 0;
}
.cs-card .cs-card__title { margin: 0; }
.cs-card .case-hero__live { margin-top: var(--space-3); height: 24px; padding: 0 8px; font-size: var(--t-micro); }
.cs-card__oneliner {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink-3);
  margin: var(--space-2) 0 0;
}
.cs-card__oneliner:empty { display: none; }
/* The facts sit in one soft box; everything else is plain on the sheet. */
.cs-card--box {
  background: var(--surface-deep);
  border-radius: var(--radius-content);
  padding: var(--space-4);
}

.cs-panel--split .cs-content-wrap { height: 100%; }
.cs-panel--split .cs-content {
  display: block;
  height: 100%;
  padding: var(--space-3) var(--space-3) var(--space-5) var(--space-2);
  box-sizing: border-box;
}
.cs-split__hero,
.cs-split__media {
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-content);
  border: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent);
  overflow: hidden;
}
.cs-split__hero img,
.cs-split__media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cs-split__hero .cs-hero-placeholder,
.cs-split__media.cs-hero-placeholder { width: 100%; height: 100%; background: var(--surface-deep); }
.cs-split__text {
  display: grid;
  grid-template-columns: 5fr 7fr;
  gap: var(--space-6);
  padding: var(--space-5) var(--space-2);
}
.cs-split__row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.cs-split__row:empty { display: none; }
.cs-panel--split .cs-section-label {
  font-size: var(--t-meta);
  font-weight: 500;
  letter-spacing: 0.04em;
  color: var(--ink-3);
  margin-bottom: var(--space-3);
}
.cs-panel--split .cs-overview-text,
.cs-panel--split .cs-contributions-list li {
  font-size: 13px;
  line-height: 1.65;
  color: var(--ink-2);
  opacity: 1;
}
.cs-panel--split .cs-contributions-list { gap: 10px; }
.cs-panel--split .cs-contributions-list li { padding-left: 28px; }
.cs-panel--split .cs-contributions-list li::before { font-size: var(--t-micro); color: var(--accent-blue); opacity: 1; }

@media (max-width: 719px) {
  .cs-split { display: block; overflow-y: auto; overscroll-behavior: contain; }
  .cs-split__side { padding: var(--space-4) var(--space-4) 0; overflow: visible; }
  .cs-panel--split .cs-content-wrap,
  .cs-panel--split .cs-content { height: auto; overflow: visible; }
  .cs-panel--split .cs-content { padding: var(--space-3) var(--space-4) var(--space-6); }
  .cs-split__text { grid-template-columns: 1fr; gap: var(--space-5); padding: var(--space-5) 0; }
  .cs-split__row { grid-template-columns: 1fr; }
}

/* ── Popup case, Playground pieces (Oct 2026) ────────────────────
   Playground leads with its overview alone (no contributions, so the
   text row is one column), then each piece: two detail stills stacked
   on the left and a bigger looping video on the right (2 : 3 columns,
   the video 5 : 4, so the stills come out about 16 : 9), then one
   caption line: name and what it does, its kind in the accent, its
   date. The colour in the media is the work's own (each piece on its
   painted ground, baked by scripts/playground-media.mjs); nothing here
   paints it. Phones put the video on top and the stills side by side
   under it. A still or video not made yet is a plain placeholder. */
.cs-panel--split .cs-split__text--solo { grid-template-columns: minmax(0, 1fr); }
.cs-panel--split .cs-split__text--solo .cs-overview-text { max-width: 64ch; }
.cs-panel--split .cs-split__text .cs-section[hidden] { display: none; }
/* Directory: the pieces by name, in a soft box like the facts box it
   stands in for, untitled; softer hairlines than the frame split the
   names, each numbered at the right. The one in
   view is in ink and 600, like the contents rail's current entry, the
   rest grey; hovering or clicking a name goes there. The arrows sit at
   the column's foot. */
.cs-dir {   /* the facts box's (.cs-card--box): soft fill, no stroke */
  padding: var(--space-2) 0; overflow: hidden;
  background: var(--surface-deep);
  border-radius: var(--radius-content);
}
.cs-dir__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
/* the hairline is the button's own edge, so the pointer never falls in
   a gap between names and the hover blur doesn't blink row to row */
.cs-dir__list li + li .cs-dir__item { border-top: 1px solid color-mix(in oklab, var(--ink-1) 6%, transparent); }
.cs-dir__item {
  display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-2);
  width: 100%; padding: var(--space-2) var(--space-4); border: 0; background: none; cursor: pointer; text-align: left;
  font-family: var(--font-body); font-size: var(--t-small); line-height: 1.5; color: var(--ink-4);
  transition: color 240ms var(--ease-out-quart);
}
/* the blur takes the words, never the hairline (the button's own edge) */
.cs-dir__item > span {
  transition: filter 340ms var(--ease-out-quart),
              opacity 340ms var(--ease-out-quart);
}
.cs-dir__item[aria-current] { color: var(--ink-1); font-weight: 600; }
.cs-dir__num {
  font-family: var(--font-primary); font-size: var(--t-meta); font-weight: 500;
  letter-spacing: 0.04em; font-variant-numeric: tabular-nums; color: var(--ink-4);
}
/* Hover defocus, the contents rail's (.case-toc__list): hovering one
   name deepens it and glides the rest back with the same whisper of
   blur and dim. Hover-capable pointers only. */
@media (hover: hover) and (pointer: fine) {
  .cs-dir__item:hover { color: var(--ink-1); }
  .cs-dir__list:has(.cs-dir__item:hover) .cs-dir__item:not(:hover) > span {
    filter: blur(0.7px);
    opacity: 0.42;
  }
}
.cs-dir__item:focus-visible { outline: 1.5px solid var(--accent-blue); outline-offset: 2px; }
.cs-step { display: flex; gap: var(--space-2); margin-top: auto; }
.cs-step[hidden], .cs-dir[hidden], .cs-card__rows[hidden], .cs-split__text[hidden] { display: none; }
.cs-step__btn {
  display: grid; place-items: center; width: 32px; height: 32px;
  border: 0; border-radius: var(--radius-control); cursor: pointer;
  background: var(--surface-deep); color: var(--ink-1);
  transition: background-color 200ms var(--ease-out-quart), opacity 200ms var(--ease-out-quart);
}
.cs-step__btn:hover:not(:disabled) { background: color-mix(in oklab, var(--ink-1) 10%, var(--surface-deep)); }
.cs-step__btn:disabled { opacity: .35; cursor: default; }
.cs-step__btn:focus-visible { outline: 1.5px solid var(--accent-blue); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
  .cs-dir__item, .cs-dir__item > span, .cs-step__btn { transition: none; }
}
@media (max-width: 719px) { .cs-step { display: none; } }
.cs-panel--split .cs-demos { display: grid; gap: var(--space-7) var(--space-2); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.cs-panel--split .cs-demos[hidden] { display: none; }
.cs-panel--split .cs-piece { min-width: 0; grid-column: 1 / -1; scroll-margin-top: var(--space-3); }  /* a jump keeps the column's top padding */
/* video-only pieces take half a row each, their caption stacked */
.cs-panel--split .cs-piece--solo { grid-column: auto; }
.cs-panel--split .cs-piece--solo .cs-piece__media { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto; }
.cs-panel--split .cs-piece--solo .cs-piece__video { grid-column: 1; grid-row: 1; }
.cs-panel--split .cs-piece__media {
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
  grid-template-rows: 1fr 1fr;
  gap: var(--space-2);
}
.cs-panel--split .cs-piece__still,
.cs-panel--split .cs-piece__video {
  position: relative;
  min-height: 0;
  overflow: hidden;
  background: var(--surface-deep);
}
.cs-panel--split .cs-piece__video { grid-column: 2; grid-row: 1 / span 2; aspect-ratio: 5 / 4; }
.cs-panel--split .cs-piece__still img,
.cs-panel--split .cs-piece__still video,
.cs-panel--split .cs-piece__video video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.cs-panel--split .cs-piece__ph {
  display: grid;
  place-items: center;
  border: 1px dashed color-mix(in oklab, var(--ink-1) 18%, transparent);
}
.cs-panel--split .cs-piece__ph span {
  font-family: var(--font-primary);
  font-size: var(--t-micro);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-4);
}
.cs-panel--split .cs-piece__caption {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  gap: var(--space-5);
  align-items: baseline;
  margin-top: var(--space-3);
}
.cs-panel--split .cs-piece__caption p { margin: 0; }
/* pill and date read as a pair: a closer gap than the text's */
.cs-panel--split .cs-piece__caption .cs-piece__date { margin-left: calc(var(--space-3) - var(--space-5)); }
.cs-panel--split .cs-piece__text {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink-3);
  text-wrap: pretty;
}
.cs-panel--split .cs-piece__text strong { font-weight: 600; color: var(--ink-1); }
.cs-panel--split .cs-piece__tag {   /* the project cards' grey meta chip (.card__meta dd) */
  justify-self: start;
  font-family: var(--font-primary);
  font-size: var(--t-meta);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  line-height: 1.5;
  white-space: nowrap;
  color: color-mix(in oklab, var(--ink-1) 55%, transparent);
  background: color-mix(in oklab, var(--ink-1) 4%, transparent);
  border-radius: var(--radius-control);
  padding: 2px 7px 3px;
}
.cs-panel--split .cs-piece__date {
  font-family: var(--font-body);
  font-size: var(--t-small);
  color: var(--ink-2);
  white-space: nowrap;
}
@media (max-width: 719px) {
  .cs-panel--split .cs-piece__media { grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; }
  .cs-panel--split .cs-piece__video { grid-column: 1 / span 2; grid-row: 1; }
  .cs-panel--split .cs-piece__still { grid-row: 2; aspect-ratio: 16 / 9; }
  .cs-panel--split .cs-piece__caption { grid-template-columns: 1fr auto; gap: var(--space-2) var(--space-4); }
  .cs-panel--split .cs-piece__text { grid-column: 1 / span 2; }
  .cs-panel--split .cs-piece--solo { grid-column: 1 / -1; }
  .cs-panel--split .cs-piece__caption .cs-piece__date { margin-left: 0; }
}

/* ════════════════════════════════════════════════════════════════
   POWDER ROOM (Oct 2026, lab page: powder-room.html, js/powder-room.js).
   A retro bathroom in three.js, painted. The page is the lab's quiet
   kit: the corner back-tag, chip-tag buttons, the sound chip, a lab-hint
   line and the cursor-tip chip as the room's tooltip and toast. The
   only new control is the realism slider, drawn as an ink hairline.

   The room itself is an object with its own material, like --paper-*
   and --plastic-*: the --bath-* tones are read by the scene (never by
   chrome or type) and stay the same in every theme, the way a photo does.
   ════════════════════════════════════════════════════════════════ */
.powder {
  --bath-tile:        #b4d0d8;   /* pale blue glaze, after the reference photo */
  --bath-grout:       #eef0ee;
  --bath-sink:        #dfeceb;
  --bath-china:       #f6f6f3;   /* the toilet, all white */
  --bath-paint:       #f3f3ef;   /* white wall above the tiles */
  --bath-towel:       #f6f5f1;   /* white terry */
  --bath-towel-band:  #2f4a6b;
  --bath-mat:         #f6f5f0;   /* the rectangular white rug */
  --bath-rug:         #f6f5f0;   /* the toilet's shag cover and mat */
  --bath-navy:        #2f4a6b;   /* the floral row and the towel's stripe */
  --bath-floor:       #f4f4f1;   /* white floor tiles */
  --bath-floor-grout: #d9dcdc;
  --bath-plaster:     #e2e1dc;   /* the cut edges of the wall and floor */
  --bath-wood:        #9b6e45;
  --bath-fan:         #94c2b0;
  --bath-curtain:     #f7f7f4;
  height: 100dvh;
  box-sizing: border-box;
  justify-content: flex-start;
  gap: var(--space-4);
  padding: var(--space-8) var(--space-6) var(--space-5);
}
.powder__zoom {
  position: absolute; top: var(--space-6); left: var(--space-6); z-index: 3; height: 28px; padding-block: 0;
  background: var(--surface-base); translate: 0 -4px; opacity: 0; pointer-events: none;
  transition: opacity 220ms var(--ease-out-quart), translate 320ms var(--ease-out-expo);
}
.powder__zoom.is-on { opacity: 1; translate: 0 0; pointer-events: auto; }
@media (prefers-reduced-motion: reduce) { .powder__zoom { transition: none; } }
.powder__corner {
  position: fixed;
  top: var(--space-4);
  right: var(--space-4);
  z-index: 20;
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.powder__corner .chip-tag { height: 28px; padding-block: 0; gap: 0.4em; }
.powder__corner .sd { fill: var(--ink-1); }
.powder__corner .sound-btn[aria-pressed="false"] .sd { fill: var(--ink-4); }

.powder__stage {
  flex: 1;
  min-height: 0;
  width: min(100%, 1320px);
  display: flex;
  align-items: center;
  justify-content: center;
}
.powder__painting {
  position: relative;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  opacity: 0;
  transition: opacity 700ms var(--ease-out-soft);
}
.powder__painting.is-ready { opacity: 1; }
.powder__painting canvas { display: block; width: 100%; height: 100%; outline: none; cursor: grab; }
.powder__painting canvas:active { cursor: grabbing; }
.powder__painting canvas:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 4px; }
.powder__fallback { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; }

/* The room's labels speak in the cursor-tip chip, sat just above the pointer
   (the pointer stays: in a room of small things you need to see what it's on). */
.powder__tip { z-index: 30; }
.powder__toast {
  position: absolute;
  top: auto;
  left: 50%;
  bottom: var(--space-6);
  translate: -50% 6px;
  max-width: calc(100% - 2 * var(--space-6));
  white-space: normal;
  text-align: center;
  line-height: 1.3;
  transition: opacity 220ms var(--ease-out-quart), translate 320ms var(--ease-out-expo), scale 220ms var(--ease-out-expo);
}
.powder__toast.is-on { opacity: 1; scale: 1; translate: -50% 0; }

.powder__bar {
  width: min(100%, 1320px);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3) var(--space-5);
  flex-wrap: wrap;
}
.powder__hint { margin: 0; flex: 1 1 260px; text-align: center; }

/* Zone chips: the wall's parts as a row of pills; the one you're standing at is inked blue. */
.powder__zones { display: flex; flex-wrap: wrap; gap: 6px; }
.powder__zones .chip-tag { height: 26px; padding-block: 0; transition: color 180ms var(--ease-out-quart), border-color 180ms var(--ease-out-quart); }
@media (hover: hover) and (pointer: fine) {
  .powder__zones .chip-tag:hover { color: var(--ink-1); border-color: var(--ink-1); }
}
.powder__zones .chip-tag[aria-pressed="true"] { color: var(--accent-blue); border-color: var(--accent-blue); }
.powder__zones .chip-tag:focus-visible { outline: 1px solid var(--accent-blue); outline-offset: 2px; }

/* Keys: the shortcut sheet, only where there is a keyboard to use it. */
.powder__keys dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 6px var(--space-3); align-items: baseline; }
.powder__keys dt { font-family: var(--font-primary); font-size: var(--t-eyebrow); letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-2); white-space: nowrap; }
.powder__keys dd { margin: 0; font-family: var(--font-body); font-size: var(--t-small); line-height: 1.45; color: var(--ink-1); }
.powder__keys kbd {
  display: inline-block; min-width: 1.6em; padding: 2px 4px 1px; text-align: center;
  font-family: var(--font-primary); font-size: var(--t-eyebrow); line-height: 1;
  border: 1px solid color-mix(in oklab, var(--ink-1) 30%, transparent); border-radius: var(--radius-control);
}
@media (hover: none) { .powder__keys { display: none; } }

/* Painted ↔ real: an ink hairline with a small ink bead, blue only on focus. */
.powder__realism { display: inline-flex; align-items: center; gap: var(--space-3); cursor: pointer; }
.powder__realism input {
  -webkit-appearance: none;
  appearance: none;
  width: clamp(120px, 14vw, 200px);
  height: 20px;
  margin: 0;
  background: transparent;
  cursor: pointer;
}
.powder__realism input::-webkit-slider-runnable-track { height: 1px; background: var(--ink-3); }
.powder__realism input::-moz-range-track { height: 1px; background: var(--ink-3); }
.powder__realism input::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 11px;
  height: 11px;
  margin-top: -5px;
  border-radius: 50%;
  background: var(--ink-1);
  border: 0;
  transition: scale 180ms var(--ease-out-expo);
}
.powder__realism input::-moz-range-thumb { width: 11px; height: 11px; border-radius: 50%; background: var(--ink-1); border: 0; }
@media (hover: hover) and (pointer: fine) {
  .powder__realism input:hover::-webkit-slider-thumb { scale: 1.25; }
}
.powder__realism input:focus-visible { outline: none; }
.powder__realism input:focus-visible::-webkit-slider-thumb { background: var(--accent-blue); }
.powder__realism input:focus-visible::-moz-range-thumb { background: var(--accent-blue); }

/* Secrets: a hairline sheet under the chip. */
.powder__secrets { position: relative; }
.powder__secret-list {
  position: absolute;
  right: 0;
  top: calc(100% + var(--space-2));
  width: min(280px, calc(100vw - 2 * var(--space-4)));
  max-height: 60vh;
  overflow: auto;
  padding: var(--space-3) var(--space-4) var(--space-4);
  background: var(--surface-base);
  border: 1px solid color-mix(in oklab, var(--ink-1) 14%, transparent);
  box-shadow: var(--glass-shadow);
}
.powder__secret-list .eyebrow { margin: 0 0 var(--space-3); }
.powder__secret-list ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; }
.powder__secret-list li {
  display: flex;
  gap: var(--space-2);
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.45;
  color: var(--ink-4);
}
.powder__secret-list li::before { content: ""; flex: none; width: 5px; height: 5px; margin-top: 0.55em; border-radius: 50%; border: 1px solid var(--ink-4); }
.powder__secret-list li.got { color: var(--ink-1); }
.powder__secret-list li.got::before { background: var(--accent-blue); border-color: var(--accent-blue); }

/* The folded note from the cabinet: index card stock with the library card's rules. */
.powder__note {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: grid;
  place-items: center;
  padding: var(--space-4);
  background: color-mix(in oklab, var(--surface-base) 82%, transparent);
}
.powder__note-card {
  width: min(360px, 100%);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-3);
  padding: var(--space-5) var(--space-5) var(--space-4);
  background: var(--paper-card);
  color: var(--paper-ink);
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.6;
  rotate: -1deg;
  box-shadow: 0 1px 2px color-mix(in oklab, var(--paper-shadow) 10%, transparent), 0 18px 40px -18px color-mix(in oklab, var(--paper-shadow) 30%, transparent);
  background-image: repeating-linear-gradient(transparent 0 calc(1.6em - 1px), color-mix(in oklab, var(--paper-rule) 55%, transparent) calc(1.6em - 1px) 1.6em);
}
.powder__note-card p { margin: 0; }
.powder__note[hidden], .powder__secret-list[hidden], .powder__fallback[hidden] { display: none; }
.powder__note-card .lab-btn { margin-top: var(--space-2); }

@media (max-width: 719px) {
  .powder { padding: var(--space-8) var(--space-4) var(--space-4); }
  .powder__bar { justify-content: center; }
  .powder__zones { justify-content: center; }
  .powder__hint { text-align: center; flex-basis: 100%; order: -1; }
}
@media (prefers-reduced-motion: reduce) {
  .powder__painting, .powder__toast, .powder__realism input::-webkit-slider-thumb, .powder__zones .chip-tag { transition: none; }
}

/* ════════════════════════════════════════════════════════════════
   WATER COOLER (Oct 2026, cooler.html; personal links at /c/<code>).
   An outreach page: a 3D water cooler beside a short scripted chat.
   The object is in the same library as the bread clip and the borrow
   card: one scale, light from the top, a two-layer shadow falling
   straight down, no idle motion. It leans toward the pointer, turns in
   the hand, and settles back on --ease-out-expo when let go.

   The scene (js/cooler.js, three.js) reads its materials from the
   --cooler-* tokens below and never draws chrome or type. The chat
   (js/cooler-chat.js) renders from PortfolioUI.cooler in
   js/components.js. See DESIGN.md, "Cooler objects".
   ════════════════════════════════════════════════════════════════ */
.cooler {
  --cooler-body:   var(--plastic-face);   /* the cabinet: the clip's moulded off-white */
  --cooler-recess: var(--plastic-edge);   /* the tap alcove and drip tray, in shade */
  --cooler-ink:    var(--plastic-ink);    /* brand plate, grille, the coils on the back */
  --cooler-cup:    var(--paper-card);     /* a paper cup, the index card's stock */
  --cooler-hot:    var(--plastic-ink);    /* the hot tap's cap: ink. Never red; there is one accent */
  --cooler-light:  var(--plastic-light);
  --cooler-shadow: var(--plastic-shadow);
  --cooler-h: min(560px, 70svh);          /* the object's height; its width follows */
  --cooler-bubble-r: 16px;

  flex: 1;
  width: min(100%, 1040px);
  margin: 0 auto;
  box-sizing: border-box;
  /* Where the object rests: centred in the first screen, and held there
     (sticky) while the chat grows down the page beside it. */
  --cooler-top: max(var(--space-8), calc((100svh - var(--cooler-h)) / 2 - var(--space-4)));
  display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr);
  align-items: start;
  gap: var(--space-6);
  padding: var(--cooler-top) var(--space-6) var(--space-7);
}
.cooler-page { min-height: 100dvh; display: flex; flex-direction: column; }

/* ── The object ── */
.cooler__object { position: sticky; top: var(--cooler-top); display: flex; flex-direction: column; align-items: center; gap: var(--space-3); }
.cooler__stage {
  position: relative;
  height: var(--cooler-h);
  aspect-ratio: 0.72;
  max-width: 100%;
  touch-action: pan-y;          /* a vertical swipe still scrolls the page; a sideways one turns it */
  user-select: none;
  -webkit-user-select: none;
}
.cooler__stage canvas {
  position: relative;
  z-index: 1;
  display: block;
  width: 100%;
  height: 100%;
  outline: none;
  cursor: grab;
  opacity: 0;
  transition: opacity 700ms var(--ease-out-soft);
}
.cooler__stage.is-ready canvas { opacity: 1; }
.cooler__stage.is-held canvas { cursor: grabbing; }
.cooler__stage canvas:focus-visible { outline: 1px solid var(--accent-blue); outline-offset: 6px; }
/* The borrow card's shadow recipe laid on the floor, as under the clip:
   a tight contact under a long faint ambient, both falling straight
   down. js/cooler.js sets its top to wherever the base lands. */
.cooler__shadow {
  position: absolute;
  left: 50%;
  top: var(--cooler-floor, 92%);
  width: 62%;
  height: 7%;
  border-radius: 50%;
  background:
    radial-gradient(closest-side, color-mix(in srgb, var(--cooler-shadow) 10%, transparent), transparent 55%),
    radial-gradient(closest-side, color-mix(in srgb, var(--cooler-shadow) 16%, transparent), transparent);
  filter: blur(8px);
  transform: translate(-50%, -50%);
  pointer-events: none;
  opacity: 0;
  transition: opacity 700ms var(--ease-out-soft);
}
.cooler__stage.is-ready .cooler__shadow { opacity: 1; }
.cooler__fallback { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; text-align: center; }
.cooler__fallback[hidden] { display: none; }
.cooler__hint {
  margin: 0;
  min-height: 1.5em;
  font-family: var(--font-body);
  font-size: var(--t-small);
  color: var(--ink-4);
  text-align: center;
}

/* ── The chat: an invite, not a support widget (Oct 2026). Filled
      bubbles with no stroke or shadow, small body type, generous air.
      Her bubbles are paper-grey with one tucked corner toward the
      cooler; the reader's replies are small ink bubbles on the right. ── */
.cooler-chat { display: flex; flex-direction: column; gap: var(--space-4); max-width: 380px; padding-top: calc(var(--cooler-h) * 0.3); padding-bottom: calc(var(--cooler-h) * 0.25); }
.cooler-chat__invite { margin: 0 0 var(--space-1); color: var(--accent-blue); }
.cooler-chat__log { display: flex; flex-direction: column; gap: var(--space-2); }
.cooler-say { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-2); animation: chat-ink-in 340ms var(--ease-out-expo) both; }
.cooler-say__bubble {
  max-width: 100%;
  padding: 10px 14px;
  border-radius: var(--cooler-bubble-r) var(--cooler-bubble-r) var(--cooler-bubble-r) var(--space-1);
  background: var(--surface-deep);
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.55;
  color: var(--ink-2);
  text-wrap: pretty;
}
.cooler-say__bubble p + p { margin-top: var(--space-2); }
.cooler-say--intro .cooler-say__bubble { color: var(--ink-1); padding: 12px 16px; }
.cooler-say .chat-card { margin: 0; }
.cooler-say__more { font-family: var(--font-body); font-size: var(--t-small); color: var(--accent-blue); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
.cooler-ask { align-self: flex-end; animation: chat-from-right 240ms var(--ease-out-expo) both; }
.cooler-ask .chat-msg__text {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.5;
  padding: 7px 12px;
  border-radius: var(--cooler-bubble-r) var(--cooler-bubble-r) var(--space-1) var(--cooler-bubble-r);
  background: var(--ink-1);
  color: var(--ink-inv);
  box-shadow: none;
}
.cooler-chat .chat-typing { padding-left: var(--space-1); }

/* The reader's options: quiet filled chips, no outline. */
.cooler-chat__asks { display: flex; flex-wrap: wrap; gap: 6px; min-height: 28px; }
.cooler-chat__asks .chat-followup {
  font-family: var(--font-body);
  font-size: var(--t-small);
  font-weight: 400;
  letter-spacing: 0;
  padding: 6px 12px;
  border: 0;
  background: var(--surface-tint);
  color: var(--ink-2);
  transition: background 180ms var(--ease-out-quart), color 180ms var(--ease-out-quart), transform 320ms var(--ease-out-expo);
}
@media (hover: hover) and (pointer: fine) {
  .cooler-chat__asks .chat-followup:hover { background: var(--ink-1); color: var(--ink-inv); transform: none; }
}
.cooler-chat__asks .chat-followup:focus-visible { outline: 1px solid var(--accent-blue); outline-offset: 2px; }

.cooler-contact { display: flex; flex-wrap: wrap; gap: 6px; }
.cooler-contact__btn {
  height: 30px; padding: 0 14px; border: 0; cursor: pointer; text-decoration: none;
  background: var(--surface-tint); color: var(--ink-2);
  font-family: var(--font-body); font-size: var(--t-small); font-weight: 400; letter-spacing: 0; text-transform: none;
  transition: color 180ms var(--ease-out-quart), background 180ms var(--ease-out-quart);
}
.cooler-contact__btn--ink { background: var(--ink-1); color: var(--ink-inv); }
@media (hover: hover) and (pointer: fine) {
  .cooler-contact__btn:not(.cooler-contact__btn--ink):hover { background: var(--ink-1); color: var(--ink-inv); }
  .cooler-contact__btn--ink:hover { background: var(--accent-blue); }
}
.cooler-contact__btn:focus-visible { outline: 1px solid var(--accent-blue); outline-offset: 2px; }

/* ── Corners: who this is from, and the way out to the portfolio ── */
.cooler__tag { position: fixed; top: var(--space-4); left: var(--space-4); margin: 0; z-index: 20; }
.cooler__corner {
  position: fixed;
  top: var(--space-4);
  right: var(--space-4);
  z-index: 20;
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.cooler__corner .chip-tag { height: 28px; padding-block: 0; text-decoration: none; border: 0; background: var(--surface-tint); transition: color 180ms var(--ease-out-quart), background 180ms var(--ease-out-quart); }
@media (hover: hover) and (pointer: fine) {
  .cooler__corner .chip-tag:hover { background: var(--ink-1); color: var(--ink-inv); }
}
.cooler__corner .sd { fill: var(--ink-1); }
.cooler__corner .sound-btn[aria-pressed="false"] .sd { fill: var(--ink-4); }
.cooler__tip { z-index: 30; }

/* ── Solo (watercooler.html, a Playground piece): the object alone,
      centred in the screen, with nothing beside it to stay level with. ── */
.cooler--solo { grid-template-columns: minmax(0, 1fr); justify-items: center; }
.cooler--solo .cooler__object { position: static; }

@media (max-width: 719px) {
  .cooler {
    --cooler-h: min(42svh, 360px);
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-4);
    padding: var(--space-8) var(--space-4) var(--space-7);
    align-content: start;
  }
  .cooler--solo { --cooler-h: min(64svh, 480px); }
  .cooler__object { position: static; }
  .cooler__tag-role { display: none; }
  .cooler-chat { max-width: none; padding: 0; }
  .cooler-say__bubble { border-radius: var(--space-1) var(--cooler-bubble-r) var(--cooler-bubble-r) var(--cooler-bubble-r); }
}
@media (prefers-reduced-motion: reduce) {
  .cooler__stage canvas, .cooler__shadow { transition: none; }
  .cooler-say, .cooler-ask, .cooler-chat .chat-typing { animation: none; }
}

/* ── Cooler links admin (cooler-admin.html): the borrow review's page,
      with a form for making a link and a row per person. ── */
.cla__form { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); margin-top: var(--space-3); }
.cla__field { display: flex; flex-direction: column; gap: var(--space-2); }
.cla__field--wide { grid-column: 1 / -1; }
.cla__field em { font-style: normal; text-transform: none; letter-spacing: 0; color: var(--ink-4); }
.cla__field input, .cla__field select, .cla__field textarea {
  padding: 9px 12px;
  border: 1px solid color-mix(in oklab, var(--ink-1) 20%, transparent);
  border-radius: var(--radius-control);
  background: var(--surface-base);
  color: var(--ink-1);
  font: 400 16px/1.5 var(--font-body);
}
.cla__field textarea { resize: vertical; }
.cla__help { font-size: var(--t-small); color: var(--ink-4); }
.cla__acts { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.cla__acts a.bc-btn { text-decoration: none; }
.cla__made { margin: 0; font-size: var(--t-small); color: var(--ink-2); overflow-wrap: anywhere; }
.cla__made a { color: var(--accent-blue); }
.cla__row { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-1); }
.cla__who { font-family: var(--font-body); font-size: var(--t-body); }
.cla__meta { font-size: var(--t-small); color: var(--ink-3); }
.cla__opened.is-opened { color: var(--accent-blue); }
.cla__row .cla__acts { margin-top: var(--space-2); }
@media (max-width: 599px) { .cla__form { grid-template-columns: 1fr; } }

/* ── Lab ink ──────────────────────────────────────────────────────────
   Wet-ink type on the component library (js/lab-ink.js). Each glyph is
   its own span; JS softens and thickens the ones near the cursor and
   #ink-goo fuses them while the line is wet. */
.lab-ink__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.lab-ink__word { display: inline-block; }
.lab-ink__char {
  display: inline-block;
  transform-origin: 50% 78%; /* swell from near the baseline: ink pooling, not a balloon */
}
.is-lab-ink.is-wet { filter: url(#ink-goo); }
/* The lab page for it (ink.html): three cards, one interaction each. */
.ink-lab { min-height: 100vh; padding: var(--space-8) var(--pad-page-x); display: flex; flex-direction: column; gap: var(--space-6); justify-content: center; }
.ink-lab__head { display: flex; flex-direction: column; gap: var(--space-2); }
.ink-lab__title { font-family: var(--font-primary); font-size: var(--t-hero); font-weight: 500; line-height: 1.16; color: var(--ink-1); }
.ink-lab__grid { display: grid; grid-template-columns: 1fr; gap: var(--space-3); }
@media (min-width: 900px) { .ink-lab__grid { grid-template-columns: repeat(3, 1fr); } }
.ink-card { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-4); background: var(--surface-base); border: 1px solid color-mix(in oklab, var(--ink-1) 14%, transparent); }
.ink-card__label { color: var(--ink-3); }
.ink-card__stage { min-height: 180px; display: flex; align-items: center; justify-content: center; text-align: center; }
.ink-card__text { font-family: var(--font-primary); font-size: var(--t-hero); font-weight: 500; line-height: 1.16; color: var(--ink-1); }
.ink-card__body { font-family: var(--font-body); font-size: var(--t-body); line-height: 1.6; color: var(--ink-1); max-width: 24em; }
.ink-card__foot { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-3); border-top: 1px solid color-mix(in oklab, var(--ink-1) 10%, transparent); padding-top: var(--space-3); }
.ink-card__note { font-size: var(--t-small); color: var(--ink-3); line-height: 1.5; }
.ink-card__replay { flex: none; cursor: pointer; background: transparent; border: 1px solid color-mix(in oklab, var(--ink-1) 28%, transparent); border-radius: var(--radius-control); padding: 4px 8px; color: var(--ink-2); }
@media (hover: hover) and (pointer: fine) { .ink-card__replay:hover { color: var(--accent-blue); border-color: var(--accent-blue); } }

/* 01 Ink in: the old hero entrance. Blur spreads each glyph and a high
   contrast() re-thresholds it into solid goo, then the blur only falls,
   so the words dry into type. contrast() needs paper behind the glyphs,
   hence the background-color keyframes. Linear on purpose: the curve
   lives in the keyframes. */
@keyframes lab-ink-in {
  0%   { opacity: 0; filter: blur(3.8px) contrast(6);   background-color: var(--surface-base); }
  14%  { opacity: 1; filter: blur(3.6px) contrast(6);   background-color: var(--surface-base); }
  45%  {             filter: blur(2.1px) contrast(4.3); background-color: var(--surface-base); }
  72%  {             filter: blur(0.9px) contrast(2.3); background-color: var(--surface-base); }
  90%  {             filter: blur(0.35px) contrast(1.5); background-color: var(--surface-base); }
  100% { opacity: 1; filter: blur(0) contrast(1);       background-color: transparent; }
}
@media (prefers-reduced-motion: no-preference) {
  .ink-card__text--in { animation: lab-ink-in 1400ms linear both; }
}
@media (prefers-reduced-motion: reduce) {
  .ink-card__text--in { animation: fade-in 300ms var(--ease-out-soft) both; }
}

/* ── Lab page for the street footer (street-footer.html) ──
   Before and after, stacked: the first street (line art, live in its
   own document, street-archive.html) over the street drawn in code.
   The homepage's depth gradient assumes a long page, so it is off here. */
body.lab-street { min-height: 100vh; display: flex; flex-direction: column; }
body.lab-street:has(#street-footer) { background: var(--surface-base); }
.lab-street__main { display: flex; flex-direction: column; align-items: center; padding: var(--space-8) var(--space-4) var(--space-6); }
.lab-street__scene { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-6); }
.lab-street__label { padding-inline: var(--pad-page-x); }
.lab-street .street-footer { margin-top: 0; }
.lab-street__frame { display: block; width: 100%; aspect-ratio: 2560 / 692; border: 0; background: var(--surface-deep); }
/* Phones: the old street's own CSS draws it 230px tall and crops the
   ends (the same move the new street makes at 240px), so the frame follows. */
@media (max-width: 719px) { .lab-street__frame { aspect-ratio: auto; height: 230px; } }

/* ── Lab grid (lab.html) ──────────────────────────────────────────────
   The component library borrows the homepage card (.card) and makes it
   quieter and softer: grey plates with no stroke, objects floating on
   them, hairline chips, and plates of deliberately different shapes so
   the columns never line up into a tidy grid. Corners are --radius-soft,
   a lab-only token (DESIGN.md "Rounded content on record").
   Each card picks its plate shape with --lab-ratio (inline on the card);
   the thumbnail is contained inside it, never cropped. CSS columns
   balance the cards themselves, and the homepage's phone reordering
   (by data-card) is switched off so the lab reads in page order. */
.lab .lab-grid { display: block; columns: 1; column-gap: var(--space-4); }
.lab .lab-grid > .card { break-inside: avoid; margin-bottom: var(--space-5); }
@media (min-width: 720px) { .lab .lab-grid { columns: 2; } }
@media (min-width: 1024px) { .lab .lab-grid { columns: 3; } }
.lab [data-card] { order: 0; }
/* With js/lab-masonry.js the cards run left to right: each one drops into the shortest
   column, so the first cards in the markup sit across the top row. */
.lab .lab-grid.is-masonry { columns: auto; display: flex; align-items: flex-start; gap: var(--space-4); }
.lab .lab-grid.is-masonry > .lab-col { flex: 1 1 0; min-width: 0; }
.lab .lab-grid.is-masonry .card { break-inside: avoid; margin-bottom: var(--space-5); }

/* The card: no shape, a soft wash on hover, a rounded corner for it. */
.lab .card { padding: var(--space-2); border-radius: var(--radius-soft); }
@media (hover: hover) and (pointer: fine) {
  .lab .card:hover { background-color: color-mix(in oklab, var(--ink-1) 4%, transparent); }
}
.lab .card:focus-within { background-color: color-mix(in oklab, var(--ink-1) 4%, transparent); }

/* The plate: a grey fill, no stroke, the object floating inside it. */
.lab .card .card__frame {
  aspect-ratio: var(--lab-ratio, 4 / 3);
  border-radius: var(--radius-soft);
  background: var(--surface-tint);
}
.lab .card .card__frame::after { content: none; }
.lab .card__frame-shape { background: var(--surface-tint); }
.lab .card__thumb {
  inset: 9%;
  width: 82%;
  height: 82%;
  object-fit: contain;
  /* White captures multiply into the grey, so each object sits on the plate. */
  mix-blend-mode: multiply;
}
/* Dark: multiply would swallow black line art into the dark plate, so
   the capture shows as a small rounded print on it instead. */
html[data-theme="dark"] .lab .card__thumb { mix-blend-mode: normal; border-radius: var(--radius-control); }
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
  .lab .card:hover .card__thumb { transform: scale(1.03); }
}

/* Body: quieter type, hairline chips, meta reveal kept from the homepage. */
.lab .card__body { padding: var(--space-3) var(--space-1) var(--space-1); gap: var(--space-1); }
.lab .chip-tag { border-width: 1px; border-color: color-mix(in oklab, var(--ink-1) 16%, transparent); color: var(--ink-3); }
.lab .card__meta dd { border-radius: var(--radius-control); background: color-mix(in oklab, var(--ink-1) 5%, transparent); }

/* ══════════════════════════════════════════════════════════════
   PLAYGROUND FOOTER (Oct 2026, js/playground.js, loaded lazily by
   js/playground-boot.js). A dreamcore playground under a pale blue sky,
   linking to the playground of side projects (lab.html). It is the
   site's second scoped colour exception: every colour here is a
   --play-* token that exists only inside .playground-footer and the
   page gradient that leads down into its sky (DESIGN.md "The
   playground's pastels"). Blues keep red <= green; no lilac.
   ══════════════════════════════════════════════════════════════ */

/* Into the sky. The page stays paper right up to the playground; the
   hand-off is painted, flat-brush gouache strokes over the seam
   (.playground-footer__paint, drawn by js/playground.js) that cover it
   at the bottom and thin out upward. Under them, a short band (a
   five-stop smoothstep) turns the page to --play-sky, so the seam is
   still soft before the paint arrives. The footer and its painted edge
   take a little more than half a 13-inch laptop's screen. --play-h and
   --play-rise live here so this gradient and the footer read the same
   two numbers. */
body:has(#playground-footer) {
  --play-sky: #cfe4f5;            /* pale baby blue, azure (r < g) */
  --play-h: clamp(320px, 44vh, 500px);
  --play-rise: clamp(56px, 8vh, 88px);
  --mist-end: calc(100% - var(--play-h) + 24px);
  --mist-span: calc(var(--play-rise) * 0.7 + 24px);
  background: linear-gradient(to bottom,
    var(--surface-base) calc(var(--mist-end) - var(--mist-span)),
    color-mix(in srgb, var(--play-sky) 15.6%, var(--surface-base)) calc(var(--mist-end) - var(--mist-span) * 0.75),
    color-mix(in srgb, var(--play-sky) 50%, var(--surface-base)) calc(var(--mist-end) - var(--mist-span) * 0.5),
    color-mix(in srgb, var(--play-sky) 84.4%, var(--surface-base)) calc(var(--mist-end) - var(--mist-span) * 0.25),
    var(--play-sky) var(--mist-end));
}
html[data-theme="dark"] body:has(#playground-footer) { --play-sky: #121a27; }   /* a night sky, azure */
html[data-theme="eink"] body:has(#playground-footer) { --play-sky: #cccccc; }   /* 204 */

.playground-footer {
  /* air */
  --play-haze:   #edf3f7;   /* the horizon and the fog: far things dissolve into it */
  --play-wisp:   #ffffff;   /* fog banks drifting between the pieces */
  /* ground */
  --play-grass:  #b8cfa0;   /* a soft sage lawn */
  --play-sand:   #f2e7d0;   /* under the tower and the swings */
  --play-path:   #b3b8bf;   /* soft asphalt, under the chalk */
  --play-paper:  var(--surface-base);   /* the page's paper, dragged into the painted edge */
  /* pieces: cream plastic from the object library, painted in baby pastels */
  --play-body:   var(--plastic-face);
  --play-pink:   #f6cad5;
  --play-rose:   #eeb0c0;   /* the spring, a step deeper than the saddle */
  --play-butter: #f7e7a9;
  --play-mint:   #c9eadb;
  --play-blue:   #c3dcf3;
  --play-peach:  #f8d5c0;
  --play-metal:  #d9dde3;   /* chains, poles */
  --play-bark:   #847a72;   /* bare winter wood */
  --play-iron:   #5d636c;   /* the park lamp */
  /* chalk: a step stronger than the pieces, so it reads on the path */
  --play-chalk-pink:   #f2a7bd;
  --play-chalk-blue:   #86bdea;
  --play-chalk-yellow: #f1d76e;
  --play-chalk-white:  #fbfbf7;
  --play-shadow: #6f7a8c;   /* contact shadows lean blue, like shade under a blue sky */
  --play-glow:   var(--street-glow);
  position: relative;
  height: var(--play-h, 480px);
  margin-top: calc(var(--play-rise, 72px) + var(--space-6));
  /* No fill of its own: the page's gradient has already arrived at the
     sky here, and the scene paints its own. */
}
html[data-theme="dark"] .playground-footer {
  --play-haze:   #1c2430;
  --play-wisp:   #2c3542;
  --play-grass:  #2b3a2c;
  --play-sand:   #3a362e;
  --play-path:   #3a3e45;
  --play-pink:   #b88e99;
  --play-rose:   #ae7f8c;
  --play-butter: #b9ab78;
  --play-mint:   #8fb0a1;
  --play-blue:   #8ea8c2;
  --play-peach:  #b99a87;
  --play-metal:  #6c737d;
  --play-bark:   #2e2c2b;
  --play-iron:   #2a2e34;
  --play-chalk-pink:   #d7899f;
  --play-chalk-blue:   #6fa3d6;
  --play-chalk-yellow: #d8c060;
  --play-chalk-white:  #d9d9d4;
  --play-shadow: #05070a;
}
/* E-ink: every pastel snapped to the panel's 16-level ramp. */
html[data-theme="eink"] .playground-footer {
  --play-haze:   #dddddd;   /* 221 */
  --play-wisp:   #eeeeee;   /* 238 */
  --play-grass:  #cccccc;   /* 204 */
  --play-sand:   #eeeeee;   /* 238 */
  --play-path:   #aaaaaa;   /* 170 */
  --play-pink:   #cccccc;   /* 204 */
  --play-rose:   #999999;   /* 153 */
  --play-butter: #eeeeee;   /* 238 */
  --play-mint:   #dddddd;   /* 221 */
  --play-blue:   #bbbbbb;   /* 187 */
  --play-peach:  #dddddd;   /* 221 */
  --play-metal:  #888888;   /* 136 */
  --play-bark:   #777777;   /* 119 */
  --play-iron:   #555555;   /* 85  */
  --play-chalk-pink:   #eeeeee;
  --play-chalk-blue:   #ffffff;
  --play-chalk-yellow: #eeeeee;
  --play-chalk-white:  #ffffff;
  --play-shadow: #444444;   /* 68 */
}

/* The scene fills the footer. Neither canvas takes a click: the link
   above them does. */
.playground-footer__scene {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
/* The painted edge: gouache strokes laid over the seam, from
   --play-rise above the footer to 56px into it. */
.playground-footer__paint {
  position: absolute;
  left: 0;
  top: calc(-1 * var(--play-rise, 72px));
  display: block;
  width: 100%;
  height: calc(var(--play-rise, 72px) + 56px);
  pointer-events: none;
  z-index: 1;
}

/* Ground mist and petals over the picture (built by js/playground.js):
   two bands of mist drift left to right on the page's wind at their own
   speeds, and a few blossom petals float across. Transforms only, so
   the compositor carries them; they run only while the footer is on
   screen (.is-live). */
.playground-footer__mist {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 1;
}
.playground-footer__mist-layer {
  position: absolute;
  left: 0;
  bottom: -4%;
  width: 200%;
  background-repeat: repeat-x;
  background-size: 50% 100%;
  animation: play-mist 150s linear infinite paused;
}
.playground-footer__mist-layer--1 { height: 60%; opacity: 0.36; }
.playground-footer__mist-layer--2 { height: 36%; opacity: 0.3; animation-duration: 96s; }
.playground-footer__petal {
  position: absolute;
  left: -14px;
  top: var(--py, 40%);
  width: 7px;
  height: 5px;
  border-radius: 70% 0 70% 0;   /* a petal */
  background: var(--play-pink);
  opacity: 0.8;
  animation: play-petal var(--pd, 48s) linear var(--pdel, 0s) infinite paused;
}
.playground-footer__petal--1 { --py: 26%; --pd: 52s; --pdel: -8s;  --pfall: 70px; }
.playground-footer__petal--2 { --py: 44%; --pd: 61s; --pdel: -31s; --pfall: 40px; width: 6px; height: 4px; }
.playground-footer__petal--3 { --py: 18%; --pd: 47s; --pdel: -22s; --pfall: 110px; }
.playground-footer__petal--4 { --py: 58%; --pd: 69s; --pdel: -50s; --pfall: 30px; width: 5px; height: 4px; opacity: 0.6; }
.playground-footer__petal--5 { --py: 34%; --pd: 56s; --pdel: -12s; --pfall: 90px; }
.playground-footer__petal--6 { --py: 50%; --pd: 74s; --pdel: -60s; --pfall: 50px; width: 6px; height: 4px; opacity: 0.65; }
.playground-footer__petal--7 { --py: 22%; --pd: 50s; --pdel: -40s; --pfall: 80px; }
.playground-footer.is-live .playground-footer__mist-layer,
.playground-footer.is-live .playground-footer__petal { animation-play-state: running; }
@keyframes play-mist {
  from { transform: translateX(-50%); }
  to   { transform: translateX(0); }
}
@keyframes play-petal {
  0%   { transform: translate(0, 0) rotate(0deg); }
  25%  { transform: translate(26vw, calc(var(--pfall, 60px) * 0.3)) rotate(140deg); }
  50%  { transform: translate(52vw, calc(var(--pfall, 60px) * 0.5 - 14px)) rotate(300deg); }
  75%  { transform: translate(78vw, calc(var(--pfall, 60px) * 0.8)) rotate(420deg); }
  100% { transform: translate(calc(100vw + 28px), var(--pfall, 60px)) rotate(560deg); }
}
/* Walking in: the mist closes over the playground, then the link. */
.playground-footer__mist::after {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--play-wisp);
  opacity: 0;
  transition: opacity 520ms var(--ease-out-soft);
}
.playground-footer.is-entering .playground-footer__mist::after { opacity: 0.92; }

/* The whole footer is one link with one name, above the painted edge. */
.playground-footer__link {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: block;
  color: inherit;
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}
.playground-footer__link:focus-visible { outline: none; }
.playground-footer__label {
  position: absolute;
  top: var(--space-6);
  left: var(--pad-page-x);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  max-width: 24em;
  transition: opacity 280ms var(--ease-out-quart);
}
.playground-footer__link:focus-visible .playground-footer__label {
  outline: 1.5px solid var(--accent-blue);
  outline-offset: 6px;
}
.playground-footer__eyebrow { color: var(--accent-blue); }
.playground-footer__line {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink-3);
  transition: color 200ms var(--ease-out-quart);
}
.playground-footer__arrow {
  display: inline-block;
  transition: translate 320ms var(--ease-out-expo);
}
@media (hover: hover) and (pointer: fine) {
  .playground-footer__link:hover .playground-footer__line { color: var(--accent-blue); }
  .playground-footer__link:hover .playground-footer__arrow { translate: 3px 0; }
}
/* Walking in: the words go first, then the haze closes (js/playground.js). */
.playground-footer.is-entering .playground-footer__label { opacity: 0; }

@media (max-width: 719px) {
  body:has(#playground-footer) { --play-h: 400px; --play-rise: 56px; }
  .playground-footer__label { top: var(--space-5); left: var(--space-5); right: var(--space-5); }
}
@media (prefers-reduced-motion: reduce) {
  .playground-footer__label,
  .playground-footer__line,
  .playground-footer__arrow,
  .playground-footer__mist::after { transition: none; }
  .playground-footer__mist-layer { animation: none; transform: translateX(-25%); }
  .playground-footer__petal { display: none; }
}
/* Case pages lift their content and footer over the body-wide sky field. */
.case-page .playground-footer { z-index: 1; }

/* ── Curtains (Oct 2026, curtain.html) ──────────────────────────────
   A popup with curtains draped over it: the popup is a plain note card
   (chrome, --radius-dock), and the cloth hangs in front of it from a
   rod above its top, wider than it on both sides and past its hem. The
   cloth is a WebGL canvas (js/curtain.js) in .curtain__drape; markup is
   PortfolioUI.curtain in js/components.js. How far the drape reaches
   past the card is three custom properties, as shares of the card. The
   --curtain-* tokens live only in here. */
.curtain {
  --drape-x: 18%;        /* past each side, of the card's width */
  --drape-top: 22%;      /* above it, of its height: room for the rod and valance */
  --drape-bottom: 10%;   /* below its hem, of its height */
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  padding: var(--space-4);
}
.curtain[hidden] { display: none; }
.curtain__veil {
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--surface-base) 97%, transparent);
  opacity: 0;
  transition: opacity 320ms var(--ease-out-quart);
}
.curtain__sheet {
  position: relative;
  width: min(400px, calc((100vw - 32px) / 1.36), calc((100svh - 150px) / 1.32 * 5 / 6));
  margin-top: calc(var(--drape-top) * 0.6);
  opacity: 0;
  transform: translateY(var(--space-4)) scale(0.98);
  transition: opacity 420ms var(--ease-out-expo), transform 620ms var(--ease-out-expo);
}
.curtain__sheet:focus { outline: none; }
.curtain.is-open .curtain__veil,
.curtain.is-open .curtain__sheet,
.curtain.is-open .curtain__close { opacity: 1; transform: none; }
.curtain__close {
  position: absolute;
  top: var(--space-4);
  right: var(--space-4);
  margin: 0;
  z-index: 1;
  opacity: 0;
  transition: opacity 320ms var(--ease-out-quart);
}
/* The popup itself: a note card. */
.curtain__card {
  aspect-ratio: 5 / 6;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  padding: 16% 13%;
  text-align: center;
  background: var(--surface-base);
  border-radius: var(--radius-dock);
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--ink-1) 8%, transparent),
    0 24px 60px -28px color-mix(in srgb, var(--ink-1) 35%, transparent);
}
.curtain__eyebrow { color: var(--accent-blue); }
.curtain__title {
  font-family: var(--font-primary);
  font-size: var(--t-manifesto);
  font-weight: 500;
  line-height: 1.16;
  letter-spacing: -0.005em;
  color: var(--ink-1);
  text-wrap: balance;
}
.curtain__body {
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.55;
  color: var(--ink-3);
  text-wrap: pretty;
}
/* The drape: in front of the card and past its edges. It takes no
   pointer itself; js/curtain.js hit-tests the cloth from .curtain, so
   the card stays clickable between the panels. */
.curtain__drape {
  position: absolute;
  top: calc(var(--drape-top) * -1);
  bottom: calc(var(--drape-bottom) * -1);
  left: calc(var(--drape-x) * -1);
  right: calc(var(--drape-x) * -1);
  pointer-events: none;
}
.curtain__cloth {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;   /* its shadow is drawn in the canvas: a CSS filter here re-blurred the whole drape every frame */
}
.curtain__cloth:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 2px; }
.curtain.is-grabbing { cursor: grabbing; touch-action: none; }
.curtain__hint {
  position: absolute;
  left: 0;
  right: 0;
  bottom: var(--space-5);
  font-size: var(--t-small);
  color: var(--ink-4);
  text-align: center;
  pointer-events: none;
  transition: opacity 320ms var(--ease-out-quart);
}
.curtain.is-revealed .curtain__hint { opacity: 0; }
/* No WebGL: no cloth, the note is simply there. */
.curtain--bare .curtain__drape { display: none; }
/* E-ink has no depth (the cloth's own shadow is off there too, in js/curtain.js). */
html[data-theme="eink"] .curtain__card { box-shadow: 0 0 0 1px var(--ink-1); }
@media (max-width: 719px) {
  .curtain { --drape-x: 16%; }
  .curtain__sheet { width: min(400px, calc((100vw - 32px) / 1.32), calc((100svh - 150px) / 1.32 * 4 / 5)); }
  .curtain__card { aspect-ratio: 4 / 5; padding: 12% 17%; }
}
@media (prefers-reduced-motion: reduce) {
  .curtain__veil,
  .curtain__sheet,
  .curtain__close,
  .curtain__hint { transition: opacity 200ms linear; }
  .curtain__sheet { transform: none; }
}

/* The slider's page is cool beige paper (on request, Oct 2026): a
   page-scoped token, a documented exception to the white paper (see
   DESIGN.md). It lives on body.ms-page only. Low chroma, a step darker
   than --surface-tint so the grey plates still lift off it. Dark mode
   gets a deep warm grey; e-ink, which has no colour, keeps its paper. */
/* The text plates are a warm off white on it (--ms-plate), brighter
   than the beige, in place of the site's grey. */
.ms-page { --ms-paper: oklch(89% 0.014 78); --ms-plate: oklch(97.5% 0.006 85); background: var(--ms-paper); }
html[data-theme="dark"] .ms-page { --ms-paper: oklch(22% 0.008 78); --ms-plate: oklch(30% 0.006 78); }
html[data-theme="eink"] .ms-page { --ms-paper: var(--surface-base); --ms-plate: var(--surface-tint); }

/* ════════════════════════════════════════════════════════════════
   Morph slider (Oct 2026, morph-slider.html): a picture and a grey
   text container that merge into one card in the middle and pull apart
   with a liquid neck off it. Markup js/components.js, behaviour
   js/morph-slider.js. --m (0 apart, 1 merged) is set per card by JS;
   everything below derives from it.
   ════════════════════════════════════════════════════════════════ */
.ms {
  --ms-w: min(66vw, 300px);
  --ms-media-h: calc(var(--ms-w) * 9 / 16);   /* the picture, shorter than the words' card is wide */
  --ms-gap: var(--space-8);       /* the gap between picture and words when apart */
  --ms-gap-far: var(--space-6);   /* and more for every slide further out, up to two */
  /* Corners: a deliberate exception to --radius-content (4px), scoped
     to this slider only, on request (Oct 2026). See DESIGN.md. */
  --ms-radius: 8px;
  --ms-inset: var(--space-2);     /* how much narrower the grey sits when apart */
  --ms-spacing: var(--space-5);   /* between cards */
  --ms-goo: var(--space-5);       /* how far each goo strip tucks under its piece */
  width: 100%;
  overflow-x: clip;
}
.ms-mount { width: 100%; }
.ms-defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ms-stage {
  position: relative;
  /* The lens (#ms-warp) bends each colour channel a touch differently.
     Over a transparent stage that leaves bare single channels at every
     edge (dark rims); over the page's own paper (--ms-paper) the
     channels split against it, which is what reads as a rainbow fringe. */
  filter: url(#ms-warp);
  background: var(--ms-paper, var(--surface-base));
  /* room inside the box for the lens's flare and the edge blur's spill
     to fade, so nothing the lens touches is ever transparent */
  padding-block: var(--space-7);
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
  cursor: grab;
  outline: none;
}
.ms-stage.is-dragging { cursor: grabbing; }
.ms-stage:focus-visible .ms-card.is-active .ms-media { outline: 2px solid var(--accent-blue); outline-offset: 3px; }

/* The hover tease (below) has to transition, so it is a registered,
   typed property; an unregistered custom property would just jump. */
@property --ms-tease { syntax: "<number>"; inherits: true; initial-value: 0; }
.ms-card {
  --m: 0;        /* how merged, linear in distance (drops run on this) */
  --ms-pull: 0;  /* 1 while the card is pulling apart, 0 while merging, set by JS */
  --ms-j: 0;     /* the settle's squash, set per frame by JS (see DESIGN.md, Motion) */
  /* what the drops run on: --m, nudged up while a neighbour is hovered */
  --md: calc(var(--m) + var(--ms-tease) * 0.4 * (1 - var(--m)));
  --me: 0;       /* the same, eased (smoothstep), for the shape: gap, inset, joint corners */
  --ms-far: 0;   /* slides past the first neighbour, 0 to 2, set by JS */
  --ms-gap-now: calc(var(--ms-gap) * (1 - var(--me)) + var(--ms-gap-far) * var(--ms-far));
  /* The corners where the pieces meet: --ms-radius apart, square once
     merged, and rounder than either mid melt (a bump peaking at the
     middle, +10px), so the crisp piece curves into the liquid's fillet
     instead of poking out past it as a hard corner. */
  --ms-joint: calc(var(--ms-radius) * (1 - var(--me)) + 40px * var(--me) * (1 - var(--me)));
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--ms-w);
  margin-left: calc(var(--ms-w) / -2);
  display: flex;
  flex-direction: column;
  transform: translate3d(var(--ms-x, 0px), -50%, 0) scale(var(--ms-s, 1))
    scale(calc(1 + var(--ms-j)), calc(1 - var(--ms-j)));
  will-change: transform;
  transition: --ms-tease 600ms var(--ease-out-quart);
}
/* Hover a neighbour and its pieces reach for each other, partway, then
   let go when you leave: a hint that it will merge if you pick it.
   Only the melting neighbours (the goo is on) and never mid-drag. */
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
  .ms-stage:not(.is-dragging) .ms-card.is-melting:not(.is-active):hover { --ms-tease: 1; }
}
@media (prefers-reduced-motion: reduce) { .ms-card { transition: none; } }
/* While a card is near the middle the whole card runs through #ms-goo,
   so picture, drops and grey share ONE liquid outline (see the filter
   in js/components.js). Further out it's switched off to save the work;
   that happens behind the edge blur, where nothing is melting anyway. */
.ms-card.is-melting { filter: url(#ms-goo); }
.ms-card:not(.is-active) { cursor: pointer; }
.ms-stage.is-dragging .ms-card { cursor: grabbing; }

.ms-media {
  position: relative;
  z-index: 1;
  height: var(--ms-media-h);
  overflow: hidden;
  background: var(--surface-tint);
  border-radius: var(--ms-radius) var(--ms-radius) var(--ms-joint) var(--ms-joint);
}
.ms-img { display: block; width: 100%; height: 100%; object-fit: cover; pointer-events: none; }

.ms-body {
  position: relative;
  z-index: 1;
  margin-top: var(--ms-gap-now);
  margin-inline: calc(var(--ms-inset) * (1 - var(--me)));
  padding: var(--space-4) var(--space-4) var(--space-5);
  background: var(--ms-plate, var(--surface-tint));
  border-radius: var(--ms-joint) var(--ms-joint) var(--ms-radius) var(--ms-radius);
}
.ms-eyebrow { margin: 0 0 var(--space-2); color: var(--ink-4); }
.ms-title {
  margin: 0 0 var(--space-2);
  font-family: var(--font-primary);
  font-size: var(--t-card-title);
  font-weight: 500;
  line-height: 1.25;
  color: var(--ink-1);
}
.ms-text {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink-3);
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: 4.5em;
}

/* The drops, in the gap, under both pieces (seeded per card in
   js/components.js, morphDrops): drips hang from the picture, rises
   lift from the grey, each from its own --x, starting at its own --t,
   reaching at its own speed --k up to --cap halves of the gap. Paired
   drops meet at two or three points that land one after another,
   strays only bulge, and every card melts its own way.

   Liquid is sticky, so apart is not merge played backwards: while a
   card pulls apart (--ms-pull) its drops start reaching earlier, which
   keeps each neck attached to a lower --m, and they thin as they go,
   so the necks stretch into strands before they snap. They run on
   --md, which the hover tease nudges up.

   A drip is the picture itself running: it wears the card's melt
   texture (--ms-melt, painted by js/morph-slider.js: the picture's
   bottom-edge colours carried down, with the same grain), lined up with
   the picture column for column, so where it leaves the picture there
   is no edge, only the outline bending. */
.ms-goo {
  position: absolute;
  z-index: 0;
  left: 0;
  right: 0;
  top: calc(var(--ms-media-h) - var(--ms-goo));
  height: calc(var(--ms-goo) * 2 + var(--ms-gap-now));
  pointer-events: none;
}
.ms-goo__drip,
.ms-goo__rise {
  --size: calc((var(--w) + 0.18 * var(--md)) * (1 - 0.5 * var(--ms-pull) * (1 - var(--md))));   /* width, as a share of the card */
  --reach: calc(var(--ms-gap-now) / 2 * clamp(0, (var(--md) - var(--t) * (1 - 0.7 * var(--ms-pull))) * var(--k), var(--cap) + 0.35 * var(--ms-pull)));
  position: absolute;
  left: calc(var(--x) * 100%);
  width: calc(var(--ms-w) * var(--size));
  height: calc(var(--ms-goo) + var(--reach));
  border-radius: 50%;
  transform: translateX(-50%);
  /* shape only: 0.5 marks a drop so #ms-goo blurs it into the liquid
     but never lays it back crisp (see the filter in js/components.js) */
  opacity: 0.5;
}
.ms-goo__drip {
  top: 0;
  background-color: var(--ms-plate, var(--surface-tint));
  background-image: var(--ms-melt, none);
  background-size: var(--ms-w) auto;
  background-repeat: repeat-y;
  /* the texture's left edge on the card's left edge, whatever the drop's x */
  background-position: calc(var(--ms-w) * (var(--size) / 2 - var(--x))) 0;
}
.ms-goo__rise { bottom: 0; background: var(--ms-plate, var(--surface-tint)); }

/* Focus: the page's edge blur (.case-edge-blur, and its sideways turn
   in the case slider, .case .cs-shot-edge), on the left and right of
   the stage. Each side spans from the screen edge to the selected card,
   strongest at the edge and easing to nothing at the card, so the
   neighbours soften the further out they sit. Same layers, same masks;
   phones keep only the middle layer, as the page strips do. */
.ms-edge {
  position: absolute;
  top: 0;
  bottom: 0;
  z-index: 200;
  width: calc((100% - var(--ms-w)) / 2);
  pointer-events: none;
}
.ms-edge--l { left: 0; --dir: to right; }
.ms-edge--r { right: 0; --dir: to left; }
.ms-edge .pb { position: absolute; inset: 0; -webkit-mask-image: var(--mk); mask-image: var(--mk); }
.ms-edge .pb2 { -webkit-backdrop-filter: blur(1.5px); backdrop-filter: blur(1.5px); --mk: linear-gradient(var(--dir), #000 0%, #000 55%, transparent 80%); }
.ms-edge .pb3 { -webkit-backdrop-filter: blur(3px);   backdrop-filter: blur(3px);   --mk: linear-gradient(var(--dir), #000 0%, #000 35%, transparent 60%); }
.ms-edge .pb4 { -webkit-backdrop-filter: blur(6px);   backdrop-filter: blur(6px);   --mk: linear-gradient(var(--dir), #000 0%, #000 20%, transparent 42%); }
.ms-edge .pb5 { -webkit-backdrop-filter: blur(12px);  backdrop-filter: blur(12px);  --mk: linear-gradient(var(--dir), #000 0%, #000 8%, transparent 25%); }
@media (max-width: 719px) {
  .ms-edge .pb2,
  .ms-edge .pb4,
  .ms-edge .pb5 { display: none; }
}

.ms-ticks { display: flex; justify-content: center; gap: 0; margin-top: var(--space-5); }
.ms-tick {
  display: grid;
  place-items: center;
  width: 10px;   /* hit area; the marks sit 10px apart */
  height: 28px;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: pointer;
}
.ms-tick::before {
  content: "";
  width: 2px;
  height: 10px;
  border-radius: 1px;   /* fully round ends */
  background: var(--ink-4);
  opacity: 0.5;
  transition: height 300ms var(--ease-out-quart), opacity 300ms var(--ease-out-quart), background-color 300ms var(--ease-out-quart);
}
.ms-tick.is-active::before { height: 20px; opacity: 1; background: var(--ink-1); }
@media (hover: hover) and (pointer: fine) { .ms-tick:hover::before { opacity: 1; } }
.ms-tick:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: -2px; border-radius: var(--radius-control); }

@media (prefers-reduced-motion: reduce) {
  .ms-tick::before { transition: none; }
}

/* ════════════════════════════════════════════════════════════════
   RELIEF THEME (Oct 2026, relief-slider.html)
   The morph slider's engine in another skin: data-ms-theme="relief" on
   .ms (js/morph-slider.js sets it from the mount's theme). Soft UI,
   neumorphism: one grey surface, and the cards are that surface, drawn
   only in light and shadow, lit from above the middle of the stage. Off
   centre every card is pressed into the paper; the chosen one rises out
   of it. One card, never two: no gap, no goo, no lens.

   --rl, the lift, is a soft spring per card (js), so it swings a little
   past either end: above 1 the card rides past its height, below 0 it
   sinks past its well. --lean (-1 to 1) is how far left or right of the
   middle the card is; its shadow turns with it.

   Nothing here repaints a big blur per frame. The raised shadows are
   drawn once on .ms-lift and only fade and move; the well is drawn once
   on .ms-well and only fades. The surface (::before) sits between them,
   so the lift layer can slide under it without showing through.
   The stack inside a card: lift 1, surface 2, well 3, picture and words 4.
   ════════════════════════════════════════════════════════════════ */
.ms-lift,
.ms-well { display: none; }

/* The paper and its two lights. Page-scoped tokens, like --ms-paper:
   on the relief page and on the slider itself, so it carries its own
   ground wherever it's mounted. A near neutral grey, barely cool. */
.rs-page,
.ms[data-ms-theme="relief"] {
  --rs-paper: oklch(89.5% 0.003 250);
  --rs-dark: oklch(62% 0.006 250);     /* the shadow side */
  --rs-light: oklch(100% 0 0);         /* the lit side */
}
html[data-theme="dark"] .rs-page,
html[data-theme="dark"] .ms[data-ms-theme="relief"] {
  /* white light on dark grey reads as a glow, so the lit side is only a
     step lighter than the paper */
  --rs-paper: oklch(27% 0.004 250);
  --rs-dark: oklch(13% 0.004 250);
  --rs-light: oklch(36% 0.004 250);
}
html[data-theme="eink"] .rs-page,
html[data-theme="eink"] .ms[data-ms-theme="relief"] { --rs-paper: var(--surface-base); }
.rs-page { background: var(--rs-paper); }

.ms[data-ms-theme="relief"] {
  --ms-gap: 0px;               /* one card: the picture and words never part */
  --ms-gap-far: 0px;
  --ms-inset: 0px;
  --ms-air: var(--space-6);    /* room for the raised shadow (js/morph-slider.js reads it) */
  /* Corners: a scoped exception like --ms-radius (DESIGN.md). Soft UI
     needs a softer corner than --radius-content; the picture's is about
     the card's minus its rim, so the two curves run parallel. */
  --rs-r: 14px;
  --rs-r-in: 8px;
  --rs-rim: var(--space-2);
}
.ms[data-ms-theme="relief"] .ms-stage {
  filter: none;
  background: var(--rs-paper);
}
.ms[data-ms-theme="relief"] .ms-goo,
.ms[data-ms-theme="relief"] .ms-edge .pb { display: none; }
/* The sides fade into the paper instead of blurring: a blur smears soft
   shadows into smudges. */
.ms[data-ms-theme="relief"] .ms-edge {
  background: linear-gradient(var(--dir), var(--rs-paper) 8%, transparent);
}
.ms[data-ms-theme="relief"] .ms-stage:focus-visible .ms-card.is-active .ms-media { outline: none; }
.ms[data-ms-theme="relief"] .ms-stage:focus-visible .ms-card.is-active { outline: 2px solid var(--accent-blue); outline-offset: 6px; }
/* Once the arrow keys are steering it, the card moving is the feedback
   and the ring steps aside (js/morph-slider.js, .is-keyed, until blur).
   Tabbing in still shows it, so you can see where focus landed. */
.ms-stage.is-keyed:focus-visible .ms-card.is-active .ms-media,
.ms[data-ms-theme="relief"] .ms-stage.is-keyed:focus-visible .ms-card.is-active { outline: none; }

.ms[data-ms-theme="relief"] .ms-card {
  --rl: 0;
  --lean: 0;
  /* how sunk: 1 at rest in its well, more while pushed past it */
  --ud: max(0, calc(1 - var(--rl) * 1.6));
  --u: min(1, var(--ud));
  /* how lifted: from flush up, past 1 while it rides over */
  --kk: max(0, calc((var(--rl) - 0.38) * 1.6));
  --k: min(1, var(--kk));
  /* how far past its well it's pushed */
  --press: max(0, calc(var(--rl) * -1));
  padding: var(--rs-rim);
  border-radius: var(--rs-r);
  transform: translate3d(var(--ms-x, 0px), -50%, 0) scale(var(--ms-s, 1))
    translateY(calc(var(--kk) * -6px))
    scale(calc(1 + var(--kk) * 0.03 - var(--press) * 0.03));
}
.ms[data-ms-theme="relief"] .ms-card.is-melting { filter: none; }
/* the surface: the paper itself, with a hairline lip that never goes,
   so at flush (mid drag, no other shadow) it's a shape rising, not a hole */
.ms[data-ms-theme="relief"] .ms-card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  border-radius: inherit;
  background: var(--rs-paper);
  box-shadow:
    inset 1px 1px 0 color-mix(in oklch, transparent, var(--rs-light) 70%),
    inset -1px -1px 0 color-mix(in oklch, transparent, var(--rs-dark) 22%);
}
.ms[data-ms-theme="relief"] .ms-lift,
.ms[data-ms-theme="relief"] .ms-well {
  display: block;
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
}
/* raised: a big soft pair and a tight pair, so it reads as a slab with
   an edge, not a glow. It stays on the ground as the card rises (the
   card goes up 6px, this comes back down 8px, so the shadow reaches
   further), turns with the light, and spreads as it rides past its height.
   The layer is 12px smaller than the card on every side, with the shadows
   spread 12px back out to the card's size: its own box (which a shadow
   never fills) has to stay under the surface while it slides, or a strip
   of bare paper shows beside the card. */
.ms[data-ms-theme="relief"] .ms-lift {
  z-index: 1;
  inset: 12px;
  border-radius: 2px;
  box-shadow:
    22px 22px 48px 12px color-mix(in oklch, transparent, var(--rs-dark) 75%),
    -22px -22px 48px 12px var(--rs-light),
    6px 6px 12px 12px color-mix(in oklch, transparent, var(--rs-dark) 45%),
    -5px -5px 10px 12px var(--rs-light);
  opacity: var(--k);
  transform: translate(calc(var(--lean) * 10px), calc(var(--kk) * 8px))
    scale(calc(1 + max(0, var(--kk) - 1) * 0.5));
  will-change: transform, opacity;
}
/* pressed: the same pair turned inward, darker while pushed past the well */
.ms[data-ms-theme="relief"] .ms-well {
  z-index: 3;
  box-shadow:
    inset 12px 12px 26px color-mix(in oklch, transparent, var(--rs-dark) 85%),
    inset -12px -12px 26px var(--rs-light);
  opacity: min(1, calc(var(--u) * 0.8 + var(--press) * 1.5));
  will-change: opacity;
}

/* The picture sits below the card, seen through a window cut in it: the
   window's top and left edges throw shade down onto it, its bottom and
   right edges catch the light. Pressed, it's a faded grey print; lifted,
   it's in full colour, still under the frame. */
.ms[data-ms-theme="relief"] .ms-media,
.ms[data-ms-theme="relief"] .ms-body { z-index: 4; }
.ms[data-ms-theme="relief"] .ms-media {
  border-radius: var(--rs-r-in);
  background: var(--rs-paper);
}
.ms[data-ms-theme="relief"] .ms-media::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  box-shadow:
    inset 4px 4px 9px color-mix(in oklch, transparent, var(--rs-dark) 60%),
    inset -3px -3px 7px color-mix(in oklch, transparent, var(--rs-light) 70%),
    inset 0 0 0 0.5px color-mix(in oklch, transparent, var(--rs-dark) 35%);   /* the cut's lip */
}
.ms[data-ms-theme="relief"] .ms-img {
  filter: grayscale(calc(1 - var(--k))) contrast(calc(0.5 + var(--k) * 0.5)) brightness(calc(1.25 - var(--k) * 0.25));
  opacity: calc(0.35 + var(--k) * 0.65);
}
.ms[data-ms-theme="relief"] .ms-body {
  margin: 0;
  padding: var(--space-3) var(--space-2) var(--space-1);
  border-radius: 0;
  background: none;
}
/* the words: the paper's own colour, debossed, until the card lifts and
   they ink */
.ms[data-ms-theme="relief"] .ms-eyebrow,
.ms[data-ms-theme="relief"] .ms-title,
.ms[data-ms-theme="relief"] .ms-text {
  color: color-mix(in oklch, var(--rs-paper), var(--rs-ink) calc(var(--k) * 100%));
  text-shadow:
    0 1px 0 color-mix(in oklch, transparent, var(--rs-light) calc(var(--u) * 100%)),
    0 -1px 0 color-mix(in oklch, transparent, var(--rs-dark) calc(var(--u) * 45%));
}
/* --ink-4 is about 4.2:1 on this grey, under AA for small type; --ink-3
   is about 5.5:1 (and 5.3:1 in dark mode), so the quiet lines take it */
.ms[data-ms-theme="relief"] .ms-eyebrow { --rs-ink: var(--ink-3); }
.ms[data-ms-theme="relief"] .ms-title { --rs-ink: var(--ink-1); }
.ms[data-ms-theme="relief"] .ms-text { --rs-ink: var(--ink-3); }

/* The stepper: small pressed dots, the current one raised */
.ms[data-ms-theme="relief"] .ms-tick { width: 18px; }
.ms[data-ms-theme="relief"] .ms-tick::before {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--rs-paper);
  opacity: 1;
  box-shadow:
    inset 1.5px 1.5px 3px color-mix(in oklch, transparent, var(--rs-dark) 70%),
    inset -1.5px -1.5px 3px var(--rs-light);
  transition: box-shadow 300ms var(--ease-out-quart), transform 300ms var(--ease-out-quart);
}
.ms[data-ms-theme="relief"] .ms-tick.is-active::before {
  height: 8px;
  background: var(--rs-paper);
  transform: scale(1.2);
  box-shadow:
    2px 2px 4px color-mix(in oklch, transparent, var(--rs-dark) 60%),
    -2px -2px 4px var(--rs-light);
}
@media (prefers-reduced-motion: reduce) {
  .ms[data-ms-theme="relief"] .ms-tick::before { transition: none; }
}

/* E-ink has no depth (DESIGN.md, Themes): the relief goes flat. Sunk is
   a hairline outline, lifted a solid one; the picture keeps its colour
   (the work stays in colour), only fading while sunk. */
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-lift,
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-well { display: none; }
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-card::before {
  box-shadow: inset 0 0 0 calc(1px + var(--k) * 1px) color-mix(in oklch, var(--ink-4), var(--ink-1) calc(var(--k) * 100%));
}
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-media::after { box-shadow: inset 0 0 0 1px var(--ink-4); }
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-img { filter: none; }
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-eyebrow,
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-title,
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-text {
  color: color-mix(in oklch, var(--ink-4), var(--rs-ink) calc(var(--k) * 100%));
  text-shadow: none;
}
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-tick::before { box-shadow: inset 0 0 0 1px var(--ink-4); }
html[data-theme="eink"] .ms[data-ms-theme="relief"] .ms-tick.is-active::before { background: var(--ink-1); box-shadow: none; }

/* ── EXPERIMENTS!!! (the playground's homepage card, Oct 2026) ──────────────
   The card's title is EXPERIMENTS!!!, and only the three marks are
   --accent-blue (the one accent, as type, sparingly). They stay still. */
.card__bangs { color: var(--accent-blue); }

/* ── A live piece in the Playground popup (Oct 2026) ──────────────────
   The morph slider runs in the popup itself, on its own beige page
   (.ms-page) in a content frame. It's sized by its frame, not the
   window: the frame is a size container and the cards are 39% of it,
   so neighbours always peek in at the sides. */
.cs-panel--split .cs-piece__live {
  container-type: inline-size;
  /* its height is set by js/casestudy.js (fitLive); the slider sits
     centred in whatever room that leaves */
  display: flex;
  flex-direction: column;
  justify-content: center;
  border-radius: var(--radius-content);
  overflow: hidden;
}
.cs-piece__live .ms { --ms-w: min(39cqi, 240px); }   /* zoomed out a little (Oct 2026): more of the neighbours in view */
.cs-piece__live .ms-ticks { margin: 0; padding-bottom: var(--space-4); }
/* A narrow frame (a phone) packs the slider down so the frame isn't a
   tall column: a smaller card with its neighbours peeking in, the
   split's gaps cut to about two thirds (half was too tight, Oct 2026),
   and the words two lines, with --ms-air giving the card room above
   and below.
   js/morph-slider.js reads the gaps from here. */
@container (max-width: 520px) {
  .cs-piece__live .ms {
    --ms-w: 58cqi;
    --ms-gap: var(--space-6);
    --ms-gap-far: var(--space-4);
    --ms-spacing: var(--space-4);
    --ms-air: var(--space-8);       /* extra room above and below the card (js/morph-slider.js) */
  }
  .cs-piece__live .ms-stage { padding-block: var(--space-6); }
  .cs-piece__live .ms-body { padding: var(--space-4) var(--space-4) var(--space-5); }
  .cs-piece__live .ms-eyebrow { margin-bottom: var(--space-1); }
  .cs-piece__live .ms-title { margin-bottom: var(--space-1); }
  .cs-piece__live .ms-text { -webkit-line-clamp: 2; min-height: 3em; }
  /* the stepper rides up into the card's room below (over the stage's
     foot, so it's lifted above the stage), and the room goes under it */
  .cs-piece__live .ms-ticks {
    position: relative;
    z-index: 1;
    margin-top: calc(var(--space-7) * -1);
    padding-bottom: var(--space-7);
  }
}
/* A piece that runs live in a frame (the wishing well, well.html): the page fills the video's
   slot, so it takes the same fitted height as the rest. */
.cs-panel--split .cs-piece__frame { background: var(--surface-base); }
.cs-panel--split .cs-piece__frame iframe {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}

/* while it demos itself the cursor still says it can be taken */
.ms-stage.is-demo { cursor: grab; }

/* Every piece in the popup as tall as the live one's frame, which
   js/casestudy.js (fitLive) sizes to the column's first view and
   publishes as --cs-piece-h. Their stills and videos fill it by
   cropping (object-fit: cover), so the video drops its own 5:4. */
.cs-panel--split .cs-demos.is-fit .cs-piece__media { height: var(--cs-piece-h); }
.cs-panel--split .cs-demos.is-fit .cs-piece__video { aspect-ratio: auto; }

/* Video-only pieces (Cake, Neverland) take a full row like the rest:
   at the fitted height, half a row cropped their 5:4 videos too close. */
.cs-panel--split .cs-demos.is-fit .cs-piece--solo { grid-column: 1 / -1; }
/* With every piece one view tall, the column snaps piece to piece, so
   a scroll always comes to rest on a whole piece (scroll-margin-top on
   .cs-piece keeps the column's top padding above it). */
.cs-panel--split .cs-content:has(> .cs-demos.is-fit),
.cs-panel--split .cs-content:has(.cs-demos.is-fit) { scroll-snap-type: y mandatory; }
.cs-panel--split .cs-demos.is-fit .cs-piece { scroll-snap-align: start; }

