/* ════════════════════════════════════════════════════════════════
   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 */

  /* 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. */
  --font-primary: "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-card-title: 16px;
  --t-display-sm: clamp(120px, 22vh, 260px);

  /* 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;
  --brand-circles:  #413CAD;  /* Circles' own brand violet: its case chart only, see DESIGN.md */
  --radius-bar:     6px;   /* chart bar tops only; see DESIGN.md content exceptions */

  /* 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; }
}

@keyframes enter-down {
  from { opacity: 0; translate: 0 -14px; }
  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="4"] { animation: enter-up 580ms var(--ease-out-expo) 360ms both; } /* TarotNook */
  .card[data-card="3"] { animation: enter-up 580ms var(--ease-out-expo) 420ms both; } /* Ok-bye    */
  .card[data-card="5"] { animation: enter-up 580ms var(--ease-out-expo) 480ms both; } /* Friday    */

  .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. */
  .cat-mascot { animation: enter-up 580ms var(--ease-out-expo) 240ms both; }
}

/* 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,
html.is-return .cat-mascot { 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;
}

.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. */
.hero-type {
  display: inline-grid;
  white-space: pre;
}
.hero-type__ghost,
.hero-type__live {
  grid-area: 1 / 1;
  text-align: center;
}
.hero-type__ghost { visibility: hidden; }
.hero-type__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.hero-type__word {
  position: relative;
  display: inline-block;
}
/* 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. */
.hero-type__word { line-height: 1.2; }
.hero-type__text { position: relative; }
.hero-type__sel {
  position: absolute;
  inset: -0.06em -0.18em;
  border-radius: var(--radius-control);
  background: var(--selection-wash);
  scale: 0 1;
  transform-origin: right;
  pointer-events: none;
}
.hero-type__live[data-selecting="true"] .hero-type__sel {
  scale: 1 1;
  transition: scale 220ms var(--ease-out-expo);
}
.hero-type__live[data-selecting="true"] .hero-type__text {
  color: var(--accent-blue);
}
/* 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. */
.hero-type__caret {
  display: inline-block;
  width: 2px;
  height: 1.05em;
  margin-left: 2px;
  vertical-align: -0.16em;
  background: var(--ink-1);
  animation: hero-type-blink 1.06s linear infinite;
}
.hero-type__live[data-typing="true"] .hero-type__caret { animation: none; }
.hero-type__live[data-selecting="true"] .hero-type__caret { visibility: hidden; }
@keyframes hero-type-blink {
  0%, 45%  { opacity: 1; }
  55%, 95% { opacity: 0; }
  100%     { opacity: 1; }
}
/* Each key lands with a short ink-in, so it reads as struck, not popped. */
.hero-type__letter { animation: hero-type-ink 140ms var(--ease-out-expo); }
@keyframes hero-type-ink {
  from { opacity: 0; filter: blur(2px); }
}
/* 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. */
.hero-type__stack { display: inline-grid; }
.hero-type__phrase {
  grid-area: 1 / 1;
  opacity: 0;
  transition: opacity 500ms var(--ease-out-soft);
}
.hero-type__phrase.is-on { opacity: 1; }
/* An e-ink panel repaints in steps and can't show a blur, so keys just land. */
html[data-theme="eink"] .hero-type__letter { animation: none; }

.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: 600;
  font-size: var(--t-eyebrow);
  letter-spacing: 0.1em;
  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="5"] .card__frame { aspect-ratio: 16 / 10; } /* FRIDAY    — landscape */
.card[data-card="6"] .card__frame { aspect-ratio: 5 / 6;   } /* 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;
  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;
  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: 600;
  font-size: var(--t-eyebrow);
  letter-spacing: 0.1em;
  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-small);
  line-height: 1.55;
  color: var(--ink-3);
  text-wrap: pretty;
  max-width: 60ch;
}

/* ── 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: 700;
  letter-spacing: 0.11em;
  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;
  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-list {
  display: flex;
  flex-direction: column;
}

/* .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. */
.resume-row {
  display: grid;
  row-gap: 3px;
  padding: 9px 0;
}

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

/* Separator between the visible row and the expanded more-rows */
.resume-more-inner .resume-list {
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 11%, transparent);
}

/* 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. */
.resume-row__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: 12px;
  row-gap: 2px;
}

/* 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. */
.resume-row__title,
.overview-about__title {
  font-family: var(--font-primary);
  font-size: var(--t-eyebrow);
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  /* .eyebrow's own ink-4 read too quiet as a section header on its own
     line rather than a caption beside a value (which is what INFO's
     labels are) — ink-1 keeps the eyebrow's shape (size, weight,
     tracking, case) but stands out the way a header should. */
  color: var(--ink-1);
}

.resume-row__title {
  flex: 1 1 auto;
  min-width: 0;
}

.resume-row__sub {
  font-family: var(--font-body);
  font-size: var(--t-meta);
  line-height: 1.3;
  color: var(--ink-3);
}

.resume-row__date {
  /* Pushes to the row's right edge when it shares the line with title
     (mimics justify-content: space-between without needing it on the
     shared axis), and keeps that same right alignment on its own wrapped
     line, so a dropped date still reads as paired with the row rather
     than stranded at the left margin. */
  margin-left: auto;
  flex: 0 0 auto;
  font-family: var(--font-primary);
  font-weight: 600;
  font-size: var(--t-meta);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-4);
  white-space: nowrap;
}

/* ── 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). */
.bio-quirk {
  position: relative;
  white-space: nowrap;   /* keep the colon married to the word */
}
.bio-quirk__ink {
  position: absolute;
  left: -1px;
  bottom: -5px;
  width: calc(100% + 3px);
  height: 8px;
  overflow: visible;
  pointer-events: none;
}
.bio-quirk__ink path {
  fill: none;
  stroke: var(--accent-blue);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* 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 .bio-quirk__ink path {
  stroke-dasharray: 1 2;
  stroke-dashoffset: 1;
}
.bio-letter__sheet .bio-quirk__ink path {
  transition: stroke-dashoffset 480ms linear;
}
.bio-letter.is-signed .bio-letter__sheet .bio-quirk__ink path {
  stroke-dashoffset: 0;
}
@media (prefers-reduced-motion: reduce) {
  .bio-letter .bio-quirk__ink path { transition: none; stroke-dashoffset: 0; }
}

/* ── 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. */
.bio-letter {
  position: fixed;
  inset: 0;
  z-index: 520;                     /* over the dock (400) and modal (500) */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
  pointer-events: none;
  visibility: hidden;
}
.bio-letter.is-mounted { pointer-events: auto; visibility: visible; }
body.bio-letter-open { overflow: hidden; }
.bio-letter__scrim {
  position: absolute;
  inset: 0;
  background: color-mix(in oklab, var(--ink-1) 32%, transparent);
  backdrop-filter: blur(5px);
  -webkit-backdrop-filter: blur(5px);
  opacity: 0;
  transition: opacity 380ms var(--ease-out-soft);
  overscroll-behavior: none;
}
.bio-letter.is-mounted .bio-letter__scrim { opacity: 1; }
/* 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. */
.bio-letter.is-closing .bio-letter__scrim { opacity: 0; transition-duration: 520ms; }
.bio-letter__box {
  position: relative;
  width: var(--bl-w, 560px);
  opacity: 0;
  transform: translateY(14px);
  transition:
    opacity 360ms var(--ease-out-expo),
    transform 360ms var(--ease-out-expo);
}
.bio-letter.is-mounted .bio-letter__box { opacity: 1; transform: translateY(0); }
/* 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. */
.bio-letter.is-closing .bio-letter__box {
  opacity: 0;
  transform: translateY(8px);
  transition-duration: 220ms;
}
/* 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. */
.bio-letter.is-closing .bio-letter__close {
  opacity: 0;
  transition: opacity 200ms var(--ease-out-quart);
}
/* 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. */
.bio-letter__plate-ghost {
  position: absolute;
  top: 0;
  left: 0;
  transform-origin: top left;
  z-index: 4;
  opacity: 0;
  pointer-events: none;
  transition: opacity 300ms var(--ease-out-quart) 120ms;
}
.bio-letter.is-closing .bio-letter__plate-ghost { opacity: 1; }
/* 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. */
.bio-letter__close {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 3;
}
/* 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. */
.bio-letter__sign {
  margin-top: 0;
  text-align: center;
}
.bio-letter__sign-note {
  margin: 0;
  color: var(--ink-3);
}

/* 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. */
.bio-letter__page p {
  font-size: 11px;
  line-height: 1.55;
  color: var(--ink-3);
}

/* ── 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. */
.bio-letter__page .rcpt-totals,
.bio-letter__page .rcpt-fineprint {
  font-family: var(--font-mono);
  letter-spacing: -0.01em;
}

/* 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. */
.bio-letter__page .rcpt-rule {
  height: 1px;
  /* 8px, down from 12px: four of these margin-edges stack up across the
     three rules on this receipt, and it's whitespace, not content — the
     cheapest place to buy back height without touching a single line
     of type. */
  margin: 8px 0;
  background-image: repeating-linear-gradient(
    to right,
    color-mix(in oklab, var(--ink-1) 34%, transparent) 0 4px,
    transparent 4px 8px
  );
}

/* "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. */
.bio-letter__page .rcpt-line {
  display: flex;
  align-items: baseline;
  gap: 6px;
  font-size: 9.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.bio-letter__page .rcpt-line + .rcpt-line { margin-top: 3px; }
.bio-letter__page .rcpt-line__v { color: var(--ink-2); }

/* "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. */
.bio-letter__page .rcpt-line--total .rcpt-line__k {
  font-family: var(--font-primary);
  font-size: var(--t-eyebrow);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-1);
}
/* 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. */
.bio-letter__page .rcpt-line--total .rcpt-line__v {
  margin-left: auto;
}

/* 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. */
.resume-row__head { gap: 6px; }
.resume-row__head::before {
  content: "";
  order: 1;
  flex: 1 1 auto;
  align-self: center;
  height: 1px;
  min-width: 12px;
  background-image: repeating-linear-gradient(
    to right,
    color-mix(in oklab, var(--ink-1) 30%, transparent) 0 1px,
    transparent 1px 4px
  );
}
.resume-row__title { flex: 0 0 auto; }
.resume-row__date  { order: 2; }

/* The foot: totals, thank-you, signature, barcode, small print. */
.bio-letter__page .rcpt-totals { margin-top: 2px; }

/* 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. */
.bio-letter__page .bio-letter__sign-words { margin-top: 12px; }

/* 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. */
.bio-letter__page .overview-career__section + .overview-career__section {
  margin-top: 12px;
}
.bio-letter__page .overview-about {
  margin-top: 2px;   /* + the shared .overview-text flex gap (10px) = 12px */
}

/* 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. */
.bio-letter__page .overview-about p {
  font-size: var(--t-meta);
  line-height: 1.3;
  color: var(--ink-3);
}

/* 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). */
.bio-letter__page .rcpt-barcode {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 2px;
  height: 24px;
  margin-top: 10px;
}
.bio-letter__page .rcpt-barcode i {
  display: block;
  height: 100%;
  background: var(--ink-1);
}

.bio-letter__page .rcpt-fineprint {
  margin-top: 8px;
  text-align: center;
  font-size: 8.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-4);
}

/* 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. */
.bl-sig {
  display: block;
  width: 86px;
  height: auto;
  margin: 4px auto 0;   /* text-align:center on the parent won't center a block; this does */
}
.bl-sig__path {
  stroke: var(--accent-blue);
  /* "1 2", not "1": with pathLength=1 a plain `1` is a 1-on/1-off
     pattern, and a ROUND linecap overshoots each dash boundary by half
     the stroke-width — so a couple of caps bled through as faint dots
     even at full offset, visible during the open-but-unsigned beat. The
     wider gap keeps every cap clear of the drawn region. */
  stroke-dasharray: 1 2;
  stroke-dashoffset: 1;
}
.bio-letter__sheet .bl-sig__path {
  /* linear, deliberately: ink leaves a moving pen at a roughly constant
     rate, which is DESIGN.md's documented exception for constant-rate
     motion. An ease-out here made every one of the 27 strokes lurch
     then crawl to a stop, which is precisely what read as robotic. The
     human rhythm lives in the GAPS between strokes instead, sized from
     real pen-lift distance (see the pen model in js/bio-letter.js). */
  transition: stroke-dashoffset var(--sig-dur, 24ms) linear var(--sig-delay, 0ms);
}
/* 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. */
.bio-letter.is-signed .bio-letter__sheet .bl-sig__path {
  stroke-dashoffset: 0;
}
/* 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. */
.bio-letter.is-refolding .bio-letter__theater .bl-sig__path,
.bio-letter.is-refolding .bio-letter__theater .bio-quirk__ink path {
  stroke-dashoffset: 0;
  transition: none;
}
@media (prefers-reduced-motion: reduce) {
  .bl-sig__path { stroke-dashoffset: 0; transition: none; }
}
/* 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. */
.bio-letter__stage {
  position: relative;
  height: calc(var(--bl-ph, 160px) * 3);
}
.bio-letter.is-settled .bio-letter__stage { height: auto; }

/* ── Act one: the fold theater ── */
.bio-letter__theater { position: absolute; inset: 0 0 auto; }
/* 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. */
.bio-letter.is-swap-done .bio-letter__theater,
.bio-letter.is-measuring .bio-letter__theater { display: none; }
.blf { transform-style: preserve-3d; }
.blf--1 {
  position: relative;
  height: var(--bl-ph);
  perspective: 1100px;
}
.blf--2,
.blf--3 {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  height: var(--bl-ph);
  transform-origin: top center;
  /* Folded up FLAT behind the panel above. A full 180deg, not the 178
     this started at: the 2deg peek left a ~7px sliver of shaded
     paper-back poking out below the packet, which made the resting
     rectangle read as a 3D object instead of the flat plate it just
     lifted out of. The translateZ (in the rotated frame, so it pushes
     BEHIND the sheet) is what makes exact-180 safe — without it the
     folded plane is coplanar with the front panel inside the
     preserve-3d context and the two z-fight. Both states carry the
     same rotateX+translateZ function list so the hinge interpolates
     per-function, never through a matrix decomposition. */
  /* Quart at 640ms, not the 560ms expo this opened with (August 2026):
     a back-fold's unfold is HIDDEN until the flap passes edge-on, and
     expo front-loads so hard that each flap burst through its hidden
     half and popped into view already at peak speed — the unfolding
     read as flaps appearing abruptly rather than swinging open. Quart
     is the gentlest entry in the token set (initial slope 4 vs expo's
     6.25) and the longer run spreads the visible descent instead of
     blast-then-crawl. OPEN direction only — the close's folds run on
     the is-refolding keyframe animations, which override this. */
  transition: transform 640ms var(--ease-out-quart);
  perspective: 1100px;
}
/* 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. */
.blf--2 { transform: rotateX(-180deg) translateZ(2px); }
.blf--3 { transform: rotateX(-180deg) translateZ(1px); }
.bio-letter.is-f2 .blf--2 { transform: rotateX(0deg) translateZ(0); }
.bio-letter.is-f3 .blf--3 { transform: rotateX(0deg) translateZ(0); }
/* 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. */
@keyframes blf-unfold-2 {
  from { transform: rotateX(-180deg) translateZ(2px); }
  to   { transform: rotateX(0deg) translateZ(0); }
}
@keyframes blf-unfold-3 {
  from { transform: rotateX(-180deg) translateZ(1px); }
  to   { transform: rotateX(0deg) translateZ(0); }
}
.bio-letter.is-refolding .blf--3 {
  /* 140ms delay = the sheet's fade-out (see the cross-fade rules): the
     first fold starts moving just as the sheet finishes dissolving
     into the theater, so the close reads swap-then-fold as one
     breath, and the fold never rotates underneath a still-solid
     sheet where its start would be half-hidden. */
  animation: blf-unfold-3 560ms var(--ease-out-soft) 140ms reverse both;
}
.bio-letter.is-refolding .blf--2 {
  animation: blf-unfold-2 560ms var(--ease-out-soft) 340ms reverse both;
}
.bio-letter.is-refolding .blf__shade {
  transition: opacity 480ms var(--ease-out-quart);
}
/* --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. */
.blf__face {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: var(--surface-deep);
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}
.blf__face--back {
  transform: rotateX(180deg);
  /* Paper back: a shade darker toward the hinge, so a closed fold reads
     as the underside of the sheet rather than a blank card. */
  background:
    linear-gradient(to top, color-mix(in oklab, var(--ink-1) 9%, transparent), transparent 70%)
    var(--surface-deep);
}
/* 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). */
.blf__shade {
  position: absolute;
  inset: 0;
  background: color-mix(in oklab, var(--ink-1) 14%, transparent);
  opacity: 1;
  transition: opacity 640ms var(--ease-out-quart);
  pointer-events: none;
}
.bio-letter.is-f2 .blf--2 > .blf__face .blf__shade,
.bio-letter.is-f3 .blf--3 > .blf__face .blf__shade { opacity: 0; }
/* Each window translates a full page clone so its third lines up. */
.blf__win { position: absolute; inset: 0 0 auto; }
.blf__win--2 > .bio-letter__page { transform: translateY(calc(var(--bl-ph) * -1)); }
.blf__win--3 > .bio-letter__page { transform: translateY(calc(var(--bl-ph) * -2)); }

/* ── Act two: the settled sheet ── */
.bio-letter__sheet {
  display: none;
  /* Positioned, deliberately: the theater is position:absolute, and a
     positioned element paints above any non-positioned sibling no
     matter the DOM order — an unpositioned sheet would run its whole
     settle cross-fade INVISIBLY beneath the opaque theater and the
     swap would still be a cut. Positioned (and the later sibling),
     the sheet genuinely fades in on top. */
  position: relative;
  background: var(--surface-deep);   /* the plate's paper — see .blf__face */
  /* 88vh, up from 82vh: the dialog itself (.bio-letter) is a flex-
     centered overlay with its own 16px padding, so that padding alone
     already guarantees clearance at the top and bottom of the
     viewport — the extra 6% below 100 was margin on top of margin.
     Keep in step with the matching 0.88 in js/bio-letter.js measure(),
     which sizes the fold panels off this same fraction. */
  max-height: 88vh;
  overflow: auto;
  overscroll-behavior: contain;
  /* The creases the folds leave behind: hairlines at the fold thirds.
     3%, not the 8% this started at — the lines cross mid-paragraph, and
     8% was reading as a rule under the text, not a faint paper crease. */
  background-image:
    linear-gradient(color-mix(in oklab, var(--ink-1) 3%, transparent), color-mix(in oklab, var(--ink-1) 3%, transparent)),
    linear-gradient(color-mix(in oklab, var(--ink-1) 3%, transparent), color-mix(in oklab, var(--ink-1) 3%, transparent));
  background-repeat: no-repeat;
  background-size: 100% 1px, 100% 1px;
  background-position: 0 var(--bl-ph), 0 calc(var(--bl-ph) * 2);
}
/* 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. */
.bio-letter.is-settling .bio-letter__sheet,
.bio-letter.is-measuring .bio-letter__sheet { display: block; }
.bio-letter.is-settling .bio-letter__sheet {
  opacity: 0;
  transition: opacity 220ms var(--ease-out-quart);
}
.bio-letter.is-settling.is-settled .bio-letter__sheet { opacity: 1; }
.bio-letter.is-refolding .bio-letter__sheet {
  transition: opacity 140ms var(--ease-out-quart);
}
/* 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). */
.bio-letter.is-settling:not(.is-swap-done) .bio-letter__sheet {
  box-shadow: none;
}
.bio-letter.is-swap-done .bio-letter__sheet {
  transition: opacity 140ms var(--ease-out-quart);
}
.bio-letter.is-measuring {
  visibility: hidden;
  pointer-events: none;
}
.bio-letter__sheet:focus-visible { outline: 2px solid var(--ink-stamp); outline-offset: 3px; }
.bio-letter__page {
  position: relative;
  /* Ceiling dropped 36px -> 28px: this content now runs long enough
     (career rows, NOTES, Total, Card #, Auth code, the sign-off) that a
     real 13" laptop's browser chrome can eat enough of the viewport to
     push .bio-letter__sheet's max-height below the letter's own
     content height, which is what a scrollbar on a "big enough" screen
     actually meant. 8px back on every side, twice over (left+right),
     buys a little text width too, which thins the wrap on the longer
     bio lines for free. */
  padding: clamp(20px, 3.4vw, 28px);
  /* Head margin always clears the ✕ pocket (10px inset + 28px chip),
     so the first line never runs under it at narrow paddings. */
  padding-top: max(42px, clamp(20px, 3.4vw, 28px));
}
/* 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. */
@media (max-width: 767px) {
  .bio-letter__page { padding-top: 62px; }
}
/* 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. */
.blf__face--front,
.bio-letter__sheet {
  box-shadow:
    0 2px 5px -2px transparent,
    0 10px 22px -14px transparent;
  transition: box-shadow 460ms var(--ease-out-quart);
}
.bio-letter.is-f2 .blf__face--front,
.bio-letter.is-f2 .bio-letter__sheet {
  box-shadow:
    0 2px 5px -2px color-mix(in oklab, var(--ink-1) 10%, transparent),
    0 10px 22px -14px color-mix(in oklab, var(--ink-1) 26%, transparent);
}

/* Reduced motion: no folds, no travel — the letter simply fades in
   already open, and fades out. */
@media (prefers-reduced-motion: reduce) {
  .bio-letter__box { transform: none; transition: opacity 200ms var(--ease-out-quart); }
  .bio-letter__scrim { transition: opacity 200ms var(--ease-out-quart); }
  .blf--2, .blf--3, .blf__shade { transition: none; animation: none; }
  .bio-letter__sheet { transition: none; }
}

/* 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). */
html[data-theme="dark"] .bio-letter.is-f2 .blf__face--front,
html[data-theme="dark"] .bio-letter.is-f2 .bio-letter__sheet {
  box-shadow:
    0 2px 5px -2px rgba(0, 0, 0, 0.4),
    0 10px 22px -14px rgba(0, 0, 0, 0.62);
}
/* 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. */
html[data-theme="dark"] .bio-letter.is-settling:not(.is-swap-done) .bio-letter__sheet {
  box-shadow: none;
}

/* 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. */
html[data-theme="eink"] .bio-letter.is-f2 .blf__face--front,
html[data-theme="eink"] .blf__face--front {
  box-shadow: none;
}
html[data-theme="eink"] .bio-letter.is-f2 .bio-letter__sheet,
html[data-theme="eink"] .bio-letter__sheet {
  box-shadow: none;
}
html[data-theme="eink"] .bio-letter__sheet {
  border: 1px solid color-mix(in oklab, var(--ink-1) 18%, transparent);
}

/* ── 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. */
.overview-career {
  display: flex;
  flex-direction: column;
  gap: 0;
}

/* 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. */
.overview-career__section + .overview-career__section {
  margin-top: 16px;
}

/* 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. */

.overview-career__section {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

/* 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. */
.overview-career__section .resume-row:first-child {
  padding-top: 0;
}

.overview-career__section .resume-row:last-child {
  padding-bottom: 0;
}

/* ── Rail name (above manifesto) ───────────────────────────── */
/* ════════════════════════════════════════════════════════════════
   CAT MASCOT — pixel sprite contained below the info section
   ──────────────────────────────────────────────────────────────── */
.cat-stage {
  display: none; /* stage no longer used — cat is fixed */
}

/* The cat rides the overview card and must scroll WITH the page, on every
   breakpoint. Absolute positioning lets the browser composite her scroll
   natively, in sync with the card. (Fixed + a per-frame translateY chases
   the scroll on the main thread and lags a frame behind, which reads as
   glitchy.) JS sets `top` in page coordinates on load + resize; the walk
   is a horizontal transform only. */
.cat-mascot {
  position: absolute;
  left: 0;
  width: 64px;
  height: 64px;
  background-image: url('../images/Cat Sprite Sheet.png');
  background-size: 512px 640px; /* 256×320 at 2× */
  background-repeat: no-repeat;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  opacity: 1;
  pointer-events: auto;
  cursor: pointer;
  z-index: 105;
  transform-origin: center bottom;
  /* Clip faint artifact row at top of sprite frames */
  clip-path: inset(6px 0 0 0);
}

/* ════════════════════════════════════════════════════════════════
   CAT — feed button, food bowl, speech bubble
   ──────────────────────────────────────────────────────────────── */

/* The feed button now lives in the dock (see THE DOCK below); its
   cooldown state is styled there. */
.cs-view-btn:active { transform: scale(0.97); }

/* Food bowl — pixel art image, positioned via JS */
.food-bowl {
  position: absolute;
  bottom: 0;
  left: 0;           /* JS sets exact left */
  pointer-events: none;
  z-index: 106;      /* above cat (105) */
  transform-origin: bottom center;
  transform: scale(0.5);
  opacity: 0;
  transition: transform 340ms cubic-bezier(0.34, 1.56, 0.64, 1),
              opacity   220ms var(--ease-out-quart);
}
.food-bowl.is-visible {
  transform: scale(1);
  opacity: 1;
}
.food-bowl__img {
  display: block;
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
}

/* Speech bubble — above cat's head, positioned via JS */
.cat-speech {
  position: absolute;
  bottom: 58px;       /* closer to cat's head */
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.04em;
  white-space: nowrap;
  background: var(--ink-1);
  color: var(--ink-inv);
  padding: 4px 8px;
  border-radius: 0;
  pointer-events: none;
  z-index: 106;
  opacity: 0;
  transform: translateX(-50%) translateY(5px);
  transition: opacity 200ms var(--ease-out-quart), transform 200ms var(--ease-out-quart);
}
/* Downward arrow */
.cat-speech::after {
  content: '';
  position: absolute;
  bottom: -4px;
  left: 50%;
  transform: translateX(-50%);
  width: 0; height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 4px solid var(--ink-1);
}
.cat-speech.is-visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* (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="4"] { order: 4; } /* TarotNook */
  [data-card="3"] { order: 5; } /* Ok-bye    */
  [data-card="5"] { order: 6; } /* Friday    */

  .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; }
}
/* --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: 17px;
  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. 37px — the
   30px pass read dainty; this keeps the bar an island, not a sliver. */
.tbar__btn {
  position: relative;
  width: 37px;
  height: 37px;
  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; }
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 37px 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: 74px;
  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); }
}

/* Feed-cat cooldown (class set by the cat script in index.html). */
.tbar #cat-feed-btn.is-cooldown {
  opacity: 0.35;
  pointer-events: none;
  cursor: default;
}

/* 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: 34px;
  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: 37px;
  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 spec-sheet: one row per face — a live sample scaled to its place
   in the hierarchy, a leader line + bracket divider, then a role/family/
   weight label column and a bulleted spec column. Plain paper, no cards.
   Every piece is centered on its own row (align-self: center on sample,
   line, and bracket), so the line always meets the bracket at its
   midpoint and any leftover row height splits evenly above and below
   instead of stranding a block of dead space under the shorter column.
   Each bracket keeps its own natural height, driven by that row's meta
   content, and the list gap gives adjacent brackets enough separation
   that two of them never read as one merged shape. */
.cs-type-list {
  display: flex;
  flex-direction: column;
  gap: 26px;
  margin-top: 28px;
}
.cs-type-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;   /* sample | bracket */
  column-gap: 46px;   /* the leader line spans exactly this, see .cs-type-meta::before */
  align-items: center;
}
.cs-type-sample {
  margin: 0;
  color: var(--ink-1);
}
/* Sizes are deliberately modest for a specimen: the left column is only
   about half the row, so an oversized display face wraps to three or four
   lines and blows the row height far past its own bracket, stranding a
   block of dead space beside it. Big enough to show the hierarchy, small
   enough to stay near one or two lines. */
.cs-type-sample--display {
  font-size: clamp(24px, 3.2vw, 32px);
  line-height: 1.3;   /* reads as flowing prose, not a cropped headline */
  letter-spacing: -0.01em;
}
.cs-type-sample--body {
  font-size: clamp(16px, 1.9vw, 19px);
  line-height: 1.55;   /* matches the site's actual body-copy leading, see .cs-deep-text */
}
.cs-type-sample--label {
  font-size: clamp(12px, 1.5vw, 15px);
  line-height: 1.55;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

/* Meta block — the bracket: border-left spine + top/bottom ticks, sized
   to its own natural content (never stretched to match the sample), so
   each row's bracket is exactly as tall as that row's label and spec
   list need it to be.

   The two ticks are drawn as background gradients rather than pseudo-
   elements, which frees ::before for the leader line. That matters: the
   line has to sit at the BRACKET's midpoint, so it must be positioned
   against the bracket's own box (top: 50%) rather than aligned within
   the grid row, whose height is usually set by the taller sample column
   and therefore has a different midpoint entirely. */
.cs-type-meta {
  display: grid;
  grid-template-columns: minmax(96px, auto) minmax(112px, auto);
  gap: clamp(16px, 2vw, 26px);
  padding-left: clamp(18px, 2vw, 26px);
  position: relative;
  border-left: 1px solid color-mix(in oklab, var(--ink-1) 20%, transparent);
  background-image:
    linear-gradient(to right,
      color-mix(in oklab, var(--ink-1) 20%, transparent),
      color-mix(in oklab, var(--ink-1) 20%, transparent)),
    linear-gradient(to right,
      color-mix(in oklab, var(--ink-1) 20%, transparent),
      color-mix(in oklab, var(--ink-1) 20%, transparent));
  background-size: 9px 1px, 9px 1px;
  background-position: left top, left bottom;
  background-repeat: no-repeat;
}
/* Leader line — anchored to the bracket at exactly half its height, and
   spanning left across the row's 46px column gap to reach the sample. */
.cs-type-meta::before {
  content: '';
  position: absolute;
  top: 50%;
  left: -46px;
  width: 46px;
  height: 1px;
  background: color-mix(in oklab, var(--ink-1) 32%, transparent);
}

.cs-type-tier {
  display: block;
  font-family: var(--font-primary);
  font-weight: 600;
  font-size: var(--t-small);
  letter-spacing: 0.02em;
  color: var(--ink-1);
  margin-bottom: 5px;
}
.cs-type-fam,
.cs-type-wt {
  display: block;
  font-family: var(--font-primary);
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink-3);
}
.cs-type-spec { margin: 0; padding: 0; list-style: none; }
.cs-type-spec li {
  position: relative;
  padding-left: 12px;
  font-family: var(--font-primary);
  font-size: 11.5px;
  line-height: 1.75;
  color: var(--ink-3);
  letter-spacing: normal;
  font-kerning: none;
}
.cs-type-spec li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.72em;
  width: 3px;
  height: 3px;
  background: var(--ink-4);
}

.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);
}
@media (max-width: 719px) {
  /* Stack the sample over its meta; drop the bracket and leader line
     (no side column to divide) and use a hairline above the label/spec
     pair instead. */
  .cs-type-row { grid-template-columns: 1fr; gap: 14px; align-items: start; }
  .cs-type-list { gap: 24px; }
  .cs-type-sample--display { font-size: clamp(26px, 8vw, 34px); }
  .cs-type-meta {
    padding-top: 12px;
    padding-left: 0;
    border-left: none;
    border-top: 1px solid color-mix(in oklab, var(--ink-1) 12%, transparent);
    grid-template-columns: 1fr 1fr;
    background-image: none;   /* drops the bracket's two ticks */
  }
  .cs-type-meta::before { display: none; }   /* drops the leader line */
}

.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). Segment corners carry --radius-bar, a documented content
   exception in DESIGN.md. No JS or reduced motion: shown at its share. */
.case .cs-bars {
  position: relative;
  margin: 0;
  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-bar); }
.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 — nudge right, number brightens */
.cs-contributions-list li {
  transition: transform 200ms var(--ease-out-soft);
}

.cs-contributions-list li:hover {
  transform: translateX(4px);
}

.cs-contributions-list li::before {
  transition: opacity 200ms var(--ease-out-quart);
}

.cs-contributions-list li:hover::before {
  opacity: 0.7;
}
/* The card variant (My impact) is a static plate: a sideways nudge on a
   boxed card read as a glitch rather than as feedback, and nothing here
   is pressable. No motion, numeral unchanged on hover (removed on
   request, Sept 2026). */
.cs-contributions-list--cards li,
.cs-contributions-list--cards li:hover { transform: none; transition: none; }
.cs-contributions-list--cards li::before,
.cs-contributions-list--cards li:hover::before { opacity: 1; transition: none; }

/* 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 {
  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; }

/* ── 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/friday 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 friday]]): 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);
}

/* 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; }

/* ── 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),
              padding-left 260ms var(--ease-out-expo),
              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;
  }
}
/* Wayfinding tick — the bold-only active state was easy to lose while
   scrolling; a short rule slides in beside the current section. On hover
   a shorter, lighter tick previews the same gesture, so hover and active
   read as two intensities of one mark rather than two unrelated states. */
.case-toc__link::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.62em;
  width: 0;
  height: 1px;
  background: var(--ink-1);
  transition: width 260ms var(--ease-out-expo),
              background-color 200ms var(--ease-out-quart);
}
@media (hover: hover) and (pointer: fine) {
  .case-toc__link:hover {
    color: var(--ink-2);
    padding-left: 10px;
  }
  .case-toc__link:hover::before {
    width: 6px;
    background: var(--ink-3);
  }
}
/* Active declared after hover so on the current section the full tick
   wins — hovering it never shrinks the mark back to preview size. */
.case-toc__link.is-active {
  color: var(--ink-1);
  font-weight: 700;
  padding-left: 15px;
}
.case-toc__link.is-active::before {
  width: 10px;
  background: var(--ink-1);
}
@media (prefers-reduced-motion: reduce) {
  .case-toc__link,
  .case-toc__link::before { 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 {
  font-family: var(--font-primary);
  font-size: var(--t-small);
  font-weight: 400;   /* was 500 — lightest Archivo Narrow cut, so the value reads calmer under its blue label */
  line-height: 1.4;
  color: var(--ink-2);
}

/* 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: 14px; 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: 0;
}
/* 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;
}
/* 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; }
.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; }
  .case-close {
    width: 44px;
    height: 44px;
    margin-bottom: 0;
    background: var(--surface-base);
    border: 1px solid color-mix(in oklab, var(--ink-1) 16%, transparent);
    color: var(--ink-2);
    box-shadow: 0 2px 12px -6px rgba(0, 0, 0, 0.45);
  }
  .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;
  z-index: 0;              /* behind rail + cards (.main children) */
  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. Only the thin-stroke sprites; cloud-4's heavy
   outline is banned from the page sky. */
.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: 0;
  pointer-events: none;
}
@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 — illustrated street image with drifting pixel
   clouds behind it and a birds + steam layer in front. Overlay
   viewBoxes match the image pixels (2560×744); js/footer.js drives
   the birds and steam; the clouds loop here in CSS.
   ══════════════════════════════════════════════════════════════ */
.street-footer {
  position: relative;
  /* Always grey — never white, unconditionally (not scroll-driven, not
     gated behind motion preference). The art's transparent "white"
     areas (window panes, wall fills, sky) sit directly on this. */
  background: var(--surface-deep);
  margin-top: clamp(72px, 12vh, 160px);
  overflow: hidden;
}

.street-footer__stage {
  position: relative;
}

.street-footer__clouds {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.street-footer__clouds image { image-rendering: pixelated; }

.street-footer__art {
  position: relative;    /* stacks over the cloud layer */
  z-index: 1;
  display: block;
  width: 100%;
  height: auto;
  user-select: none;
  -webkit-user-drag: none;
  pointer-events: none;
}

.street-footer__birds {
  position: absolute;
  inset: 0;
  z-index: 2;
  width: 100%;
  height: 100%;
  pointer-events: none;   /* presses land on the footer for the startle */
}

/* ── Clouds — each sprite drifts along its own lane of open sky,
      fading at the ends of the run. Static without motion. The lanes
      sit behind the art, so clouds slide behind rooftops and poles. */
.sf-cloud { will-change: transform; }
@media (prefers-reduced-motion: no-preference) {
  .sf-cloud--1 { animation: sf-drift-1 240s linear -30s  infinite; }
  .sf-cloud--2 { animation: sf-drift-2 200s linear -80s  infinite; }
  .sf-cloud--3 { animation: sf-drift-3 320s linear -160s infinite; }
  .sf-cloud--4 { animation: sf-drift-4 260s linear -40s  infinite; }
  .sf-cloud--5 { animation: sf-drift-5 220s linear -120s infinite; }
  .sf-cloud--6 { animation: sf-drift-6 180s linear -60s  infinite; }
}
@keyframes sf-drift-1 {
  0%   { transform: translateX(-300px); opacity: 0; }
  6%   { opacity: 1; }
  94%  { opacity: 1; }
  100% { transform: translateX(900px); opacity: 0; }
}
@keyframes sf-drift-2 {
  0%   { transform: translateX(-260px); opacity: 0; }
  6%   { opacity: 1; }
  94%  { opacity: 1; }
  100% { transform: translateX(700px); opacity: 0; }
}
@keyframes sf-drift-3 {
  0%   { transform: translateX(-400px); opacity: 0; }
  6%   { opacity: 1; }
  94%  { opacity: 1; }
  100% { transform: translateX(1100px); opacity: 0; }
}
@keyframes sf-drift-4 {
  0%   { transform: translateX(-350px); opacity: 0; }
  6%   { opacity: 1; }
  94%  { opacity: 1; }
  100% { transform: translateX(800px); opacity: 0; }
}
@keyframes sf-drift-5 {
  0%   { transform: translateX(-300px); opacity: 0; }
  6%   { opacity: 1; }
  94%  { opacity: 1; }
  100% { transform: translateX(600px); opacity: 0; }
}
@keyframes sf-drift-6 {
  0%   { transform: translateX(-280px); opacity: 0; }
  6%   { opacity: 1; }
  94%  { opacity: 1; }
  100% { transform: translateX(460px); opacity: 0; }
}

/* ── Steam & smoke — pixel puffs above the coffee cup, the kiosk
      griddle and the corner-store flue. steps() keeps the rise on
      the pixel grid. Hidden entirely under reduced motion. */
.sf-steam .sf-puff { fill: #2a2a2a; opacity: 0; }
@media (prefers-reduced-motion: no-preference) {
  .sf-puff { animation: sf-puff var(--t, 3s) steps(8) var(--d, 0s) infinite; }
  .sf-steam--coffee .sf-puff { --t: 2.6s; --o: 0.55; }
  .sf-steam--grill  .sf-puff { --t: 4.2s; --o: 0.38; }
  .sf-steam--flue   .sf-puff { --t: 7s;   --o: 0.42; }
  /* pressed table → the cup works overtime for a moment */
  .sf-steam--burst  .sf-puff { animation-duration: 1s; --o: 0.8; }
}
@keyframes sf-puff {
  0%   { transform: translate(0, 0); opacity: 0; }
  12%  { opacity: var(--o, 0.5); }
  60%  { transform: translate(var(--sx, 4px), -24px); opacity: calc(var(--o, 0.5) * 0.6); }
  100% { transform: translate(calc(var(--sx, 4px) * -0.5), -44px); opacity: 0; }
}

/* ── Post office — fascia letters on the corner store (js/footer.js
      draws them; pressing the storefront just posts a pixel envelope
      out of the door and flies it away — decorative only). */
.sf-post-letter {
  font-family: var(--font-mono);
  font-weight: 700;
  fill: #2a2a2a;
}

.sf-envelope {
  animation: sf-post-fly 1.15s var(--ease-out-quart) both;
}
@keyframes sf-post-fly {
  0%   { transform: translate(0, 6px) scale(0.6); opacity: 0; }
  16%  { transform: translate(2px, -14px) scale(1); opacity: 1; }
  60%  { transform: translate(26px, -96px) rotate(7deg); opacity: 1; }
  100% { transform: translate(44px, -170px) rotate(-5deg); opacity: 0; }
}

/* ── Postbox letter — hangs from the slot; pressing the box lets it
      tumble to the pavement (transform set by js/footer.js — CSS only
      shapes HOW it moves). A slow fade covers the climb back up. */
.sf-letter { will-change: transform; }
@media (prefers-reduced-motion: no-preference) {
  .sf-letter--falling {
    transition: transform 700ms cubic-bezier(0.3, 1.6, 0.5, 1);  /* drop + landing bounce */
  }
  .sf-letter--reset {
    transition: transform 0ms 350ms;
    animation: sf-letter-blink 700ms var(--ease-out-quart) both;                  /* discreet reappear */
  }
}
@keyframes sf-letter-blink {
  0%   { opacity: 1; }
  45%  { opacity: 0; }
  55%  { opacity: 0; }
  100% { opacity: 1; }
}

@media (max-width: 719px) {
  /* Keep the scene readable on small screens: scale by height and
     let the sides crop, centred on the cafe. */
  .street-footer { display: flex; flex-direction: column; align-items: center; }
  .street-footer__stage { width: max-content; align-self: center; }
  .street-footer__art { width: auto; height: 230px; max-width: none; }
}

/* ── TEMP: street postbox hidden ─────────────────────────────────
   The chatbot widget is back (July 2026); the postbox stays off for
   now — markup + JS intact, delete this block to restore it. */
#sf-postbox,
#sf-postbox-letter {
  display: none !important;
}

/* ══════════════════════════════════════════════════════════════
   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;
  /* 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;

  /* 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  */
  --hl-sun:  #aaaaaa;  /* 170 */
  --hl-rose: #888888;  /* 136 */
  --hl-mint: #777777;  /* 119 */

  /* 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.) */
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 {
  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 ────────────────────────────────────── */
/* Invert the line art: ink → light lines, background hills → deep
   grey. Transparency is preserved, so the night surface shows through. */
html[data-theme="dark"] .street-footer__art { filter: invert(1); }

/* The cat stays her daytime white self — a little lantern of her own.
   (Deliberately NOT inverted; only the street art flips.) */

/* The wires (js/footer.js buildWires — live SVG since July 2026, so
   they can bounce under the cursor; the raster wires were erased from
   street-footer.png). Day ink matches the art's line colour; dark
   matches what invert(1) used to make of it. */
.sf-wire { stroke: #262626; }
html[data-theme="dark"] .street-footer__birds .sf-wire { stroke: #d9d9d9; }

/* Live sprites (drawn with attributes, so property rules win) */
html[data-theme="dark"] .street-footer__birds [fill="#000"]    { fill: #e6e6e6; }
html[data-theme="dark"] .street-footer__birds [fill="#fff"]    { fill: #141414; }
html[data-theme="dark"] .street-footer__birds [stroke="#2a2a2a"] { stroke: #d9d9d9; }
html[data-theme="dark"] .street-footer__birds [fill="#2a2a2a"] { fill: #d9d9d9; }
html[data-theme="dark"] .sf-steam .sf-puff { fill: #cfcfcf; }
html[data-theme="dark"] .sf-post-letter    { fill: #d9d9d9; }
/* The window walker stays a dark shape — at night it reads against
   the lit panes instead of the daylight. (Attribute-qualified so it
   out-ranks the generic [fill="#2a2a2a"] night flip above.) */
html[data-theme="dark"] .street-footer__birds .sf-walker [fill="#2a2a2a"] {
  fill: #0b0b0b;
  opacity: 0.55;
}

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

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

/* ── Stars — pixel dots that breathe ──────────────────────────── */
.sf-star { fill: #e8e8e8; }
.sf-star--dim    { fill: #9a9a9a; }
.sf-star--bright { fill: #ffffff; }
/* Constellation hairline — visible but never louder than its stars */
.sf-const {
  fill: none;
  stroke: #606060;
  stroke-width: 1;
}
#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) {
  .sf-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. */
.sf-shoot line { stroke: #f0f0f0; stroke-width: 2; stroke-linecap: round; }
.sf-shoot rect { fill: #ffffff; }

.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; }
}

/* ── Lamplight + lit windows — the warmth night earns ─────────── */
@media (prefers-reduced-motion: no-preference) {
  #sf-glow { animation: lamp-breathe 5s ease-in-out infinite; }

  /* String lights — each bulb flickers on its own clock (personality +
     --t/--d set inline in footer.js), layered over the whole glow's
     breathe. Three temperaments so the garland never pulses in unison. */
  .sf-bulb--soft  { animation: sf-bulb-soft  var(--t, 3s) ease-in-out var(--d, 0s) infinite; }
  .sf-bulb--fast  { animation: sf-bulb-fast  var(--t, 1s) linear      var(--d, 0s) infinite; }
  .sf-bulb--loose { animation: sf-bulb-loose var(--t, 5s) linear      var(--d, 0s) infinite; }

  /* One window flickers like a late-night TV — irregular, cool, restless. */
  .sf-tv { animation: sf-tv-flicker 2.4s steps(1, end) infinite; }
}
@keyframes lamp-breathe {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.86; }
}

/* Gentle shimmer — a filament that never sits perfectly still. */
@keyframes sf-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; }
}
/* Nervous, rapid flutter — a loose filament about to go. */
@keyframes sf-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; }
}
/* Bad connection — burns steady, then stutters out and recovers. */
@keyframes sf-bulb-loose {
  0%, 44%  { opacity: 1; }
  46%      { opacity: 0.12; }
  48%      { opacity: 0.9; }
  49%      { opacity: 0.18; }
  52%      { opacity: 1; }
  100%     { opacity: 1; }
}
/* TV glow — hard, arrhythmic jumps between brightness levels. */
@keyframes sf-tv-flicker {
  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; }
}

/* ── Windows toggle — press a lit pane and the light clicks off ───
   The class beats both the inline opacity and the TV flicker keyframe. */
.sf-winlight { transition: opacity 200ms var(--ease-out-quart); }
.sf-winlight--off { opacity: 0 !important; animation: none !important; }

/* ── Catch-a-comet sparkle — pixel bits fly outward and fade ─────── */
.sf-sparkle rect {
  fill: #ffffff;
  transform-box: fill-box;
  transform-origin: center;
}
.sf-sparkle__flash { fill: #ffffff; }
@media (prefers-reduced-motion: no-preference) {
  .sf-sparkle rect  { animation: sf-sparkle-bit var(--t, 500ms) var(--ease-out-quart) forwards; }
  .sf-sparkle__flash { animation: sf-sparkle-flash 320ms var(--ease-out-quart) forwards; }
}
@keyframes sf-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 sf-sparkle-flash {
  0%   { transform: scale(0.2); opacity: 0.9; }
  100% { transform: scale(2.4); opacity: 0; }
}

/* ── Cafe OPEN sign — board flips theme via the generic fill rules;
   the text is styled here and buzzes like neon after dark ────────── */
.sf-sign__text {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 13px;
  letter-spacing: 0.04em;
  fill: #2a2a2a;
}
html[data-theme="dark"] .sf-sign__text { fill: #f0f0f0; }        /* lit neon */
/* CLOSED: dark storefront, buzz off, glow gone */
.sf-cafe-sign.is-closed .sf-sign__text { fill: #8a8a8a; animation: none !important; }
html[data-theme="dark"] .sf-cafe-sign.is-closed .sf-sign__text { fill: #6a6a6a; }
.sf-cafe-sign.is-closed .sf-sign__glow { display: none; }
@media (prefers-reduced-motion: no-preference) {
  html[data-theme="dark"] .sf-cafe-sign:not(.is-closed) .sf-sign__text {
    animation: sf-neon-buzz 3.4s steps(1, end) infinite;
  }
  html[data-theme="dark"] .sf-cafe-sign:not(.is-closed) .sf-sign__glow {
    animation: sf-neon-glow 3.4s steps(1, end) infinite;
  }
}
/* Neon buzz — mostly lit, with the odd sharp dropout of a tired tube. */
@keyframes sf-neon-buzz {
  0%, 100% { opacity: 1; }
  7%  { opacity: 0.5; }
  9%  { opacity: 1; }
  46% { opacity: 0.92; }
  48% { opacity: 0.35; }
  50% { opacity: 1; }
  73% { opacity: 0.85; }
  75% { opacity: 1; }
}
@keyframes sf-neon-glow {
  0%, 100% { opacity: 0.8; }
  8%  { opacity: 0.3; }
  10% { opacity: 0.8; }
  48% { opacity: 0.2; }
  50% { opacity: 0.85; }
  74% { opacity: 0.6; }
  76% { opacity: 0.8; }
}

/* ── Kiosk closed → grill off. The smoke simply stops. ──────────── */
.sf-steam--off { display: none; }

/* ── Sleeping birds — a pixel "z" drifts up and dissolves ────────── */
.sf-zzz {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  fill: #cfcfcf;
  opacity: 0;
}
@media (prefers-reduced-motion: no-preference) {
  .sf-zzz { animation: sf-zzz-rise 2s var(--ease-out-quart) forwards; }
}
@keyframes sf-zzz-rise {
  0%   { transform: translateY(0);     opacity: 0; }
  18%  { opacity: 0.85; }
  100% { transform: translateY(-16px); opacity: 0; }
}

/* ── Fireflies — position rides a rAF transform (footer.js); the blink
   lives here, each bug on its own clock. Core fill comes from this class
   (NOT a fill attribute) so the generic night [fill="#fff"] flip can't
   turn the bug dark. ─────────────────────────────────────────────── */
.sf-firefly__core { fill: #ffffff; }
.sf-firefly__pulse { opacity: 0; }
@media (prefers-reduced-motion: no-preference) {
  .sf-firefly__pulse {
    animation: sf-firefly-blink var(--t, 2.4s) ease-in-out var(--d, 0s) infinite;
  }
}
/* Fireflies pulse rather than snap — a soft swell, a beat of full glow,
   then gone dark for most of the cycle, the way the real ones tease. */
@keyframes sf-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-contributions-list li {
  transition: transform 200ms var(--ease-out-soft);
}
.cs-panel .cs-contributions-list li:hover {
  transform: translateX(4px);
}
.cs-panel .cs-contributions-list li::before {
  transition: opacity 200ms var(--ease-out-quart);
}
.cs-panel .cs-contributions-list li:hover::before {
  opacity: 0.7;
}
.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); }

/* ══════════════════════════════════════════════════════════════
   PROCESS MAP — horizontal node-graph second view of a full-page case
   study (js/case-map.js). Scoped: .case-map (the overlay),
   .case-map-toggle (the hero chip).

   Redesigned July 2026 from a pan/pinch-zoom canvas. Two bands laid
   left to right: the shipped spine runs across the top, and the
   rejected/leaf cards each stage grew fan out in a row beneath it,
   joined by thin curved connectors with a dot at each end. Scrolls on
   one axis only (horizontal) — no drag, no zoom, no vertical scroll,
   so there is exactly one way to move through it and nowhere to get
   lost.

   Surface language departs from the reading view on purpose: cards
   here are soft raised planes (tinted fill + layered shadow, no
   stroke) floating on a recessed canvas, so the map reads as objects
   on a table rather than boxes ruled on a page. The site's zero
   border-radius law still holds — softness comes from shadow and
   fill, never from rounding. Connectors are strictly monochrome; blue
   stays reserved for type (read-links, stat figures, the live dot).
   ══════════════════════════════════════════════════════════════ */

/* Hero chip — quiet text link, the same species as .case-hero__live */
.case-map-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 7px 12px;
  border: 1px solid color-mix(in oklab, var(--ink-1) 22%, transparent);
  border-radius: 0;
  background: var(--surface-base);
  color: var(--ink-2);
  font-family: var(--font-primary);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color 180ms var(--ease-out-quart),
              border-color 180ms var(--ease-out-quart),
              background 180ms var(--ease-out-quart),
              transform 160ms var(--ease-out-quart);
}
.case-map-toggle:hover {
  color: var(--ink-1);
  border-color: color-mix(in oklab, var(--ink-1) 45%, transparent);
  background: var(--surface-deep);
}
.case-map-toggle:active { transform: scale(0.97); }
.case-map-toggle svg { display: block; opacity: 0.75; }
.case-map-toggle:hover svg { opacity: 1; }

/* Overlay shell — a full-bleed drawer sliding in from the right, so
   opening reads as one surface arriving rather than a wash appearing
   behind the page. The canvas is recessed (deep, not base) so the
   cards resting on it can be the lighter plane. */
.case-map {
  /* Local surface tokens. Elevation here is the inverse of the page's
     in dark mode (the raised plate must stay LIGHTER than the canvas
     in both themes), and shadows must stay black in both — tinting a
     shadow with --ink-1 turns it into a white halo once ink inverts.
     Hence a scoped set rather than reaching for the globals. */
  --map-canvas: var(--surface-deep);
  --map-plate:  var(--surface-base);
  --map-shade:  #000000;
  --map-w-spine: 288px;
  --map-w-branch: 200px;
  --map-band-1: 250px;

  position: fixed;
  inset: 0;
  z-index: 540;
  display: flex;
  flex-direction: column;
  background: var(--map-canvas);
  opacity: 0;
  transform: translateX(6%);
  transition: transform 560ms var(--ease-out-expo), opacity 420ms var(--ease-out-quart);
}
html[data-theme="dark"] .case-map {
  --map-canvas: #0c0c0c;
  --map-plate:  #1c1c1c;
  --map-shade:  #000000;
}
.case-map.is-live { opacity: 1; transform: translateX(0); }
.case-map.is-closing {
  opacity: 0;
  transform: translateX(4%);
  transition: transform 320ms var(--ease-out-quart), opacity 280ms var(--ease-out-quart);
}
body.case-map-open { overflow: hidden; }

/* Top bar — a case-page header strip, not app chrome */
.case-map__bar {
  display: flex;
  align-items: baseline;
  gap: var(--space-4);
  padding: 18px 28px 16px;
  background: var(--map-plate);
  box-shadow: 0 1px 0 0 color-mix(in oklab, var(--map-shade) 7%, transparent),
              0 8px 24px -20px color-mix(in oklab, var(--map-shade) 40%, transparent);
  flex: none;
  z-index: 2;
}
.case-map__bar-eyebrow {
  font-family: var(--font-primary);
  font-size: var(--t-eyebrow);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent-blue);
}
.case-map__bar-title {
  font-family: var(--font-primary);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-1);
}
.case-map__legend {
  margin-left: auto;
  padding-left: var(--space-4);
  font-family: var(--font-body);
  font-size: 11.5px;
  color: var(--ink-3);
  text-align: right;
}
.case-map__close {
  flex: none;
  align-self: center;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  margin: -6px -6px -6px 4px;
  border: 0;
  border-radius: var(--radius-control);
  background: var(--map-canvas);
  color: var(--ink-3);
  cursor: pointer;
  transition: color 180ms var(--ease-out-quart),
              background 180ms var(--ease-out-quart),
              transform 160ms var(--ease-out-quart);
}
.case-map__close:hover { color: var(--ink-1); background: var(--surface-tint); }
.case-map__close:active { transform: scale(0.94); }

/* Progress bar — foot of the drawer, filling in step with how far
   through the deck you've scrolled. A presentation advance cue, not a
   restyled scrollbar: it sits outside .case-map__scroll so it never
   scrolls with the content it measures. */
.case-map__progress {
  flex: none;
  height: 2px;
  background: color-mix(in oklab, var(--ink-1) 7%, transparent);
}
.case-map__progress-fill {
  width: 100%;
  height: 100%;
  transform-origin: left;
  transform: scaleX(0);
  background: color-mix(in oklab, var(--ink-1) 45%, transparent);
}

/* Scroll well — ONE axis. Horizontal scroll carries the whole deck;
   vertical is clipped outright, so the map can never drift up or down
   under you. Everything therefore has to fit the viewport height,
   which the compact card sizing below is tuned for. */
.case-map__scroll {
  flex: 1 1 auto;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  position: relative;
  padding: var(--space-7) var(--space-7) var(--space-6);
}

/* Timeline — spine stages left to right. A stage is a column: its
   spine card on the top band, and the cards it grew fanned out in a
   row on the band beneath. One SVG layer draws spine-to-spine
   connectors across the whole deck; each stage draws its own
   stage-to-branch connectors locally. */
.case-map__timeline {
  position: relative;
  z-index: 1;
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  gap: 76px;
  height: 100%;
}
.case-map__timeline-wires {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  z-index: 0;
}
/* A stage is a three-row grid: numeral, spine card, branch band. The
   middle row is a FIXED height (--map-band-1) rather than content-sized,
   so the branch band starts at the same y in every column and the two
   bands read as bands. Content-sized rows made the lower band ragged by
   ~46px across the deck, which is what breaks the node-graph read.
   justify-items centres the spine card over its branch row, so a stage
   with five branches fans symmetrically instead of raking hard right. */
.case-map__stage {
  position: relative;
  z-index: 1;
  flex: 0 0 auto;
  display: grid;
  grid-template-rows: auto var(--map-band-1) auto;
  justify-items: center;
}
.case-map__stage-num {
  width: var(--map-w-spine);
  font-family: var(--font-primary);
  font-size: 52px;
  /* Was 300, which Archivo Narrow does not ship (it loads 400-700), so it
     was already falling back to 400. Stated honestly here; the lightness
     this wanted comes from the 12% ink below, not the weight. */
  font-weight: 400;
  line-height: 1;
  letter-spacing: -0.03em;
  color: color-mix(in oklab, var(--ink-1) 12%, transparent);
  margin-bottom: var(--space-2);
  pointer-events: none;
}
.case-map__stage-main {
  position: relative;
  z-index: 1;
  align-self: start;
  width: var(--map-w-spine);
}

/* Branch band — the cards a stage grew, fanned out in a row beneath it
   rather than stacked, so the deck stays two bands tall no matter how
   many a stage has and the whole thing fits without vertical scroll. */
.case-map__branches {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  gap: var(--space-6);
}
.case-map__branches .case-map__branch { width: var(--map-w-branch); flex: 0 0 auto; }

/* Connectors — thin curved lines, dot at each end, drawn by JS from
   layout offsets (getBoundingClientRect would fold in the entrance
   transform mid-animation and throw the line off). Monochrome only:
   weight and dash pattern carry the meaning, never hue. */
.case-map__branch-wires {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  z-index: 0;
}
.case-map__branch-wire {
  fill: none;
  stroke-width: 1.25;
  vector-effect: non-scaling-stroke;
}
.case-map__branch-wire--main { stroke: color-mix(in oklab, var(--ink-1) 40%, transparent); }
.case-map__branch-wire--iterate { stroke: color-mix(in oklab, var(--ink-1) 26%, transparent); }
.case-map__branch-wire--dropped {
  stroke: color-mix(in oklab, var(--ink-1) 18%, transparent);
  stroke-dasharray: 4 5;
  stroke-linecap: butt;
}
.case-map__branch-dot {
  fill: color-mix(in oklab, var(--ink-1) 38%, transparent);
  stroke: none;
}

/* Connectors join their cards only once both ends exist. Drawn at
   open, they would otherwise hang in empty space pointing at cards
   that have not arrived — the one element that cannot read before its
   endpoints do. Opacity only, so reduced motion needs no override. */
.case-map__stage.is-armed .case-map__branch-wires { opacity: 0; }
.case-map__stage.is-in .case-map__branch-wires {
  animation: fade-in 420ms var(--ease-out-quart) 300ms both;
}
.case-map__timeline-wires { opacity: 1; transition: opacity 480ms var(--ease-out-quart) 240ms; }
.case-map:not(.is-live) .case-map__timeline-wires { opacity: 0; transition: none; }

/* Reveal choreography — armed only by JS (IntersectionObserver
   present, motion allowed); otherwise everything just shows, per the
   site's established scroll-reveal contract. Each card drifts in from
   the right and settles into place, blurred and slightly small at the
   start: numeral leads, spine card follows a beat later, branches
   cascade after, each on its own timing rather than lockstep. */
.case-map__stage.is-armed .case-map__stage-num,
.case-map__stage.is-armed .case-map__stage-main,
.case-map__stage.is-armed .case-map__branches .case-map__branch {
  opacity: 0;
  translate: 40px 0;
  scale: 0.97;
  filter: blur(5px);
}

.case-map__stage.is-in .case-map__stage-num {
  animation: case-map-settle 620ms var(--ease-out-soft) both;
}
.case-map__stage.is-in .case-map__stage-main {
  animation: case-map-settle 680ms var(--ease-out-soft) 90ms both;
}
.case-map__stage.is-in .case-map__branches .case-map__branch {
  animation: case-map-settle 620ms var(--ease-out-soft) both;
}
.case-map__stage.is-in .case-map__branches .case-map__branch:nth-child(1) { animation-delay: 210ms; }
.case-map__stage.is-in .case-map__branches .case-map__branch:nth-child(2) { animation-delay: 265ms; }
.case-map__stage.is-in .case-map__branches .case-map__branch:nth-child(3) { animation-delay: 320ms; }
.case-map__stage.is-in .case-map__branches .case-map__branch:nth-child(4) { animation-delay: 375ms; }
.case-map__stage.is-in .case-map__branches .case-map__branch:nth-child(5) { animation-delay: 430ms; }

@keyframes case-map-settle {
  from { opacity: 0; translate: 40px 0; scale: 0.97; filter: blur(5px); }
  to   { opacity: 1; translate: 0 0;    scale: 1;    filter: blur(0); }
}

/* Node cards — soft raised planes, no stroke. Depth comes from a
   layered shadow (tight contact shadow + wide ambient) over a fill
   just lighter than the canvas, so cards read as objects resting on a
   surface. Zero radius throughout, per the design system. */
.case-map__node {
  width: 100%;
  display: block;
  text-align: left;
  padding: 15px 17px 14px;
  border: 0;
  border-radius: 0;
  background: var(--map-plate);
  color: var(--ink-1);
  cursor: pointer;
  font-family: var(--font-body);
  box-shadow:
    0 1px 2px -1px color-mix(in oklab, var(--map-shade) 22%, transparent),
    0 8px 20px -14px color-mix(in oklab, var(--map-shade) 40%, transparent);
  transition: box-shadow 220ms var(--ease-out-quart),
              transform 220ms var(--ease-out-quart),
              background 220ms var(--ease-out-quart);
}
.case-map__node:active { transform: scale(0.985); }
@media (hover: hover) and (pointer: fine) {
  .case-map__node:hover {
    transform: translateY(-2px);
    box-shadow:
      0 2px 4px -2px color-mix(in oklab, var(--map-shade) 26%, transparent),
      0 18px 36px -20px color-mix(in oklab, var(--map-shade) 52%, transparent);
  }
}
.case-map__node:focus-visible {
  outline: 2px solid var(--accent-blue);
  outline-offset: 2px;
}
/* Rejected paths sit lower and quieter — recessed into the canvas
   rather than raised off it, so a dropped direction reads as set
   aside without needing a dashed outline to say so. */
.case-map__node--rejected {
  background: color-mix(in oklab, var(--map-canvas) 95%, var(--map-shade));
  box-shadow: inset 0 1px 3px -1px color-mix(in oklab, var(--map-shade) 13%, transparent);
}
.case-map__node--rejected .case-map__node-title { color: var(--ink-3); }
@media (hover: hover) and (pointer: fine) {
  .case-map__node--rejected:hover {
    transform: translateY(-2px);
    background: var(--map-plate);
    box-shadow:
      0 2px 4px -2px color-mix(in oklab, var(--map-shade) 22%, transparent),
      0 16px 32px -20px color-mix(in oklab, var(--map-shade) 45%, transparent);
  }
}
/* The shipped card carries a touch more lift than its neighbours */
.case-map__node--shipped {
  box-shadow:
    0 1px 2px -1px color-mix(in oklab, var(--map-shade) 28%, transparent),
    0 14px 30px -16px color-mix(in oklab, var(--map-shade) 48%, transparent);
}
.case-map__node--shipped .case-map__tag,
.case-map__node--live .case-map__tag { color: var(--accent-blue); }

/* Eyebrow tag — same recipe as the reading view's meta labels */
.case-map__tag {
  display: block;
  font-family: var(--font-primary);
  font-size: var(--t-eyebrow);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: 8px;
}
.case-map__node--live .case-map__tag::before {
  content: "";
  display: inline-block;
  width: 5px;
  height: 5px;
  margin-right: 6px;
  background: var(--accent-blue);
  vertical-align: 1px;
}
@media (prefers-reduced-motion: no-preference) {
  .case-map__node--live .case-map__tag::before {
    animation: case-map-pulse 2.4s var(--ease-out-soft) infinite;
  }
}
@keyframes case-map-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.35; }
}

/* Media — flush against the card's fill, no mat, no frame */
.case-map__thumb {
  position: relative;
  display: block;
  margin: 0 0 10px;
  background: var(--map-canvas);
  overflow: hidden;
}
.case-map__thumb img {
  display: block;
  width: 100%;
  height: 88px;
  object-fit: cover;
  object-position: top;
  transition: filter 240ms var(--ease-out-quart), scale 420ms var(--ease-out-quart);
}
.case-map__branch .case-map__thumb img { height: 74px; }
.case-map__node--rejected .case-map__thumb img { filter: grayscale(1) contrast(0.94); }
@media (hover: hover) and (pointer: fine) {
  .case-map__node--rejected:hover .case-map__thumb img { filter: grayscale(0.4); }
  .case-map__node:hover .case-map__thumb img { scale: 1.03; }
}
.case-map__play {
  position: absolute;
  right: 6px;
  bottom: 6px;
  padding: 2px 6px;
  font-family: var(--font-primary);
  font-size: 8.5px;
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  background: color-mix(in oklab, var(--map-plate) 88%, transparent);
  transition: opacity 180ms var(--ease-out-quart);
}
@media (hover: hover) and (pointer: fine) {
  .case-map__node:hover .case-map__play { opacity: 0; }
}

.case-map__node-title {
  display: block;
  font-family: var(--font-primary);
  font-size: 19px;
  font-weight: 600;
  line-height: 1.18;
  letter-spacing: -0.012em;
  margin-bottom: 7px;
  text-wrap: balance;
}
.case-map__branch .case-map__node-title { font-size: var(--t-small); line-height: 1.3; letter-spacing: 0.005em; }
.case-map__node-line {
  display: block;
  font-size: var(--t-body);
  line-height: 1.55;
  color: var(--ink-3);
  text-wrap: pretty;
}
.case-map__branch .case-map__node-line { font-size: 11.5px; line-height: 1.5; }

/* Stat inset — hairlines here are the exception that earns its keep:
   they bracket a figure rather than outline a box */
.case-map__stat {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 8px 0 9px;
  margin-bottom: 6px;
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 9%, transparent);
  border-bottom: 1px solid color-mix(in oklab, var(--ink-1) 9%, transparent);
}
.case-map__stat-value {
  font-family: var(--font-primary);
  font-size: 22px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--accent-blue);
  flex: none;
}
.case-map__stat-label {
  font-size: 10.5px;
  line-height: 1.5;
  color: var(--ink-3);
}

/* Mini rows — numbered like the reading view's contribution lists */
.case-map__items {
  display: block;
  margin: 2px 0 8px;
}
.case-map__item {
  position: relative;
  display: block;
  padding: 5px 0 5px 20px;
  font-size: var(--t-meta);
  line-height: 1.5;
  color: var(--ink-2);
  border-top: 1px solid color-mix(in oklab, var(--ink-1) 7%, transparent);
}
.case-map__item:first-child { border-top: 0; }
.case-map__item-n {
  position: absolute;
  left: 0;
  top: 6px;
  font-family: var(--font-primary);
  font-size: var(--t-micro);
  font-variant-numeric: tabular-nums;
  color: var(--accent-blue);
}

/* Read link — always present, so every card names where it leads */
.case-map__jump {
  display: block;
  margin-top: 10px;
  font-family: var(--font-primary);
  font-size: var(--t-micro);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  transition: color 180ms var(--ease-out-quart);
}
.case-map__jump-arr {
  display: inline-block;
  transition: translate 180ms var(--ease-out-quart);
}
.case-map__node:hover .case-map__jump,
.case-map__node:focus-visible .case-map__jump { color: var(--accent-blue); }
@media (hover: hover) and (pointer: fine) {
  .case-map__node:hover .case-map__jump-arr { translate: 3px 0; }
}

/* Short viewports — the deck is two bands tall and clips rather than
   scrolls vertically, so on a short screen it has to compact instead
   of overflow. Media shrinks first, then the gap between bands, then
   the media goes entirely: the words are what carry the argument, the
   thumbnails only illustrate it. */
@media (max-height: 780px) {
  .case-map__scroll { padding: var(--space-5) var(--space-6) var(--space-4); }
  .case-map { --map-band-1: 205px; }
  .case-map__stage-num { font-size: 38px; margin-bottom: var(--space-1); }
  .case-map__thumb img { height: 64px; }
  .case-map__branch .case-map__thumb img { height: 54px; }
  .case-map__node { padding: 13px 15px 12px; }
}
@media (max-height: 620px) {
  .case-map { --map-band-1: 168px; }
  .case-map__stage-num { font-size: 30px; }
  .case-map__thumb { display: none; }
  .case-map__stat { padding: 6px 0 7px; }
  .case-map__stat-value { font-size: 18px; }
}

/* Reduced motion — everything simply present; no slide-in drawer, no
   scroll-driven progress bar, no staged reveal. Opacity only, per the
   site's motion contract: movement, scale, and blur are dropped, not
   softened. */
@media (prefers-reduced-motion: reduce) {
  .case-map,
  .case-map-toggle,
  .case-map__node,
  .case-map__close,
  .case-map__jump-arr,
  .case-map__thumb img { transition: opacity 200ms linear !important; }
  .case-map { transform: none !important; }
  .case-map__node:hover { transform: none !important; }
  .case-map__stage-num,
  .case-map__stage-main,
  .case-map__branches .case-map__branch {
    animation: none !important;
    opacity: 1 !important;
    translate: 0 0 !important;
    scale: 1 !important;
    filter: none !important;
  }
  .case-map__timeline-wires,
  .case-map__branch-wires { opacity: 1 !important; animation: none !important; transition: none !important; }
  .case-map__progress-fill { transform: scaleX(1) !important; }
}

/* Hide the webkit scrollbar too (scrollbar-width covers Firefox). The
   progress bar at the drawer's foot already reports position, and two
   stacked indicators read as a bug. */
.case-map__scroll::-webkit-scrollbar { display: none; }

/* ── 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,
.case-map__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,
  .case-map__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,
  .case-map__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,
  .case-map__close path,
  .cs-lightbox-close path { animation: none !important; }
}
