/* =============================================================================
   James Rothen — Portfolio
   styles.css

   Structure-first skeleton. Design tokens live at the top; component styles
   below. All colors and spacing routed through custom properties so we can
   tweak the palette in one place.
   ============================================================================= */


/* =============================================================================
   FONTS — Open Runde (self-hosted)
   ============================================================================= */

@font-face {
  font-family: 'Open Runde';
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  src: url('../assets/fonts/OpenRunde-Regular.woff2') format('woff2'),
       url('../assets/fonts/OpenRunde-Regular.woff') format('woff');
}

@font-face {
  font-family: 'Open Runde';
  font-weight: 500;
  font-style: normal;
  font-display: swap;
  src: url('../assets/fonts/OpenRunde-Medium.woff2') format('woff2'),
       url('../assets/fonts/OpenRunde-Medium.woff') format('woff');
}

@font-face {
  font-family: 'Open Runde';
  font-weight: 600;
  font-style: normal;
  font-display: swap;
  src: url('../assets/fonts/OpenRunde-Semibold.woff2') format('woff2'),
       url('../assets/fonts/OpenRunde-Semibold.woff') format('woff');
}

@font-face {
  font-family: 'Open Runde';
  font-weight: 700;
  font-style: normal;
  font-display: swap;
  src: url('../assets/fonts/OpenRunde-Bold.woff2') format('woff2'),
       url('../assets/fonts/OpenRunde-Bold.woff') format('woff');
}


/* =============================================================================
   DESIGN TOKENS
   ============================================================================= */

:root {
  /* -- Color: greyscale starter palette. Tweak here. -------------------------*/
  --bg:              #0a0a0a;   /* page background */
  --bg-elevated:     #141414;   /* cards, tiles, elevated surfaces */
  --border:          #1f1f1f;   /* subtle dividers */
  --border-strong:   #2a2a2a;   /* hover borders, stronger separators */
  --text:            #e8e8e8;   /* primary text */
  --text-secondary:  #9a9a9a;   /* secondary text, descriptions */
  --text-muted:      #5a5a5a;   /* meta text, timestamps, labels */
  /* Dark text sitting on a *white* surface — the work-card badge and the
     drawer's "See it live" button, the only two places the site inverts.
     Not --bg (#0a0a0a), which both used to be: James read that as pure
     black and out of place (2026-09-02). Not an alias of --border-strong
     either, despite sharing its value today — these are different jobs,
     and retuning borders shouldn't silently restyle text. */
  --text-on-light:   #2a2a2a;

  /* Inverted surfaces: the solid pill buttons and the drawer's "See it
     live" link, white in dark mode, ink in light. Not the Selected Work
     badge: James wants that one white in both themes, so it keeps its own
     literal #ffffff and --text-on-light. */
  --surface-invert:       #ffffff;
  --surface-invert-hover: #e8e8e8;
  --text-on-invert:       var(--text-on-light);

  /* Icon glyphs are CSS masks filled with this (see .util-icon). White,
     not --text, in dark mode: the SVGs were baked white before they became
     masks, and this keeps them exactly as bright as they were. */
  --icon-color:      #ffffff;

  /* -- Type ------------------------------------------------------------------*/
  --font-sans: 'Open Runde', -apple-system, BlinkMacSystemFont, 'Segoe UI',
               system-ui, sans-serif;
  --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;

  /* -- Type scale ------------------------------------------------------------*/
  --fs-xs:   0.75rem;    /* 12px */
  --fs-sm:   0.875rem;   /* 14px */
  --fs-base: 1rem;       /* 16px */
  --fs-lg:   1.125rem;   /* 18px */
  --fs-xl:   1.5rem;     /* 24px */
  --fs-2xl:  2rem;       /* 32px */
  --fs-3xl:  2.5rem;     /* 40px */
  --fs-4xl:  3.5rem;     /* 56px */

  /* Off the --fs-* ramp on purpose: 30px sits between --fs-xl (24px) and
     --fs-2xl (32px) and no scale step lands there. Shared by .section-title
     (homepage) and .drawer-section-title, which James asked to match exactly
     (2026-09-02) - one token so they cannot drift. Not folded into --fs-xl,
     which .drawer-gallery-duration still uses at 24px for something
     unrelated. */
  --fs-section-heading: 30px;

  /* -- Spacing scale ---------------------------------------------------------*/
  --sp-1:   0.25rem;
  --sp-2:   0.5rem;
  --sp-3:   0.75rem;
  --sp-4:   1rem;
  --sp-6:   1.5rem;
  --sp-8:   2rem;
  --sp-12:  3rem;

  /* Section heading -> subheader, and subheader -> the section's content
     (.section-title / .section-lead). Tuned together; see those rules. */
  --section-title-lead-gap: var(--sp-2);
  --section-lead-gap:       var(--sp-6);
  --sp-16:  4rem;
  --sp-24:  6rem;
  --sp-32:  8rem;

  /* -- Layout ----------------------------------------------------------------*/
  /* The shared content width, edge to edge — the same number on the main
     page (.site-main), the footer (.footer-inner), and inside the drawer
     (.drawer-content-inner). This is the site's one horizontal-layout
     knob: change this line and every surface moves together, which is
     exactly what James asked for (2026-08-26). Nothing else should
     hardcode a content width.

     History worth keeping: this was 720px while the drawer carried its
     own hardcoded 960px, deliberately decoupled on 2026-08-14 so
     widening the drawer wouldn't drag the main column along with it.
     That decoupling is retired — the two are meant to match now, so the
     drawer reads this token again. The three surfaces used to resolve to
     608px / 720px / 960px respectively; they all resolve to this. */
  --content-w:   1120px;
  /* The *minimum* side gutter, not the desktop margin. Above the width
     cap (--content-w + 2 * this) the auto margins on the centered
     containers set the visible side space and this value does nothing;
     below the cap, this is the whole gutter. Worth stating plainly,
     because it was misread for a while: this token grew 1.5rem -> 2rem
     -> 3.5rem chasing wider desktop margins it was never in a position
     to affect. Back to 2rem, which is a narrow-screen decision (about
     18% more content width on a 375px phone), not a desktop one. */
  --content-pad: 2rem;

  /* -- Motion ----------------------------------------------------------------*/
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
}

/* -- Light theme -------------------------------------------------------------
   Dark is the default and the only theme until someone flips the toggle in
   the settings menu (initThemeToggle(), js/main.js). The choice is saved and
   re-applied before first paint by an inline script in index.html's <head>,
   so this block is live from the very first frame for anyone who chose it.

   Palette "A · Paper" (James, 2026-09-29, of three mocked in
   _mock-light-mode.html): a warm off-white rather than pure white, with
   ink-dark text. Every value maps one-to-one onto a dark token above, so
   anything built on the tokens needed no light-specific work at all.
   Component-level exceptions (things with hardcoded color) live in the
   LIGHT THEME section at the end of this file.

   :root[data-theme] outranks plain :root, so these win regardless of where
   a token was first declared (the --ascii-* block further down included). */
:root[data-theme="light"] {
  --bg:              #f4f3ef;
  --bg-elevated:     #ebe9e3;
  --border:          #dfdcd4;
  --border-strong:   #cbc7bd;
  --text:            #1a1917;
  --text-secondary:  #5f5c55;
  --text-muted:      #8f8b82;

  --surface-invert:       #1a1917;
  --surface-invert-hover: #5f5c55;
  --text-on-invert:       #f4f3ef;
  --icon-color:           #1a1917;

  /* Hero field: tint-1 is the faint end, tint-3 the strong end, so light
     mode runs paper -> ink where dark runs near-black -> near-white. The
     field re-reads these on every theme switch ('themechange'). */
  --ascii-tint-1: #e2dfd8;
  --ascii-tint-2: #8f8b82;
  --ascii-tint-3: #1a1917;

  /* Native scrollbars and form controls follow the theme. */
  color-scheme: light;
}

/* The theme switch is a View Transition (initThemeToggle(), js/main.js). The
   browser's default is a cross-fade between the old and new snapshots; that
   is switched off here so the only motion is the circular reveal the JS
   animates on ::view-transition-new(root). */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}


/* =============================================================================
   BASE / RESET
   ============================================================================= */

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

html {
  scroll-behavior: smooth;
  /* Keeps anchor jumps clear of the fixed header. Derived from --nav-h
     rather than hand-matched to it: the header is opaque now, so a
     section landing under it isn't a near-miss the way it was with the
     old floating pill — it's genuinely hidden. --sp-8 on top of the bar's
     own height is the breathing room. */
  scroll-padding-top: calc(var(--nav-h) + var(--sp-8));
  /* Reserves the scrollbar's width permanently, so locking body scroll for
     the drawer (overflow: hidden) never changes the layout width and the
     centered content column doesn't shift sideways underneath the header. */
  scrollbar-gutter: stable;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--fs-base);
  font-weight: 400;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

a {
  color: inherit;
  text-decoration: none;
  transition: color 0.2s var(--ease), opacity 0.2s var(--ease),
              border-color 0.2s var(--ease);
}

a:hover {
  color: var(--text);
}

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

h1, h2, h3, h4, p, ul, li {
  margin: 0;
}

ul {
  list-style: none;
  padding: 0;
}

/* Off-screen for sighted users, still read aloud by screen readers. Used where
   the visual text has been split into per-letter spans (the hero tagline), so
   the word is announced once instead of letter by letter. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}


/* =============================================================================
   HEADER — full-bleed pinned bar

   A bar spanning the full viewport width, fixed flush to the top, holding
   the logo (left), the section links (centered), and a settings gear
   (right). James's Figma design, 2026-09-01, referencing pleurat.com.

   Replaces the floating centered pill that shipped earlier the same day —
   and, before that, a three-piece build whose pieces slid together into
   one shape on scroll. Both are gone; PLAN.md §5 carries that history if
   it's ever needed. What matters here is that neither the pill's
   centering math nor any scroll-linked state survives: this bar is a
   plain fixed element that never moves and never changes.

   Always translucent, never a second state. James asked for it pinned
   *and* for the background to go glassy on scroll — those collapse into
   one treatment, because the bar and the page share a background color.
   At the top of the page there's nothing behind the bar for translucency
   to reveal, so it reads as the flat bar in the Figma comp; the moment
   content scrolls under it, the blur does its thing. No scroll listener,
   no class to keep in sync. If a *measurably* different before/after look
   is ever wanted, that's one class toggle to add on top of this.
   ============================================================================= */

:root {
  /* Nav geometry — one place to retune the whole header.

     Values below are first-pass numbers read off the Figma screenshot,
     not exported from the file itself; James has the exact specs and
     they're expected to land here. Every one of them is consumed as a
     token precisely so swapping in a real value stays a one-line edit.

     --nav-h is load-bearing beyond the bar itself: html's
     scroll-padding-top and .section--hero's padding-top are both
     calculated from it (see each), so anchor jumps and the hero's top gap
     follow the bar's height automatically instead of being hand-matched
     numbers that drift the next time it changes. It has to stay a real
     length at every breakpoint for that reason — the ≤640px override
     below sets a two-row number rather than going `auto`. */
  --nav-h:         88px;
  --nav-link-gap:  64px;
  --nav-link-fs:   17px;

  /* The three graphics in the bar size independently (James, 2026-09-01:
     "I want to tweak the logo and gear icon sizes independently"), so each
     gets its own token rather than sharing one:
       --nav-logo-size  the JR monogram's height (width follows its ratio)
       --nav-gear-size  the gear glyph in the bar, and only that
       --nav-icon-size  the sound/theme glyphs inside the settings menu
     The gear is deliberately split off from --nav-icon-size: it sits out
     in the bar where its size is a visual decision, while the other two
     are hidden inside a panel. Nudging the gear shouldn't silently resize
     them too. */
  --nav-logo-size: 40px;
  --nav-gear-size: 26px;
  --nav-icon-size: 20px;

  /* Hit area for the logo and the gear. A floor, not a fixed size (see
     each rule's min-width/min-height) — with the hover circles gone this
     box paints nothing, so its only job is staying big enough to click
     even if a glyph inside it is set small. Raising a glyph past this
     grows the box rather than overflowing it. */
  --nav-btn-size:  48px;

  /* How far the logo and gear fade on hover — James, 2026-09-01, wanting
     "those graphics to turn slightly grey, just like the pleurat logo."
     That site does it with opacity too, and for white artwork on a dark
     background opacity *is* a grey ramp: 0.6 of #e8e8e8 over --bg lands
     within a couple of values of --text-secondary, which is what the
     links beside them fade to. Opacity rather than a color, because this
     has to work identically on an <img> (the monogram) and on a
     background-image span (the gear) — no single color property reaches
     both. Lower is greyer. */
  --nav-graphic-dim: 0.6;

  /* How much of --bg-elevated survives in the bar's background; the rest
     is transparency for the backdrop-filter to blur through. James:
     "a little transparent, still giving us the glass effect. Just a
     little. Then we can tweak from there." This is that knob — lower is
     glassier. */
  --nav-glass: 70%;
}

.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  height: var(--nav-h);
  padding: 0 var(--content-pad);
  border-bottom: 1px solid var(--border);
  /* Same glass recipe as .drawer-figure-caption and the old nav pill.
     Based on --bg-elevated rather than --bg so the bar sits a touch above
     the page tonally, the way it reads in the Figma comp — over an
     identical --bg page the difference only shows as a faint lift. */
  background: color-mix(in oklab, var(--bg-elevated) var(--nav-glass), transparent);
  backdrop-filter: blur(16px) saturate(1.4);
  -webkit-backdrop-filter: blur(16px) saturate(1.4);
}

/* Caps the bar's *contents* at the page's own column while the bar itself
   stays full-bleed, so the logo's left edge and the gear's right edge land
   on the same x as .hero-name and .work-list. Pad on the outer element,
   max-width on the inner — the pattern .site-footer/.footer-inner already
   uses. (.site-main's calc() version exists only because it has no inner
   wrapper to put the cap on; there's one here, so this is the simpler
   form.)

   `1fr auto 1fr` centers the links against the *bar*, not against the gap
   between the logo and the gear — those two are different widths, and
   centering the middle of three flex items between two unequal siblings
   is exactly the asymmetry that caused a real overflow bug during the
   three-piece nav build. Grid makes it free: the two 1fr tracks are equal
   by definition regardless of what sits in them. */
.nav-inner {
  max-width: var(--content-w);
  height: 100%;
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  grid-template-areas: "logo links settings";
  align-items: center;
}

/* No background of its own, hovered or not — James, 2026-09-01: the grey
   circle behind the monogram is gone, and the glyph itself dims instead
   (see .nav-logo-img below). min-* rather than fixed width/height so the
   box still guarantees a comfortable hit area at small glyph sizes but
   grows with --nav-logo-size instead of letting a larger monogram spill
   out of a 48px box. */
.nav-logo {
  grid-area: logo;
  justify-self: start;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  min-width: var(--nav-btn-size);
  min-height: var(--nav-btn-size);
}

/* Sized by its own token, not --nav-btn-size — that box is just a hit
   area now, and the glyph is the thing actually being looked at.
   width: auto reads correctly off the file's own (already-cropped)
   aspect ratio, so --nav-logo-size is the only knob needed. */
.nav-logo-img {
  display: block;
  height: var(--nav-logo-size);
  /* A <span> now, not an <img> (2026-09-29): masked like the icons so it
     follows --icon-color. A span has no intrinsic size, so the SVG's own
     viewBox ratio (796 x 1035) stands in for the width an <img> had. */
  aspect-ratio: 796 / 1035;
  background-color: var(--icon-color);
  -webkit-mask: var(--icon-monogram) center / contain no-repeat;
          mask: var(--icon-monogram) center / contain no-repeat;
  transition: transform 0.2s var(--ease), opacity 0.2s var(--ease);
}

/* The dim *is* the hover state now that the circle is gone. The scale is
   a separate, older flourish and reads better without a chip behind it,
   so it stays. */
.nav-logo:hover .nav-logo-img {
  opacity: var(--nav-graphic-dim);
  transform: scale(1.1);
}

.nav-links {
  grid-area: links;
  display: flex;
  align-items: center;
  gap: var(--nav-link-gap);
}

/* Colors are the inverse of the old pill's: links read white by default
   and *fade* to grey on hover, rather than sitting grey and lighting up.
   James, 2026-09-01, matching pleurat.com — which does the same thing via
   opacity; a color transition is used here instead so the fade lands on a
   known palette value rather than a washed-out version of white over
   whatever happens to be behind it. */
.nav-link {
  position: relative;
  font-size: var(--nav-link-fs);
  font-weight: 500;
  color: var(--text);
  white-space: nowrap;
  transition: color 0.2s var(--ease);
}

.nav-link:hover {
  color: var(--text-secondary);
}

/* The active-section marker, replacing the sliding white pill that used
   to do this job (.nav-indicator, deleted along with the ~40 lines of JS
   that measured and positioned it — a dot pinned to its own link needs
   neither).

   A dot rather than simply greying the active link: hover already fades
   *to* grey, so a grey active link would be indistinguishable from
   whatever is under the cursor at that moment. Two signals, two
   channels. */
.nav-link::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -6px;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--text);
  opacity: 0;
  transform: translate(-50%, 3px) scale(0.4);
  transition: opacity 0.25s var(--ease), transform 0.25s var(--ease);
}

.nav-link.is-active::after {
  opacity: 1;
  transform: translate(-50%, 0) scale(1);
}

@media (prefers-reduced-motion: reduce) {
  .nav-link::after {
    transition: opacity 0.25s var(--ease);
  }

  .nav-link.is-active::after {
    transform: translate(-50%, 0) scale(1);
  }
}

/* position: relative so .settings-menu can anchor to the gear rather than
   to the bar — the menu's right edge should line up with the button's,
   not with the page column. */
.nav-settings {
  grid-area: settings;
  justify-self: end;
  position: relative;
  display: flex;
  align-items: center;
}

/* The gear opts out of the chip background .util-btn gives every other
   icon button — James, 2026-09-01, same ask as the logo: no grey circle
   on hover *or* while the menu is open. It dims its own glyph instead
   (below), matching the monogram at the other end of the bar.

   Scoped to this one button rather than changed on .util-btn itself,
   because the two buttons inside the menu genuinely still want the chip:
   they sit on a panel where a hover highlight is the normal affordance,
   and #theme-toggle's pressed background is the only feedback that an
   otherwise-inert toggle registered a click (see its rule below). */
#settings-toggle:hover,
#settings-toggle[aria-expanded="true"] {
  background: none;
}

#settings-toggle .util-icon {
  width: var(--nav-gear-size);
  height: var(--nav-gear-size);
}

#settings-toggle:hover .util-icon,
#settings-toggle[aria-expanded="true"] .util-icon {
  opacity: var(--nav-graphic-dim);
}

/* Dropped from the gear rather than the bar's bottom edge, so it reads as
   belonging to that button. Sits inside .site-header's z-index: 100
   stacking context, which puts it above page content and below the drawer
   (200) — correct on both counts, and no z-index of its own needed.

   [hidden] does the actual showing/hiding (set by initSettingsMenu()).
   Its default `display: none` would beat this rule's `display: flex` on
   specificity grounds if left to itself, hence the explicit
   `&[hidden] { display: none }` below rather than relying on the UA
   sheet. */
.settings-menu {
  position: absolute;
  top: calc(100% + var(--sp-2));
  right: 0;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: color-mix(in oklab, var(--bg-elevated) 92%, transparent);
  backdrop-filter: blur(16px) saturate(1.4);
  -webkit-backdrop-filter: blur(16px) saturate(1.4);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}

.settings-menu[hidden] {
  display: none;
}

/* min-* rather than fixed, same reasoning as .nav-logo: the box is a hit
   area, and a glyph set larger than it should grow it rather than spill
   out of it. */
.util-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: var(--nav-btn-size);
  min-height: var(--nav-btn-size);
  padding: 0;
  border: none;
  border-radius: 999px;
  background: none;
  color: var(--text-secondary);
  cursor: pointer;
  transition: color 0.2s var(--ease), background-color 0.2s var(--ease);
}

.util-btn:hover {
  color: var(--text);
  background: var(--border);
}

/* -- Icon registry -----------------------------------------------------------
   Every masked icon's SVG, embedded as a data: URL (2026-09-29). The files
   in assets/icons/ and assets/images/jr-monogram.svg stay the source;
   these are generated from them, so if an icon file changes, re-encode it
   here (strip the <?xml?> line, collapse whitespace, " -> ', then
   percent-encode % # < >).

   Why not just a url() to the file: a mask whose image fails to
   load hides its element completely, and a separate SVG request can fail
   where a data: URL can't (a file:// preview, where Chrome blocks mask
   images; a blocked or slow request). That is exactly how the logo, gear,
   mute and close icons went missing for James the day they became masks.
   data: URLs are also absolute, so they're safe inside custom properties.
   Rules read these as mask-image: var(--icon-<name>); the drawer's
   JS-rendered icons do the same through an inline style. */
:root {
  --icon-arrow-right: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%239a9a9a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M5 12l14 0'/%3E %3Cpath d='M13 18l6 -6'/%3E %3Cpath d='M13 6l6 6'/%3E %3C/svg%3E");
  --icon-building-bank: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M3 21l18 0'/%3E %3Cpath d='M3 10l18 0'/%3E %3Cpath d='M5 6l7 -3l7 3'/%3E %3Cpath d='M4 10l0 11'/%3E %3Cpath d='M20 10l0 11'/%3E %3Cpath d='M8 14l0 3'/%3E %3Cpath d='M12 14l0 3'/%3E %3Cpath d='M16 14l0 3'/%3E %3C/svg%3E");
  --icon-calendar-event: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12'/%3E %3Cpath d='M16 3l0 4'/%3E %3Cpath d='M8 3l0 4'/%3E %3Cpath d='M4 11l16 0'/%3E %3Cpath d='M8 15h2v2h-2l0 -2'/%3E %3C/svg%3E");
  --icon-clock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0'/%3E %3Cpath d='M12 7v5l3 3'/%3E %3C/svg%3E");
  --icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M18 6l-12 12'/%3E %3Cpath d='M6 6l12 12'/%3E %3C/svg%3E");
  --icon-components: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M3 12l3 3l3 -3l-3 -3l-3 3'/%3E %3Cpath d='M15 12l3 3l3 -3l-3 -3l-3 3'/%3E %3Cpath d='M9 6l3 3l3 -3l-3 -3l-3 3'/%3E %3Cpath d='M9 18l3 3l3 -3l-3 -3l-3 3'/%3E %3C/svg%3E");
  --icon-cursor-text: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath d='M10 12h4'/%3E %3Cpath d='M9 4a3 3 0 0 1 3 3v10a3 3 0 0 1 -3 3'/%3E %3Cpath d='M15 4a3 3 0 0 0 -3 3v10a3 3 0 0 0 3 3'/%3E %3C/svg%3E");
  --icon-flag-3: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M5 14h14l-4.5 -4.5l4.5 -4.5h-14v16'/%3E %3C/svg%3E");
  --icon-gear: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z'/%3E %3Cpath d='M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0'/%3E %3C/svg%3E");
  --icon-hourglass: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M6 20v-2a6 6 0 1 1 12 0v2a1 1 0 0 1 -1 1h-10a1 1 0 0 1 -1 -1z'/%3E %3Cpath d='M6 4v2a6 6 0 1 0 12 0v-2a1 1 0 0 0 -1 -1h-10a1 1 0 0 0 -1 1z'/%3E %3C/svg%3E");
  --icon-library: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath d='M7 5.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667l0 -8.666'/%3E %3Cpath d='M4.012 7.26a2.005 2.005 0 0 0 -1.012 1.737v10c0 1.1 .9 2 2 2h10c.75 0 1.158 -.385 1.5 -1'/%3E %3Cpath d='M11 7h5'/%3E %3Cpath d='M11 10h6'/%3E %3Cpath d='M11 13h3'/%3E %3C/svg%3E");
  --icon-rocket: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3 -5a9 9 0 0 0 6 -8a3 3 0 0 0 -3 -3a9 9 0 0 0 -8 6a6 6 0 0 0 -5 3'/%3E %3Cpath d='M7 14a6 6 0 0 0 -3 6a6 6 0 0 0 6 -3'/%3E %3Cpath d='M14 9a1 1 0 1 0 2 0a1 1 0 1 0 -2 0'/%3E %3C/svg%3E");
  --icon-ruler-measure: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M19.875 12c.621 0 1.125 .512 1.125 1.143v5.714c0 .631 -.504 1.143 -1.125 1.143h-15.875a1 1 0 0 1 -1 -1v-5.857c0 -.631 .504 -1.143 1.125 -1.143h15.75'/%3E %3Cpath d='M9 12v2'/%3E %3Cpath d='M6 12v3'/%3E %3Cpath d='M12 12v3'/%3E %3Cpath d='M18 12v3'/%3E %3Cpath d='M15 12v2'/%3E %3Cpath d='M3 3v4'/%3E %3Cpath d='M3 5h18'/%3E %3Cpath d='M21 3v4'/%3E %3C/svg%3E");
  --icon-sound-off: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M15 8a5 5 0 0 1 1.912 4.934m-1.377 2.602a5 5 0 0 1 -.535 .464'/%3E %3Cpath d='M17.7 5a9 9 0 0 1 2.362 11.086m-1.676 2.299a9 9 0 0 1 -.686 .615'/%3E %3Cpath d='M9.069 5.054l.431 -.554a.8 .8 0 0 1 1.5 .5v2m0 4v8a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l1.294 -1.664'/%3E %3Cpath d='M3 3l18 18'/%3E %3C/svg%3E");
  --icon-sound-on: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M15 8a5 5 0 0 1 0 8'/%3E %3Cpath d='M17.7 5a9 9 0 0 1 0 14'/%3E %3Cpath d='M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a.8 .8 0 0 1 1.5 .5v14a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5'/%3E %3C/svg%3E");
  --icon-trending-up: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M3 17l6 -6l4 4l8 -8'/%3E %3Cpath d='M14 7l7 0l0 7'/%3E %3C/svg%3E");
  --icon-user: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E %3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E %3Cpath d='M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0'/%3E %3Cpath d='M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2'/%3E %3C/svg%3E");
  --icon-monogram: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='102 -17.5 796 1035'%3E %3Cpath fill='%23ffffff' d='M626.08,915.86c-.21,1.21-1.66,3.7-2.78,4.53l-119.61,76.69c-5.69,2.1-14.03-4.31-19.07-7.34-101.47-60.99-200.74-125.96-302.02-187.38-17.82-10.81-37.99-20.91-54.99-32.66-3.15-2.18-5.9-3.5-5.6-8.1l.02-134.28c.08-3.11,2.24-5.58,4.87-7,39.7-19.36,77.87-42.02,117.49-61.47,4.61-2.26,8.56-5.07,13.75-1.84l115.92,65.31c.13-5.32-.14-10.68-.04-16.01,1.11-58.09-.06-117.57,0-176.21.06-60.59.51-122.26,1-182.53.08-10.25-1.1-23.11-.06-32.93.58-5.56,5.28-7.18,9.45-9.73,67.01-40.98,139.08-78.53,207.59-117.46,48.86-27.76,97.25-56.39,146.35-83.74,4.65-3.25,9.99.78,14.35,3.07,37.69,19.8,79.84,42.59,115.96,64.82,4.47,2.75,8.56,3.92,9.35,9.83l-.32,389.39c-.59,2.71-2.54,4.68-4.78,6.17l-111.7,64.53c0,1.78,4.88,4.35,6.47,5.4,35.52,23.41,73.17,44.15,108.41,67.81l1.13,5.27-.09,149.73-3.28,4.93c-33.02,18.01-65.77,36.55-98.64,54.76-5.11,2.83-14.1,9.52-18.81,11.32-2.76,1.06-5.89,1.19-8.63,0l-107.16-67.23-14.53-7.84v150.19ZM855.26,462.55V100.99l-339.67,189.45v673.36l88.43-57.2.09-289.33c-.26-5.58,4.48-8.63,9.21-5.47l143.32,84.49v120.52l98.48-54.92.14-132.72-.91-1.83-133.29-82.19c-1.64-.98-2.28-2.49-1.74-4.36l135.93-78.24ZM493.68,288.16l-96.79-58.43v542.79c0,.59-1.77,5.09-2.31,5.91-3.48,5.3-11.67,7.02-16.9,3.26l-233.71-139.28v111.85l47.76,28.93c101.27,61.32,201.46,124.44,301.95,187V288.16Z'/%3E %3Cpath fill='%23ffffff' d='M751.78,417.51l-134.43,76.03c-8.06,1.23-11.7-3.95-12.31-11.33l.44-135.68c1.4-6.62,4.77-7.93,9.94-11.06,40.4-24.51,82.97-45.74,123.52-70.05,9.96-3.94,16.42,2.39,16.83,12.19v129.72c-.03,3.81-1.87,7.17-3.99,10.16Z'/%3E %3C/svg%3E");
}

/* Icons are CSS masks filled with --icon-color (2026-09-29, for the light
   theme; they were background-images with a white stroke baked into each
   SVG file before that). A mask reads the rendered SVG's alpha, and a
   stroke is opaque wherever it's drawn, so stroke-only icons like these
   Tabler ones mask perfectly well — tested on every icon in the set before
   switching. (This comment used to claim the opposite; it was wrong.) The
   same treatment covers .drawer-close-icon, .drawer-meta-icon and the nav
   logo. The images come from the icon registry just above, never from a
   url() to the file (see there for why). The theme toggle's own sun/moon
   is an inline SVG, see .theme-icon. */
.util-icon {
  /* .util-icon is a <span>, which is display:inline by default — inline
     elements ignore width/height entirely, so without this the icon was
     collapsing to zero size. */
  display: block;
  width: var(--nav-icon-size);
  height: var(--nav-icon-size);
  background-color: var(--icon-color);
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
  /* Only the gear actually dims (see #settings-toggle above) — declared
     here rather than there so the property has a value to transition
     *from*, which a rule that only ever sets the hovered state wouldn't
     give it. */
  transition: opacity 0.2s var(--ease);
}

.util-icon-sound-on {
  -webkit-mask-image: var(--icon-sound-on);
          mask-image: var(--icon-sound-on);
}

.util-icon-sound-off {
  -webkit-mask-image: var(--icon-sound-off);
          mask-image: var(--icon-sound-off);
}

/* The theme toggle's icon: one inline SVG that morphs between a sun (light)
   and a moon (dark), after khasiyev.com's. Eight rays spin and shrink away,
   the core circle grows and fills, and a mask dot slides in to bite the
   crescent out of it; back to the sun, the rays spring out with a slight
   overshoot. Shows the CURRENT theme, like theirs. Base state is the moon
   (dark is the default); the light-theme rules below are the sun. Each
   state's rule carries the transition used when ENTERING it, so the two
   directions get different choreography. */
.theme-icon {
  display: block;
  width: var(--nav-icon-size);
  height: var(--nav-icon-size);
  overflow: visible;
  color: var(--icon-color);
}

.theme-icon-rays {
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  transform-box: fill-box;
  transform-origin: center;
  opacity: 0;
  transform: rotate(90deg) scale(0.2);
  transition: transform 0.22s cubic-bezier(0.55, 0, 0.55, 0.2),
              opacity 0.16s linear;
}

.theme-icon-core {
  fill: currentColor;
  fill-opacity: 1;
  stroke: currentColor;
  stroke-width: 2;
  transform-box: fill-box;
  transform-origin: center;
  transform: scale(1.6);
  transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) 0.1s,
              fill-opacity 0.2s linear 0.1s;
}

.theme-icon-bite {
  transform: translate(0, 0);
  transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) 0.14s;
}

:root[data-theme="light"] .theme-icon-rays {
  opacity: 1;
  transform: none;
  transition: transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1) 0.15s,
              opacity 0.18s linear 0.15s;
}

:root[data-theme="light"] .theme-icon-core {
  fill-opacity: 0;
  transform: none;
  transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) 40ms,
              fill-opacity 0.2s linear 40ms;
}

:root[data-theme="light"] .theme-icon-bite {
  transform: translate(8.6px, -8.6px);
  transition: transform 0.26s cubic-bezier(0.2, 0.8, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  .theme-icon * { transition: none !important; }
}

/* Opens the settings menu holding the two toggles above. Same Tabler
   outline family and same baked-white stroke as the others. */
.util-icon-gear {
  -webkit-mask-image: var(--icon-gear);
          mask-image: var(--icon-gear);
}

/* Spawned at click coordinates by initSoundSparkle() in main.js. Tied to
   the sound/mute state above — muted means no sparkle either, so this
   only ever appears when .util-icon-sound-on is showing. */
.sparkle {
  position: fixed;
  z-index: 300;
  pointer-events: none;
  width: 14px;
  height: 14px;
  color: var(--text-secondary);
  transform: translate(-50%, -50%);
  animation: sparkle-pop 500ms var(--ease) forwards;
}

.sparkle svg {
  display: block;
  width: 100%;
  height: 100%;
}

@keyframes sparkle-pop {
  0% {
    opacity: 0;
    transform: translate(-50%, -50%) scale(0.2) rotate(0deg);
  }
  35% {
    opacity: 1;
    transform: translate(-50%, -50%) scale(1) rotate(8deg);
  }
  100% {
    opacity: 0;
    transform: translate(-50%, -50%) scale(0.6) rotate(16deg);
  }
}


/* =============================================================================
   LAYOUT — main column
   ============================================================================= */

/* The pad sits *inside* the max-width here (box-sizing: border-box is set
   globally), so the cap has to include it for the content box to come out
   at exactly --content-w. Without the calc, max-width: var(--content-w)
   yields a content column of --content-w minus both pads — which is
   precisely the bug this replaced: a 720px cap against a 56px pad
   rendered a 608px column, while .footer-inner and .drawer-content-inner
   (both of which put their pad on an outer, full-width element) rendered
   their max-width as the real content width. Three surfaces, three
   different widths, and the footer's text sitting 56px outside the main
   column's left edge. All three resolve to --content-w now. */
.site-main {
  max-width: calc(var(--content-w) + 2 * var(--content-pad));
  margin: 0 auto;
  padding: 0 var(--content-pad);
}


/* =============================================================================
   SECTIONS — shared
   ============================================================================= */

.section {
  padding: var(--sp-24) 0;
  border-bottom: 1px solid var(--border);
}

.section:last-of-type {
  border-bottom: 0;
}

/* No line between About and Contact (James, 2026-09-18), and none under
   Selected Work or Experiments either (James, 2026-09-23). Transparent rather
   than 0 so the 1px box stays and the spacing between sections is unchanged.

   Note that with the hero (0) and Contact (:last-of-type) already covered,
   this leaves NO section on the page showing the .section divider above.
   The base rule is kept because it is what reserves the 1px, and because a
   future section added to the page should get a conscious yes/no. */
#work,
#experiments,
#about {
  border-bottom-color: transparent;
}

/* Matches .drawer-section-title exactly - same face, size, weight, spacing
   and colour (James, 2026-09-02: "the same section text style and size as in
   our project drawers"). Was mono / --fs-sm / --text-muted / 0.02em, a small
   grey label; it is a real heading now.

   This is also the mono-retirement pass for section labels: they were on
   STYLEGUIDE 1's "still mono" list, and this takes them off it. */
.section-title {
  font-family: var(--font-sans);
  font-size: var(--fs-section-heading);
  font-weight: 600;
  color: var(--text);
  letter-spacing: -0.01em;
  line-height: 1.25;
  margin-bottom: var(--sp-4);
}

/* Heading + subheader read as one block (James, 2026-09-29, mocked in
   _mock-section-leads.html): the pair sits tight, then a shorter drop to
   the content than the old --sp-8. Every homepage section has a lead as of
   that date; the :has() keeps a lead-less heading on its original --sp-4
   rather than baking the tight gap into .section-title itself. */
.section-title:has(+ .section-lead) {
  margin-bottom: var(--section-title-lead-gap);
}

.section-lead {
  font-size: var(--fs-lg);
  color: var(--text-secondary);
  margin-bottom: var(--section-lead-gap);
  max-width: 52ch;
}


/* =============================================================================
   HERO - bordered module with a generated ASCII field behind it

   James's Figma, 2026-09-02. One bordered box holding the greeting line, the
   tagline, a Resume button and the availability pill, over a monospace character
   field generated on the GPU (initHeroField(), js/main.js).

   Replaces layout "06" (meta row + rule + tagline + Currently), which measured
   502px tall at 1440x900 - 56vh, a band rather than a hero. Reference set was
   bouayaben.com, dvdrod.com, mariajoaoabrantes.work, ozgur.design and
   pleurat.com; the field itself is after benjamincreative.me.
   ============================================================================= */

:root {
  /* -- Hero module geometry ------------------------------------------------ */
  --hero-module-pad:    var(--sp-12);
  --hero-module-radius: 4px;
  --hero-module-min-h:  620px;

  /* -- Hero vertical spacing, top to bottom --------------------------------
     The four gaps down the hero, in the order you meet them scrolling. They
     are collected here rather than left inline because two of them were
     previously untunable in isolation: --hero-divider-below was the SHARED
     `.section` padding, so nudging the gap under the hero's divider used to
     move every other section on the page with it.

     Plain px rather than --sp-* tokens, James 2026-09-02, so they are easier
     to tune by eye. The values below are exactly what the tokens resolved to
     (--sp-12 = 48px, --sp-16 = 64px, --sp-24 = 96px), so the switch changed
     nothing visually.

     Note the shape of the change: it is `64px`, NOT `var(--sp-64px)`. var()
     looks up a declared custom property by name, so a length goes in bare and
     the whole var(...) call is what gets replaced. An undefined name inside
     var() with no fallback makes the declaration invalid at computed-value
     time, which here silently zeroed the hero's top padding.

     One consequence worth knowing: --hero-divider-below used to read --sp-24,
     which the <=640px block redefines (6rem -> 4rem), so it shrank on mobile
     by itself. A px literal cannot do that, so the mobile value is now set
     explicitly in that same block - see it there. */

  /* Header bar to the top of the module. */
  --hero-top-space:     54px;

  /* The "Hello! I'm..." line to the tagline.

     MINIMUM ~30px, and this is a real constraint, not taste. It is the
     clearance the "Interfaces" hover label needs: that label sits roughly
     26px above the word's top edge (--tagline-fs * 0.14, plus 0.3em of its
     own 12px text, plus its ~14px height), and below about 30px here it
     lands on top of the greeting. At the current 48px the measured clearance
     is 22px. Going smaller is fine visually right up until you hover. */
  --hero-greeting-gap:  2px;

  /* Bottom of the module down to the divider line. */
  --hero-divider-above: 48px;

  /* Module down to the "Selected Work" heading. 48px -> 64px, 2026-09-18,
     when the divider line between them was removed ("a little more space"). */
  --hero-divider-below: 64px;

  /* -- The ASCII field ------------------------------------------------------
     Every value James tuned in the mock round, kept as a token here so the
     field stays tunable from one place. js/main.js reads all of them ONCE at
     init (getComputedStyle has no business in a rAF loop), so changes need a
     reload rather than being live - but nothing here is hardcoded twice.
     The one exception is the three --ascii-tint-* colors, re-read on every
     theme switch; light mode overrides them (see the light theme block). 

     A caution worth reading before tuning: --ascii-fold-size is the control
     over how chunky the glyphs read, and it is the one that fixed the speckle.
     Higher is noisier, fast. Measured spatial churn (% of neighbouring cells
     holding different glyphs): 0.25 -> 40%, 0.50 -> 56%, 1.00 -> 79%.
     See asciiSilk()'s comment in js/main.js for the full measurement table. */
  --ascii-cell:          15px;   /* monospace cell height; width follows the face */
  --ascii-zoom:          2.7;    /* field units across the module */
  --ascii-fold-size:     0.53;   /* warp frequency - the chunkiness knob */
  --ascii-fold-str:      4;      /* how far the warp displaces */
  --ascii-vertical:      4;      /* N/S bias of the fold; 1 is isotropic */
  --ascii-drift:         0.03;   /* how fast the whole sheet travels north */
  --ascii-detail:        1;      /* fBm octaves, 1-4 */
  --ascii-speed:         0.6;    /* time multiplier */
  /* Redraw cadence, NOT animation speed - --ascii-speed still owns how fast
     the field moves, and simulated time keeps advancing continuously between
     redraws, so lowering this slows the repaint rate without slowing the
     motion.

     30 rather than 60 because at the shipped --ascii-drift/--ascii-speed the
     field measurably does not change between consecutive 60fps frames: a
     frame-by-frame pixel diff came out at 0% different, rising to only 7%
     over a full second. More than half of a 60fps budget was being spent
     publishing frames identical to the one before.

     That is not free even though the shader itself costs ~0ms: every redraw
     re-uploads a full-width canvas layer, forces a composite, and - because
     the fixed header sits over this canvas once you scroll - invalidates the
     header's backdrop-filter blur, which has to re-sample its backdrop
     whenever anything behind it changes. Halving the redraw rate halves all
     three. Raise it to 60 to go back to the old behaviour. */
  --ascii-fps:           30;
  --ascii-contrast:      0.65;   /* spread around the calibrated midpoint */
  --ascii-opacity:       0.12;   /* the whole layer, CSS-side */
  --ascii-wash:          0.07;   /* continuous tone under the glyphs */
  --ascii-wash-contrast: 1.9;
  --ascii-mouse:         0.03;   /* pointer push strength; 0 disables it */
  --ascii-mouse-radius:  0.87;

  /* Three gradient stops at 0 / 0.55 / 1 along the field's diagonal. Setting
     all three to one value gives a flat colour, so there is no separate
     "flat" mode to maintain - flat is a special case of this. */
  --ascii-tint-1: #242424;
  --ascii-tint-2: #6f6f6f;
  --ascii-tint-3: #e8e8e8;
}

.section--hero {
  /* Clears the fixed header. Reads the bar's own height out of the token so it
     cannot drift the next time --nav-h changes; only the --sp-8 is a judgment
     call. Much tighter than the --sp-24 the old layout used, because the module
     below now carries its own generous padding and the border wants to sit
     reasonably high in the viewport. */
  padding-top: calc(var(--nav-h) + var(--hero-top-space));
  padding-bottom: var(--hero-divider-above);
  /* No divider under the hero (James, 2026-09-18; it was added 2026-09-02).
     --hero-divider-above/-below keep their names but now just set the gap. */
  border-bottom: 0;
}

/* Scoped to the hero's own next sibling on purpose. The gap under the divider
   is the FOLLOWING section's padding-top, and that comes from the shared
   `.section` rule - so tuning it there would move the top of every section on
   the page. This overrides it for this one seam only. */
.section--hero + .section {
  padding-top: var(--hero-divider-below);
}

.hero-module {
  position: relative;
  min-height: var(--hero-module-min-h);
  padding: var(--hero-module-pad);
  /* No border. James, 2026-09-02 - the field should read as texture bleeding
     into the page, not as a boxed panel. --hero-module-radius survives because
     it still rounds the CLIPPED canvas (see overflow below); it is no longer
     drawing a visible outline. */
  border-radius: var(--hero-module-radius);
  display: flex;
  flex-direction: column;
  /* Both load-bearing. overflow clips the field to the rounded corners;
     isolation gives the module its own stacking context so the field's z-index
     can never interact with the header or the drawer. */
  overflow: hidden;
  isolation: isolate;
}

/* A <canvas>, not live <pre> text. js/main.js's ASCII notes record that a
   <pre> needed scale-to-fit JS plus per-font line-box padding compensation and
   was eventually swapped to static PNGs to stop maintaining that. Canvas has
   no text layout to fight, and this field has to animate. */
.hero-field {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
  opacity: var(--ascii-opacity);
}

/* Everything that is not the field sits above it. One rule rather than a
   z-index on each child, so adding content to the module needs no thought. */
.hero-module > *:not(.hero-field) {
  position: relative;
  z-index: 1;
}

/* The <h1>. Stays a real h1 for semantics and SEO even though the tagline
   below is the bigger thing visually - the tag and the visual weight do not
   have to agree, the same reasoning the old .hero-name carried. */
.hero-greeting {
  font-size: clamp(16px, 1.6vw, 23px);
  font-weight: 400;
  color: var(--text-secondary);
  line-height: 1.35;
  letter-spacing: -0.01em;
}

.hero-greeting strong {
  color: var(--text);
  font-weight: 600;
}

/* margin-top: auto pins the row to the bottom of the module however tall it
   grows, which is what makes --hero-module-min-h the only height knob. */
.hero-actions {
  margin-top: auto;
  padding-top: var(--sp-16);
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: var(--sp-6);
  flex-wrap: wrap;
}

.hero-btns {
  display: flex;
  gap: var(--sp-3);
}

/* Same shape language as .drawer-live-link (pill, white fill, --text-on-light)
   rather than a second button vocabulary invented for the homepage. */
.hero-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 8px 18px;
  border-radius: 999px;
  font-size: var(--fs-sm);
  font-weight: 500;
  background: var(--surface-invert);
  color: var(--text-on-invert);
  border: 1px solid var(--surface-invert);
  transition: opacity 0.2s var(--ease), border-color 0.2s var(--ease),
              color 0.2s var(--ease);
}

.hero-btn--ghost {
  background: transparent;
  color: var(--text);
  border-color: var(--border-strong);
}

.hero-btn:hover { opacity: 0.85; }
.hero-btn--ghost:hover { opacity: 1; border-color: var(--text-muted); }

.hero-availability {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  padding: 7px 14px;
}

/* The one non-greyscale mark in the site's chrome, and deliberately so: a
   status dot reads as a live signal only if it is green. The tagline's
   rainbow is the other exception (STYLEGUIDE.md 2). */
.hero-availability-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #4ade80;
  flex-shrink: 0;
}

/* -- Currently: -------------------------------------------------------------
   PARKED, not dead. James asked for this kept and scaled up; the Figma he
   drew afterwards has no place for it, so the element is not currently in
   index.html. initCurrently() (js/main.js) no-ops safely without it and these
   rules are left intact so putting the markup back is a one-line change. See
   PLAN.md for the open question. */
.hero-currently {
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  color: var(--text-muted);
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  margin-top: var(--sp-8);
  /* Reserve two lines so a longer phrase wrapping on narrow screens
     doesn't shove the rest of the page around mid-rotation. */
  min-height: calc(var(--fs-sm) * 1.55 * 2);
}

.hero-currently-label {
  color: var(--text);
  flex-shrink: 0;
}

.hero-currently-value {
  transition: opacity 0.4s var(--ease), transform 0.4s var(--ease);
}

/* Applied by JS during the swap window. */
.hero-currently-value.is-swapping {
  opacity: 0;
  transform: translateY(-4px);
}

@media (prefers-reduced-motion: reduce) {
  .hero-currently-value,
  .hero-currently-value.is-swapping {
    transition: none;
    opacity: 1;
    transform: none;
  }
}


/* -- Hero tagline ------------------------------------------------------------
   "Interfaces, Systems & Interactivity" — one line, three words, each with its
   own hover effect that enacts the meaning of that word. Interfaces and
   Systems are JS-driven overlays that ignore touch pointers; Interactivity's
   :hover rule is inside an `@media (hover: hover)` block so a tap can't leave
   it stuck on (its drag state covers touch instead). */

.hero-tagline {
  /* Fluid, and back to a container-derived formula (2026-09-02, second pass).
     James: the desktop tagline must sit on ONE line, and a brief
     clamp(34px, 5.1vw, 74px) did not - it also wrapped non-monotonically
     (one line at 1000-1200px, two at 1440px+) because a vw-based size keeps
     growing after the container has capped at --content-w.

     Solving against the container instead removes both problems. Text width
     scales linearly with font-size for a fixed string (measured at ~16.1px of
     width per 1px of font-size for this one), and the module's inner width is
     `100vw - 160px`: the two --content-pad sides (64px) plus the two
     --hero-module-pad sides (96px). The 0.054 coefficient fills ~94% of that,
     which is what the comp shows. The 60px cap is where the formula lands once
     --content-w takes over, and it holds one line at every width above the
     640px breakpoint (verified by sweeping rendered widths, not assumed).

     Below 640px --hero-module-pad drops to --sp-6, so the real column is
     `100vw - 112px` and the 28px floor wraps to two or three lines. That is
     fine and is not the same risk it used to be: the "Interfaces" overlay's
     name chip and size pill are solid-filled (2026-09-29), so they stay
     legible when they land over a neighbouring line.

     Not a new token on the --fs-* scale, which has no fluid entries - this is
     the one place on the page that scales with viewport width at all, the same
     hand-tuned-exception category as .drawer-section-title's 30px is for a
     fixed one.

     Declared as a custom property first, with font-size just reading it back,
     rather than writing the clamp() directly into font-size: the "Systems"
     code editor (.tagline-sys-editor, below) sizes itself off this exact
     number from inside an element with its own, much smaller font-size, and
     em there would resolve against *that*. A custom property has no
     font-size dependency at all, so it evaluates to the same px number
     wherever it's read. */
  --tagline-fs: clamp(28px, calc((100vw - 160px) * 0.054), 60px);

  /* Hover-overlay knobs for "Interfaces" and "Systems" (rules below). The
     colors are the sanctioned exception to greyscale chrome — see the
     COLOR note above .tagline-word--interfaces. */
  --tagline-fig:        #0d99ff;  /* Figma's selection blue */
  --tagline-fig-fs:     11px;     /* name chip, size pill, cursor tag */
  --tagline-fig-pad-x:  2px;      /* box outset past the word, each side */

  --tagline-code-scale:  0.2;     /* editor font-size = --tagline-fs * this */
  --tagline-code-fs-min: 9px;     /* ...never smaller than this */
  /* Syntax colors: the Interactivity gradient's own stops. */
  --tagline-code-prop:  #38bdf8;  /* custom property names */
  --tagline-code-num:   #ffe14d;  /* numbers + units */
  --tagline-code-fn:    #f472b6;  /* var() */
  --tagline-code-sel:   #a78bfa55;/* select-sweep band */
  /* Editor chrome: VS Code Dark+ greys. */
  --tagline-code-fg:    #cccccc;  /* punctuation */
  --tagline-code-ln:    #6e7681;  /* line numbers */
  --tagline-code-ln-on: #cccccc;  /* active line number */
  --tagline-code-line:  #ffffff13;/* active-line outline */
  --tagline-code-caret: #aeafad;
  --tagline-code-pop-bg:     #202020;  /* autocomplete panel */
  --tagline-code-pop-border: #454545;
  --tagline-code-pop-on:     #04395e;  /* autocomplete highlighted row */

  /* "Interactivity" color sweep. Symmetric ease-in-out, deliberately not
     --ease: an expo-out spends its change up front, which is the "sudden"
     this replaced. */
  --tagline-sweep-in:   0.9s;
  --tagline-sweep-out:  0.5s;
  --tagline-sweep-ease: cubic-bezier(0.45, 0, 0.55, 1);
  font-size: var(--tagline-fs);
  font-weight: 600;
  letter-spacing: -0.02em;
  /* 1.1, tighter than the 1.15 the flat layout used: at 74px across up to
     three lines the looser value opened visible gaps between them. */
  line-height: 1.1;
  color: var(--text);
  /* Top gap only, and it is --hero-greeting-gap rather than a literal because
     it is load-bearing: see that token's comment for why it has a ~30px floor
     (the "Interfaces" hover label needs the room, and collided with the
     greeting when this was --sp-2). The bottom is handled by .hero-actions'
     own margin-top: auto, which pushes the button row to the floor of the
     module however much space is left. */
  margin: var(--hero-greeting-gap) 0 0;
}

.tagline-word {
  position: relative;
  /* A word must never break internally — that would split a hover overlay
     across two lines. The line may only ever break between words. */
  white-space: nowrap;
}

.tagline-punct {
  color: var(--text-secondary);
}

/* "Interfaces" and "Systems" (rebuilt 2026-09-29, after khasiyev.com's
   Design/Code words, mocked in _mock-hero-hover.html). Both are JS-built
   overlays — initHeroTagline() in js/main.js injects the markup and drives
   state through data attributes; everything visual lives here.

   COLOR, and why it's allowed here. Site chrome is greyscale (STYLEGUIDE
   §2). James relaxed that for these overlays on 2026-09-29: color is fine
   used sparingly where it's part of what the word imitates — Figma's own
   selection blue on "Interfaces", editor syntax colors on "Systems". Neither
   is a UI accent; don't reach for these tokens outside the tagline. The
   syntax colors are the Interactivity gradient's own stops, so the whole
   line only adds one hue the page didn't already paint (the blue). */

/* -- "Interfaces": Figma selection + multiplayer cursor -------------------- */

/* The system pointer is swapped for the drawn one while the overlay is up. */
.tagline-word--interfaces[data-fx="in"] {
  cursor: none;
}

/* The overlay IS the selection box: it sits on the word's own box, pushed
   out --tagline-fig-pad-x each side, and every other piece (handles, name
   chip, size pill, cursor) is positioned against it. */
.tagline-fig {
  position: absolute;
  inset: 0 calc(-1 * var(--tagline-fig-pad-x));
  z-index: 4;
  pointer-events: none;
  font-family: var(--font-sans);
  font-size: var(--tagline-fig-fs);
  font-weight: 500;
  letter-spacing: 0.01em;
  line-height: 1;
  white-space: nowrap;
}

.tagline-fig::before {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px solid var(--tagline-fig);
}

.tagline-fig-handle {
  position: absolute;
  width: 7px;
  height: 7px;
  background: #fff;
  border: 1px solid var(--tagline-fig);
  translate: -50% -50%;
}
.tagline-fig-handle[data-c="tl"] { top: 0;    left: 0; }
.tagline-fig-handle[data-c="tr"] { top: 0;    left: 100%; }
.tagline-fig-handle[data-c="bl"] { top: 100%; left: 0; }
.tagline-fig-handle[data-c="br"] { top: 100%; left: 100%; }

/* A solid chip, not Figma's bare blue text: the greeting sits flush against
   the top of the word (measured 0.3px gap), so bare text printed straight
   over "Hello! I'm". Solid fills also keep the chip and pill legible when
   the tagline wraps on narrow widths and they land over a neighbouring line. */
.tagline-fig-name {
  position: absolute;
  bottom: 100%;
  left: 0;
  padding: 3px 5px;
  border-radius: 2px 2px 0 0;
  background: var(--tagline-fig);
  color: #fff;
}

/* Below the box, like Figma. Text is the box's real measured size, written
   by JS on each hover. */
.tagline-fig-dims {
  position: absolute;
  top: calc(100% + 6px);
  left: 50%;
  translate: -50% 0;
  padding: 4px 6px;
  border-radius: 2px;
  background: var(--tagline-fig);
  color: #fff;
  word-spacing: 1.5px;
}

/* Follows the pointer; JS writes transform on every pointermove. The
   arrow's tip is the SVG's top-left, so the transform is the hotspot. */
.tagline-fig-cursor {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1;
  will-change: transform;
}
.tagline-fig-cursor svg { display: block; }
.tagline-fig-tag {
  position: absolute;
  top: 19px;
  left: 12px;
  padding: 4px 6px;
  border-radius: 6px;
  background: var(--tagline-fig);
  color: #fff;
}

/* Rest and "out" are both opacity 0; "in" is 1. The keyframes only supply
   the motion between, so under reduced motion (animation: none, below) the
   pieces simply appear and disappear. Handles pop in one corner at a time
   and leave in reverse. */
.tagline-fig::before,
.tagline-fig > * {
  opacity: 0;
}
[data-fx="in"] .tagline-fig::before,
[data-fx="in"] .tagline-fig > * {
  opacity: 1;
}

[data-fx="in"] .tagline-fig::before   { animation: tagline-fade-in .14s var(--ease) both; }
[data-fx="in"] .tagline-fig-handle    { animation: tagline-pop-in .2s var(--ease) both;
                                        animation-delay: calc(40ms + var(--i) * 30ms); }
[data-fx="in"] .tagline-fig-name      { animation: tagline-fade-in .16s var(--ease) 60ms both; }
[data-fx="in"] .tagline-fig-dims      { animation: tagline-drop-in .17s var(--ease) 130ms both; }
[data-fx="in"] .tagline-fig-cursor    { animation: tagline-fade-in .1s var(--ease) both; }

[data-fx="out"] .tagline-fig::before  { animation: tagline-fade-out .13s ease-in 90ms both; }
[data-fx="out"] .tagline-fig-handle   { animation: tagline-pop-out .12s ease-in both;
                                        animation-delay: calc((3 - var(--i)) * 22ms); }
[data-fx="out"] .tagline-fig-name     { animation: tagline-fade-out .12s ease-in 40ms both; }
[data-fx="out"] .tagline-fig-dims     { animation: tagline-drop-out .12s ease-in both; }
[data-fx="out"] .tagline-fig-cursor   { animation: tagline-fade-out .09s ease-in both; }

@keyframes tagline-fade-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes tagline-fade-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes tagline-pop-in   { from { opacity: 0; scale: 0.3; } to { opacity: 1; scale: 1; } }
@keyframes tagline-pop-out  { from { opacity: 1; scale: 1; } to { opacity: 0; scale: 0.3; } }
@keyframes tagline-drop-in  { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
@keyframes tagline-drop-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-3px); } }

/* -- "Systems": select, delete, type design tokens -------------------------
   JS steps data-phase through select -> clear -> type -> done on
   pointerenter, and sets data-out on the overlay for the exit wipe. The
   word's own text stays in the DOM the whole time (only its opacity moves),
   so it's what screen readers get, and it holds the line's width steady —
   the editor is absolutely positioned inside that width. */

.tagline-sys-text {
  transition: opacity .12s var(--ease);
}
.tagline-word--systems[data-phase="clear"] .tagline-sys-text,
.tagline-word--systems[data-phase="type"] .tagline-sys-text,
.tagline-word--systems[data-phase="done"] .tagline-sys-text {
  opacity: 0;
  transition-duration: 90ms;
}

.tagline-sys {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
}

/* The select-all sweep: a band wiping left to right across the word, the
   way a drag-select paints. */
.tagline-sys-band {
  position: absolute;
  inset: 14% -0.04em 6%;
  background: var(--tagline-code-sel);
  transform: scaleX(0);
  transform-origin: 0 50%;
}
[data-phase="select"] .tagline-sys-band {
  animation: tagline-sweep .17s var(--ease) forwards;
}
[data-phase="clear"] .tagline-sys-band,
[data-phase="type"] .tagline-sys-band,
[data-phase="done"] .tagline-sys-band {
  transform: scaleX(1);
  opacity: 0;
  transition: opacity 90ms var(--ease);
}
@keyframes tagline-sweep { to { transform: scaleX(1); } }

/* Mono here is code, not meta text — same category as .drawer-code-pre,
   and not a candidate for the mono retirement (STYLEGUIDE §1). Sized off
   --tagline-fs so three lines fill the word's height at any width; the
   floor keeps it readable at the narrow end of the clamp. */
.tagline-sys-editor {
  display: none;
  position: absolute;
  top: 50%;
  left: 0;
  translate: 0 -50%;
  font-family: var(--font-mono);
  font-size: max(var(--tagline-code-fs-min), calc(var(--tagline-fs) * var(--tagline-code-scale)));
  font-weight: 400;
  line-height: 1.55;
  letter-spacing: 0;
  font-feature-settings: "liga" 0, "calt" 0;
  white-space: pre;
  color: var(--tagline-code-fg);
}
[data-phase="type"] .tagline-sys-editor,
[data-phase="done"] .tagline-sys-editor {
  display: block;
  animation: tagline-fade-in .11s var(--ease) both;
}
/* Exit: the code wipes upward out of the word while the word fades back. */
[data-out="1"] .tagline-sys-editor {
  display: block;
  animation: tagline-code-close .2s ease-in both;
}
@keyframes tagline-code-close {
  from { opacity: 1; clip-path: inset(-0.2em -1ch); transform: none; }
  to   { opacity: 0; clip-path: inset(-0.2em -1ch 100%); transform: translateY(-0.15em); }
}

.tagline-sys-line {
  display: block;
  position: relative;
}

/* Current-line outline; JS moves it down a line at a time. 1.55em must
   match .tagline-sys-editor's line-height (and LINE_EM in js/main.js). */
.tagline-sys-active {
  position: absolute;
  left: -0.4ch;
  right: -0.6ch;
  height: 1.55em;
  box-shadow: inset 0 0 0 1px var(--tagline-code-line);
  transition: top 70ms linear;
}

.tagline-sys-num {
  display: inline-block;
  width: 1.2ch;
  margin-right: 1.4ch;
  text-align: right;
  color: var(--tagline-code-ln);
}
.tagline-sys-num[data-on="1"] {
  color: var(--tagline-code-ln-on);
}

.tagline-tok--prop { color: var(--tagline-code-prop); }
.tagline-tok--num  { color: var(--tagline-code-num); }
.tagline-tok--fn   { color: var(--tagline-code-fn); }
.tagline-tok--pn   { color: var(--tagline-code-fg); }

.tagline-sys-caret {
  display: inline-block;
  width: 2px;
  height: 1.15em;
  margin-right: -2px;
  vertical-align: -0.22em;
  background: var(--tagline-code-caret);
}
[data-phase="done"] .tagline-sys-caret {
  animation: tagline-caret-blink 1.06s step-end infinite;
}
[data-out="1"] .tagline-sys-caret {
  visibility: hidden;
}
@keyframes tagline-caret-blink { 50% { opacity: 0; } }

/* Autocomplete, VS Code's suggest widget in miniature. Opens under the
   caret partway through typing the third line, then gets "accepted". It
   hangs below the word, over whatever is under it; z-index on .tagline-sys
   keeps it on top. Swatches are the suggested values' own colors. */
.tagline-sys-suggest {
  position: absolute;
  z-index: 1;
  min-width: 16ch;
  padding: 1px 0;
  background: var(--tagline-code-pop-bg);
  border: 1px solid var(--tagline-code-pop-border);
  border-radius: 3px;
  box-shadow: 0 2px 8px #0000005c;
}
[data-out="1"] .tagline-sys-suggest {
  visibility: hidden;
}
.tagline-sys-sug-row {
  display: flex;
  align-items: center;
  gap: 0.8ch;
  padding: 0 0.8ch;
}
.tagline-sys-sug-row[data-on="1"] {
  background: var(--tagline-code-pop-on);
}
.tagline-sys-sug-ico {
  flex: 0 0 auto;
  width: 0.8em;
  height: 0.8em;
  border-radius: 1px;
}
.tagline-sys-sug-match {
  color: var(--tagline-code-prop);
}
.tagline-sys-sug-detail {
  margin-left: auto;
  padding-left: 2ch;
  color: var(--tagline-code-ln);
}

/* -- "Interactivity": rainbow sweep + drag toy ------------------------------
   The gradient sits under the text via background-clip: text, and a
   plain-colored copy of the word (::after, text from data-text) sits on
   top. Activating slides that copy's mask across, so the color washes in
   left to right instead of the whole word fading at once (James,
   2026-09-29, option 3 of four in _mock-hero-hover.html — the old 0.28s
   fill fade on --ease, an expo-out, spent nearly all its change in ~80ms
   and read as a pop).

   Two details keep the rest state clean:
   - The word's own fill is solid --text at rest, and only goes transparent
     while active. With the gradient showing through at rest, the plain
     copy's anti-aliased edges would let a faint colored fringe bleed
     around every glyph. On deactivate the fill waits out the sweep back
     (transition-delay: --tagline-sweep-out) before flipping, so it only
     ever changes while fully covered.
   - The rainbow animation is always attached and only *paused* at rest.
     Removing it on leave (the old way) snapped the gradient back to its
     start position mid-fade; paused, it holds still and resumes where it
     stopped next time.

   Also the one word on the page you can pick up: click-drag (or touch-drag)
   moves it with the pointer, and it eases back to its resting spot on
   release — see initInteractivityDrag() in js/main.js for the pointer
   handling, and the rules below for how the CSS side of that splits into
   a "dragging" state and a "not dragging" one. The ::after is a child box,
   so it rides the drag transform for free.

   display: inline-block matters here, not just cosmetically — the CSS
   Transforms spec only applies transform to block-level or *atomic*
   inline-level boxes, not plain inline ones, so a bare <span> (this is one)
   isn't guaranteed to respect the transform the drag depends on. The other
   two tagline words stay plain inline; this is the only one that moves. */
.tagline-word--interactivity {
  display: inline-block;
  cursor: grab;
  -webkit-user-select: none;
  user-select: none;
  -webkit-user-drag: none;
  touch-action: none;
  background-image: linear-gradient(
    90deg,
    #ff3b3b, #ff9f1c, #ffe14d, #4ade80,
    #38bdf8, #a78bfa, #f472b6, #ff3b3b
  );
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: var(--text);
  animation: tagline-rainbow 2.4s linear infinite paused;
  transition: -webkit-text-fill-color 0s;
}

.tagline-word--interactivity::after {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  -webkit-text-fill-color: var(--text);
  /* Mask is 2.5x the word's width: transparent on its left 40%, opaque on
     its right 40%, a soft 20% edge between. Positioned right, the word sees
     only opaque (plain copy fully covering); positioned left, only
     transparent (rainbow fully showing). Travelling between the two drags
     the soft edge across the word. */
  -webkit-mask-image: linear-gradient(90deg, transparent 40%, #000 60%);
          mask-image: linear-gradient(90deg, transparent 40%, #000 60%);
  -webkit-mask-size: 250% 100%;
          mask-size: 250% 100%;
  -webkit-mask-position: 100% 0;
          mask-position: 100% 0;
  transition: -webkit-mask-position var(--tagline-sweep-out) var(--tagline-sweep-ease),
                      mask-position var(--tagline-sweep-out) var(--tagline-sweep-ease);
}

/* The transform transition lives here, not on the base rule above, and only
   here: while .is-dragging is on, initInteractivityDrag() (js/main.js) writes
   `transform` directly every animation frame for the weighted-follow feel,
   and a CSS transition racing those per-frame writes would fight them into a
   stutter. :not(.is-dragging) has one more class of specificity than the
   base rule, so it's the one that wins whenever the word isn't currently
   held — including the instant .is-dragging comes off on release, which is
   exactly the "ease smoothly back into place" motion: the class toggle
   re-arms this transition, and the drag handler resetting the offset to
   (0, 0) in that same tick gives it something to animate toward.

   The fill entry is the delayed flip back to solid described above. */
.tagline-word--interactivity:not(.is-dragging) {
  transition: -webkit-text-fill-color 0s linear var(--tagline-sweep-out),
              transform 0.6s var(--ease);
}

/* Same rainbow reveal as the :hover rule below, mirrored onto a plain class
   instead of a pseudo-class so touch users get it too — touch can drag via
   Pointer Events but never satisfies `hover: hover`, so without this the
   effect would only ever show for a mouse. z-index lifts the word above
   ordinary page content while it's dragged down over a later section
   (position: relative already comes from the shared .tagline-word rule
   above); kept well under the nav pill (100) and the drawer (200s, see
   STYLEGUIDE.md §7) so it can't accidentally sit above chrome that's meant
   to stay on top of everything. */
.tagline-word--interactivity.is-dragging {
  -webkit-text-fill-color: transparent;
  animation-play-state: running;
  cursor: grabbing;
  z-index: 10;
  will-change: transform;
}
.tagline-word--interactivity.is-dragging::after {
  -webkit-mask-position: 0 0;
          mask-position: 0 0;
  transition-duration: var(--tagline-sweep-in);
}

@keyframes tagline-rainbow {
  to { background-position: -200% 0; }
}

@media (hover: hover) {
  /* transition-delay only — not a full `transition` — so this can't hand a
     transform transition to a word that's being dragged while hovered.
     Zeroes the fill's wait so it flips to transparent immediately on
     entry (it's still fully covered by the plain copy at that instant). */
  .tagline-word--interactivity:hover {
    -webkit-text-fill-color: transparent;
    animation-play-state: running;
    transition-delay: 0s;
  }
  .tagline-word--interactivity:hover::after {
    -webkit-mask-position: 0 0;
            mask-position: 0 0;
    transition-duration: var(--tagline-sweep-in);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Interfaces/Systems: the pieces still appear, just without motion. The
     typing itself is skipped in JS (the block appears already written). */
  .tagline-fig::before,
  .tagline-fig > *,
  .tagline-sys-band,
  .tagline-sys-editor,
  .tagline-sys-caret {
    animation: none !important;
  }
  .tagline-sys-text,
  .tagline-sys-active {
    transition: none;
  }

  /* The gradient still reads — only the sweep and the travel are dropped.
     Color isn't motion, and the color is the whole point of this word. */
  .tagline-word--interactivity,
  .tagline-word--interactivity::after {
    animation: none;
    transition: none;
  }

  /* Matches :not(.is-dragging)'s own specificity (two classes) so this
     reliably overrides it rather than losing to source order — see that
     rule's comment. Dragging itself stays available under reduced motion
     (it's direct manipulation, not an autoplaying effect); only the eased
     glide home is motion layered on top of it, so this makes the release
     an instant snap to rest instead. */
  .tagline-word--interactivity:not(.is-dragging) {
    transition: none;
  }
}


/* =============================================================================
   SELECTED WORK — featured cards
   ============================================================================= */

:root {
  /* Card geometry — one place to retune every featured card.

     Built from James's Figma comp (2026-09-01), option A of three mocked
     against this stylesheet before any of it landed here. The comp was a
     raster screenshot rather than exported specs, so every number below
     was measured off it against a 544px card and is expected to get
     nudged — which is exactly why they're tokens and not literals.

     --wc-thumb-radius derives from the other two instead of being its own
     number: a frame inset by --wc-pad inside a --wc-radius corner reads
     concentric only when its radius is the difference between them.
     Override it with a literal if a deliberately non-concentric look is
     ever wanted. */
  --wc-pad:          6px;
  --wc-radius:       12px;
  --wc-thumb-radius: calc(var(--wc-radius) - var(--wc-pad));
  --wc-meta-pad:     16px;
  --wc-meta-gap:     16px;
  --wc-title-fs:     var(--fs-lg);

  /* Tag pills. The horizontal padding is its own token because the title's
     indent is calculated from it — see --wc-title-indent. */
  --wc-tag-fs:       var(--fs-xs);
  --wc-tag-pad-y:    4px;
  --wc-tag-pad-x:    12px;
  --wc-tag-gap:      8px;

  /* How far the title is indented so its first glyph sits over the first
     pill's first glyph rather than over the pill's border (James,
     2026-09-02: "left align the D in Design System Overhaul with the D in
     the Design Systems pill"). Derived rather than a literal 13px: it's the
     pill's own inner padding plus its 1px border, so retuning
     --wc-tag-pad-x keeps the two aligned instead of silently breaking the
     one thing this token exists for.

     It does mean the title no longer starts on the same x as the thumbnail
     frame above it. That's the trade James picked, knowing it: the text
     column aligns with itself, and the pill borders hang into the margin
     to its left. */
  --wc-title-indent: calc(var(--wc-tag-pad-x) + 1px);

  /* The client badge floating over the thumbnail. The pill's height, its
     mark and its wordmark are three separate knobs on purpose: the pill
     can be tightened without shrinking the logo inside it. */
  --wc-badge-h:      32px;
  --wc-badge-mark:   22px;
  --wc-badge-fs:     15px;
  --wc-badge-gap:    8px;
  --wc-badge-pad:    0 14px 0 6px;
  /* The arrow that springs out of the badge on hover. This is the single
     number that decides how far the pill travels: the whole animation is
     the pill growing by this plus --wc-badge-gap, and the geometry in
     initWorkBadge() (js/main.js) derives everything else from it. */
  --wc-badge-arrow:  18px;
  /* The wordmark's color. Was --bg (#0a0a0a) — James read it as pure black
     and out of place (2026-09-02). Now --text-on-light (#2a2a2a), the
     shared dark-on-white value in the palette block at the top of this
     file. .drawer-live-link was moved onto the same token in the same pass
     at James's request, so the site's two inverted surfaces match by
     construction rather than by coincidence. This card-level token is kept
     in front of it anyway, so the badge alone can be retuned (#1f1f1f,
     #5a5a5a) without touching the drawer button. */
  --wc-badge-color:  var(--text-on-light);

  /* The hover growth. Same ratio as the drawer's figures (see
     .drawer-figure:hover) — one token, so the two surfaces can't drift
     apart the next time either is retuned. */
  --wc-hover-scale:  1.02;
}

.work-list {
  display: grid;
  /* 1x2, James 2026-08-27 — down from a single column of three cards once
     the third (placeholder) card was cut, leaving just Maricopa and Epiq.
     The single-column layout also read too large at this section's width
     after the margin pass; two columns brings .work-thumb (16:10, 100%
     width) back down to a reasonable height instead of each thumbnail
     spanning the full 1120px content column. Collapses back to one column
     at <=640px alongside the site's other multi-column grids — see the
     responsive block at the end of this file. */
  grid-template-columns: repeat(2, 1fr);
  gap: var(--sp-8);
}

.work-card {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  color: inherit;
  cursor: pointer;
  /* Chrome's UA stylesheet gives every <button> `padding: 1px 6px` — and
     since this rule resets font/color/border/background but not padding,
     that default used to survive here: 6px down the sides, 1px at the top.
     Read as the thumbnail losing its top inset while the sides looked
     right. Set explicitly so all four edges match instead of inheriting
     an asymmetry from the UA. */
  padding: var(--wc-pad);
  border: 1px solid var(--border);
  border-radius: var(--wc-radius);
  overflow: hidden;
  background: var(--bg-elevated);
  transition: border-color 0.3s var(--ease), transform 0.3s var(--ease);
  /* Named query container so the badge can size itself off this card's
     width instead of the viewport's — see the @container rule under
     .work-thumb-badge for why that distinction matters here. This is the
     site's only container query; nothing else needs one yet. */
  container: work-card / inline-size;
}

/* Grows on hover by the same ratio, duration and easing as the drawer's
   figures, so a card and a figure read as the same class of object. Behind
   @media (hover: hover) like every other hover rule on the site — without
   it, a touch device that fakes :hover leaves a card stuck mid-scale after
   a tap. The border-color change used to sit outside that query; it moved
   in alongside the transform rather than leaving one hover state split
   across two rules with different conditions. */
@media (hover: hover) {
  /* Held on its own compositing layer at rest, not only while hovered.
     Without this the browser promotes the card to a layer when the
     transform transition starts and demotes it when the transition ends,
     re-rasterizing the text on both sides of that switch — which is what
     James saw as the card title jumping up about a pixel as the hover
     began and dropping back as it finished (2026-09-02). A permanent layer
     means one rasterization and no switch to jump across. Scoped to hover
     devices since they're the only ones that ever run the transform, and
     kept off .work-card:hover deliberately: declaring it there would
     promote the layer at exactly the moment we're trying not to. */
  .work-card {
    will-change: transform;
  }

  .work-card:hover {
    border-color: var(--border-strong);
    transform: scale(var(--wc-hover-scale));
  }
}

@media (hover: hover) and (prefers-reduced-motion: reduce) {
  .work-card:hover {
    transform: none;
  }
}

.work-thumb {
  aspect-ratio: 16 / 10;
  background:
    linear-gradient(135deg, #1a1a1a, #0f0f0f);
  /* Rounded on all four corners with no bottom hairline, per the comp: the
     thumbnail reads as a frame floating inside the card rather than a
     header panel butted up against a divider. The radius derives from the
     card's own (see the token block above), so the two corners stay
     concentric if either the card radius or its padding is retuned. */
  border-radius: var(--wc-thumb-radius);
  /* Containing block for .work-thumb-video/.work-thumb-badge below —
     the gradient above stays as the fallback layer beneath both
     (visible if the video hasn't loaded/painted yet, or on a card that
     doesn't have one at all; both featured cards have footage as of
     2026-09-18). overflow: hidden clips the video's own scale(1.02)
     overscan (see .work-thumb-video) to this box. */
  position: relative;
  overflow: hidden;
}

/* Prototyped as an Artifact first (four before/after directions, then
   this — James's own idea, described in chat and mocked up before any
   of it touched real code) — see PLAN.md for the exploration and the
   settings history (blur/padding/clip-length went through three rounds
   of live feedback before landing here). */
.work-thumb-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: blur(1.5px);
  /* Scales past 100% so the blur's own soft edge falls outside the
     frame (clipped by .work-thumb's own overflow: hidden above)
     instead of showing as a faded vignette right at the visible
     boundary — same reasoning as the drawer's click-to-grow figures,
     just for a permanent filter instead of a hover/click state. */
  transform: scale(1.02);
}

/* The client badge, per the Figma comp: a white pill carrying the circular
   county mark and the county name side by side. It was a rounded rectangle
   (.drawer-live-link's 6px/10px, 8px radius) holding the stacked logo
   lockup at 56px tall, which sat far heavier over the frame than the comp
   does — the pill is roughly half that height now.

   The wordmark is live text, not part of the artwork:
   assets/images/maricopa-badge.svg is the circle mark alone. That's also
   what makes the badge animatable in the next pass — the mark and the name
   are separately addressable elements rather than one flat image. */
.work-thumb-badge {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: #ffffff;
  height: var(--wc-badge-h);
  /* Tighter at the mark end than the text end: the mark is a full-bleed
     circle that carries its own visual padding, a line of type isn't. */
  padding: var(--wc-badge-pad);
  border-radius: 999px;
  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  gap: var(--wc-badge-gap);
  /* Load-bearing for the hover animation, not tidiness. The arrow starts
     parked just outside the pill's right edge and slides in as the pill
     grows; this is what covers it on the way. Take it off and the arrow
     is visible at rest. */
  overflow: hidden;
}

.work-thumb-badge-mark {
  display: block;
  width: var(--wc-badge-mark);
  height: var(--wc-badge-mark);
  /* The mark is the one thing in the pill that must never compress. */
  flex: none;
}

.work-thumb-badge-name {
  font-size: var(--wc-badge-fs);
  font-weight: 700;
  /* A dark palette value, not an inverted --text and not pure black — see
     --wc-badge-color for which one and why it isn't --bg like
     .drawer-live-link's. */
  color: var(--wc-badge-color);
  letter-spacing: -0.01em;
  white-space: nowrap;
}

/* Zero-width at rest, so the pill measures exactly as wide as it did
   before the arrow existed and nothing shifts on a page that never gets
   hovered. .is-open is added by initWorkBadge() (js/main.js) to give it
   its real width; there is deliberately no transition on either property,
   because the spring animates the pill's transform instead and a
   transition here would fight it.

   The negative margin cancels the pill's own gap while the arrow has no
   width. Without it there's 8px of dead space sitting after the wordmark
   at rest, which reads as the badge being badly padded rather than as an
   arrow waiting to appear. */
.work-thumb-badge-arrow {
  flex: none;
  display: flex;
  align-items: center;
  width: 0;
  height: var(--wc-badge-arrow);
  margin-left: calc(var(--wc-badge-gap) * -1);
  color: var(--wc-badge-color);
  overflow: hidden;
}

.work-thumb-badge.is-open .work-thumb-badge-arrow {
  width: var(--wc-badge-arrow);
  margin-left: 0;
}

/* Inline SVG rather than a background-image from assets/icons, which is
   how every other icon on the site is done (STYLEGUIDE.md §1). Deliberate:
   those files bake their stroke color in, and this one has to follow
   --wc-badge-color so the arrow and the wordmark can't drift apart. */
.work-thumb-badge-arrow svg {
  display: block;
  flex: none;
  width: var(--wc-badge-arrow);
  height: var(--wc-badge-arrow);
}

/* The badge is sized in fixed px, so it doesn't shrink with the card — the
   narrower the card, the more of the frame it covers. The worst case isn't
   mobile: it's the narrowest the grid is still two-up, a 272px card at a
   641px viewport, where the badge was covering two thirds of the frame's
   width. One pixel narrower and the grid collapses to a single 576px
   column, where the badge is fine again.

   That non-monotonic relationship is exactly why this is a container query
   and not the site's usual ≤640px media query: what the badge cares about
   is the card's width, and card width and viewport width stop agreeing at
   the breakpoint. 420px catches both the narrow two-up band (272–402px
   cards) and phone-width single columns (311px at 375px) in one rule.

   Ratio parity with the Figma comp isn't reachable at these sizes — the
   badge would need roughly a 9px wordmark to hold the comp's proportion,
   which isn't legible. These numbers are the compromise: clearly lighter
   over the frame, still readable. Retune the five tokens here, not the
   defaults above, to adjust small cards alone. */
@container work-card (max-width: 420px) {
  .work-thumb-badge {
    --wc-badge-h:    26px;
    --wc-badge-mark: 18px;
    --wc-badge-fs:   13px;
    --wc-badge-gap:  6px;
    --wc-badge-pad:  0 11px 0 4px;
  }
}

.work-meta {
  padding: var(--wc-meta-pad);
  display: grid;
  gap: var(--wc-meta-gap);
}

/* 700, up from 600, and naming the *work* rather than the client:
   "Design System Overhaul", not "Maricopa County Treasurer", which the
   badge on the thumbnail now says instead. The subhead line the card used
   to carry (.work-desc, retired below) went with it. Both come from
   James's Figma comp and supersede the card copy decided in
   _casestudies/maricopa-design-system-NOTES.md §4 and §10 — see the
   status block at the top of that file. */
.work-title {
  font-weight: 700;
  font-size: var(--wc-title-fs);
  color: var(--text);
  line-height: 1.2;
  /* Indented to align the title's first glyph with the first tag pill's
     first glyph rather than with the pill's border — see
     --wc-title-indent. padding, not margin, so the token reads as an inset
     on the text box and nothing collapses against the grid gap above. */
  padding-left: var(--wc-title-indent);
}

/* .work-desc is retired, not restyled — the card is title + tags now.
   Deleted rather than left sitting here unused; the copy it carried
   ("Overhauling a design system the build had outrun") is still recorded
   in the case study notes if it's ever wanted back. */

/* Was one line of muted text holding the start date ("August 2024"); now
   the row that lays the .work-tag pills out, per the comp. The date isn't
   on the card at all any more — the drawer's meta row still carries the
   full timeline, which is where a date has room to mean something. */
.work-tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--wc-tag-gap);
}

/* Sans by inheritance, not by declaration. This is the "project years and
   tags" entry from the sitewide mono retirement (STYLEGUIDE.md §1) — moved
   off --font-mono before this redesign and staying moved, so there's
   nothing left here to override. */
.work-tag {
  color: var(--text-secondary);
  font-size: var(--wc-tag-fs);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  padding: var(--wc-tag-pad-y) var(--wc-tag-pad-x);
  line-height: 1.4;
}


/* =============================================================================
   CASE STUDY DRAWER

   Bottom sheet pattern (ch.sh-style): panel slides up from the bottom edge,
   backdrop dims what's behind it. Duration and easing follow Charles Shin's
   own published motion spec for modals/drawers (200–300ms, ease-out,
   transform + opacity only) — see DESIGN.md in his portfolio repo.

   Minimal pass: open/close, Esc, backdrop click, scroll lock. No URL hash
   state and no focus trap yet — next pass.
   ============================================================================= */

.drawer-backdrop {
  position: fixed;
  inset: 0;
  z-index: 200;
  background: rgba(0, 0, 0, 0.6);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.26s var(--ease);
}

.drawer-backdrop.is-open {
  opacity: 1;
  pointer-events: auto;
}

.drawer {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 201;
  height: 90vh;
  max-height: 90vh;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  border: 1px solid var(--border-strong);
  border-bottom: none;
  border-radius: 20px 20px 0 0;
  box-shadow: 0 -16px 48px rgba(0, 0, 0, 0.5);
  transform: translateY(100%);
  transition: transform 0.28s var(--ease);
  overflow: hidden;
}

.drawer.is-open {
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .drawer-backdrop,
  .drawer {
    transition: none;
  }
}

.drawer-bar {
  position: relative;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-4) var(--content-pad);
  border-bottom: 1px solid var(--border);
}

.drawer-handle {
  width: 36px;
  height: 4px;
  border-radius: 999px;
  background: var(--border-strong);
}

.drawer-close {
  position: absolute;
  /* Fixed corner inset, not --content-pad. That token used to swing
     around for main-page layout reasons (1.5rem → 2rem → 3.5rem, and
     back to 2rem on 2026-08-26), and every widening dragged this button
     inward with it, away from the corner it's meant to be anchored to.
     Kept pinned even now that the token has settled at a value this
     would be happy with — the point is that a corner inset shouldn't be
     downstream of a content-width decision at all. */
  right: var(--sp-6);
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  background: none;
  border: none;
  /* Rounded square, not a pill — sized tight to the icon rather than a
     large circle. */
  border-radius: 8px;
  cursor: pointer;
  transition: background-color 0.2s var(--ease);
}

.drawer-close:hover {
  background: var(--border);
}

/* Same background-image approach as .util-icon above — stroke color baked
   into the file, not a CSS mask (see the note there for why). */
.drawer-close-icon {
  /* Span is inline by default and was ignoring width/height, so the icon
     rendered at zero size. */
  display: block;
  width: 16px;
  height: 16px;
  -webkit-mask-image: var(--icon-close);
          mask-image: var(--icon-close);
  background-color: var(--icon-color);
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
}

.drawer-content {
  flex: 1;
  overflow-y: auto;
  padding: var(--sp-12) var(--content-pad) var(--sp-24);
  /* both-edges, not the plain `stable` used on html (base rules). This
     element is the drawer's own scroll container, and its scrollbar eats
     width from the right side only %s so the centered
     .drawer-content-inner inside it was landing 8px (half a scrollbar)
     left of the main page's column, even with both capped at the same
     --content-w. Small, but visible as a sideways nudge the moment a
     drawer opens over the page. Reserving the gutter on both edges keeps
     the centering honest and puts the two columns on the exact same
     x-coordinate. No-op on overlay-scrollbar platforms (macOS default),
     where the scrollbar has no width and nothing was off-center to begin
     with; browsers without both-edges support just fall back to the 8px
     offset rather than breaking. */
  scrollbar-gutter: stable both-edges;
}

.drawer-content-inner {
  /* Reads the shared token again as of 2026-08-26. This was a hardcoded
     960px — widened from 864px on 2026-08-14 for the two-column section
     layout below (§5, PLAN.md), and deliberately *not* var(--content-w)
     at the time, because that token was shared with .site-main and
     widening the drawer would have dragged the main page's column along
     with it. The ask has since changed to exactly that: home and drawer
     on one number, one set of side margins. So the decoupling is gone,
     and .site-main was fixed to resolve to this same width rather than
     to this minus its own padding (see that rule's comment).

     Title/meta/lead still reference --drawer-measure (620px, below)
     directly, so they don't grow with this — only .drawer-section (and,
     inside it, figures that bleed back out to the full width — see
     .drawer-section-content's figure rules) actually use the extra
     room. */
  max-width: var(--content-w);
  margin: 0 auto;
  /* Shared reading measure for title/meta/lead only — section content
     (paragraphs, cards, etc. inside .drawer-section-content) uses the
     section's own right-column width instead, which is now visibly wider
     than this (1120 - 200 title - 48 gap = 872px) rather than the near
     match it happened to be back at 960px. A fixed px value, not ch: ch
     is relative to each element's own font-size, so a 40px title and a
     16px paragraph would get visibly different widths from the same
     "62ch". Deliberately not scaled off --content-w: this is a
     line-length decision, independent of how wide the container gets. */
  --drawer-measure: 620px;
}

.drawer-title {
  font-size: var(--fs-3xl);
  font-weight: 700;
  letter-spacing: -0.02em;
  max-width: var(--drawer-measure);
  /* Left-aligned since 2026-09-30 (James), matching the Experiments split
     cover (.drawer-content-inner--split) so every drawer opens the same
     way; the whole header (title, meta row, "See it live", lead) moved
     together. It had been a centered title-page cover; to restore, this
     was margin: 0 auto var(--sp-4) + text-align: center, .drawer-meta
     justify-content: center + margin: 0 auto, .drawer-live-link margin
     18px auto 0, .drawer-lead margin 0 auto var(--sp-8). */
  margin: 0 0 var(--sp-4);
  line-height: 1.1;
  text-align: left;
}

/* Role / company / start date / timeline — one wrapping mono row under the
   title. Icon + value pairs; the label lives in a visually-hidden <dt> for
   screen readers (see .visually-hidden, base rules) rather than as visible
   text — the icon carries that job. Layout and the drop-in motion are
   adapted from the meta row on joshglucas.com/search-labs, kept to this
   site's own outline-icon system rather than his filled color badges. Icons
   use the same background-image approach as .util-icon/.drawer-close-icon
   above — stroke color baked into the SVG file, not a CSS mask (see the
   note there for why). */
.drawer-meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;
  gap: var(--sp-2) var(--sp-6);
  /* Was var(--font-mono) — part of James's sitewide mono retirement
     (STYLEGUIDE.md §1), continuing from the figure-caption pass. Color
     unchanged (still var(--text-muted)); only the face moved to sans,
     same scope as every step of this retirement so far. */
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  color: var(--text-muted);
  max-width: var(--drawer-measure);
  margin: 0 0 var(--sp-4);
}

.drawer-meta-item {
  display: flex;
  align-items: center;
  gap: 0.4em;
  /* Each item is a fresh DOM node on every drawer open — renderProject()
     (js/main.js) rebuilds content.innerHTML from scratch — so this always
     replays from the start with no JS re-trigger needed. Per-item delay is
     set inline by renderMeta() for the left-to-right stagger.
     animation-fill-mode: backwards holds each item at its 0% keyframe
     (invisible, shifted up) for the duration of its own delay, so items
     2–4 don't flash visible before their turn. */
  animation: drawer-meta-drop 560ms var(--ease) backwards;
}

.drawer-meta-icon {
  /* Spans are inline by default and ignore width/height — same fix as
     .util-icon/.drawer-close-icon above. */
  display: block;
  width: 14px;
  height: 14px;
  background-color: var(--icon-color);
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
}

.drawer-meta-icon-role {
  -webkit-mask-image: var(--icon-user);
          mask-image: var(--icon-user);
}

.drawer-meta-icon-company {
  -webkit-mask-image: var(--icon-building-bank);
          mask-image: var(--icon-building-bank);
}

.drawer-meta-icon-startDate {
  -webkit-mask-image: var(--icon-calendar-event);
          mask-image: var(--icon-calendar-event);
}

.drawer-meta-icon-timeline {
  -webkit-mask-image: var(--icon-clock);
          mask-image: var(--icon-clock);
}

/* Minor-tier meta keys (see META_KEYS, js/main.js). No new icon files: `type`
   reuses the same person glyph as `role` — a personal project is one person,
   and the two keys never co-occur, since `role` only appears on client work —
   and `status` takes the hourglass already in the set. `startDate` is shared
   with the featured tier as-is rather than duplicated, so it needs no rule
   here. */
.drawer-meta-icon-type {
  -webkit-mask-image: var(--icon-user);
          mask-image: var(--icon-user);
}

.drawer-meta-icon-status {
  -webkit-mask-image: var(--icon-hourglass);
          mask-image: var(--icon-hourglass);
}

.drawer-meta-item dd {
  margin: 0;
  color: var(--text-secondary);
}

/* Drop in from above, ease into a gentle overshoot, settle — a soft
   spring-style bounce rather than the site's shared --ease soft-out. This is
   a deliberate exception the same way .sparkle's keyframe already is (see
   STYLEGUIDE.md §8): --ease governs simple two-state transitions site-wide,
   but a bounce needs multiple keyframe stops, which is its own category.
   Kept deliberately subtle (small drop distance, small overshoot) after an
   earlier pass read too sharp.

   Per-leg easing, not one curve for the whole thing: the fall (0%→60%)
   keeps var(--ease) — fast and decisive suits a drop. The settle (60%→100%)
   switches to ease-in-out instead — var(--ease) restarting on every leg of
   a multi-stop bounce made the tiny return-to-rest motion read as a stiff
   snap rather than a glide. Also dropped the earlier 80% micro-correction
   stop; merging it into one longer settle leg gave the softer curve more
   room to actually read as smooth. */
@keyframes drawer-meta-drop {
  0% {
    opacity: 0;
    transform: translateY(-10px);
    animation-timing-function: var(--ease);
  }
  60% {
    opacity: 1;
    transform: translateY(2px);
    animation-timing-function: ease-in-out;
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .drawer-meta-item,
  .drawer-live-link {
    animation: none;
  }
}

/* "See it live at [url]" — a proof-of-work CTA under the meta row
   (left-aligned with it since 2026-09-30; centered before, when the whole
   header was a centered cover — see .drawer-title). Adapted from jc.works/hrblock/'s
   "See it live at hrblock.com.", but restyled into an actual button:
   white fill (the one deliberate light surface on an otherwise dark page,
   reading as a clear call to action against everything around it) with
   dark text set to var(--bg) — literally the page's own background color,
   so the inversion is exact rather than an approximate dark grey. Sans
   (var(--font-sans), matching .drawer-paragraph) rather than mono — this
   reads as a button label, not a system/meta fact, so it shouldn't share
   the meta row's mono treatment even though it sits right below it. The
   whole label is the link now (previously just the domain was), which
   reads better once it looks like a button rather than a sentence. Reuses
   drawer-meta-drop (see above) as a fifth beat in the same stagger — its
   animation-delay is set inline by renderProject() (js/main.js) using the
   same META_ANIM_BASE_DELAY_MS/META_ANIM_STAGGER_MS constants the meta
   row uses, so it drops in last, right after "9 months."

   Margins are hand-tuned, not just "whatever the surrounding blocks leave
   behind," and went through two corrections:

   1st pass: assumed margin-top: var(--sp-2) plus .drawer-meta's own
   margin-bottom (--sp-4) would sum to a 24px gap above, matching the 24px
   below from .drawer-heading's margin-top. Wrong — .drawer-meta and
   .drawer-live-link are plain block siblings in normal flow (neither is a
   grid item), so adjacent margins *collapse* to whichever is larger rather
   than summing. Actual result was 16px above (meta's margin won the
   collapse) vs 24px below, still lopsided — caught once James saw it live.

   Fix: since collapse takes the max of the two margins, not their sum, the
   reliable way to set an exact top gap is to make this element's own
   margin-top *equal* the target and let it win the collapse outright,
   ignoring .drawer-meta's margin-bottom entirely. The bottom gap has no
   collapse to fight — .drawer-heading lives inside .drawer-blocks (a grid),
   and grid contains its items' margins rather than letting them escape to
   collapse with siblings outside it — so margin-bottom here adds on top of
   .drawer-heading's fixed 24px top margin directly, including going
   negative if the target is below 24px.

   2nd pass: James asked for both gaps equal, then — once even at 24px —
   asked for about 25% less overall. Landed on 18px both sides: margin-top:
   18px wins the collapse against .drawer-meta's 16px, and margin-bottom:
   -6px brings the fixed 24px from .drawer-heading down to 18px. Not
   spacing-scale tokens (no --sp value lands near 18px) — a deliberate,
   hand-tuned exception for this one pairing, same category as the
   diagram's hardcoded colors: precise adjustment mattered more than
   staying on the token grid here.

   3rd pass (two-column rework, later reverted): the -6px got repurposed
   to collapse against a new margin-top on .drawer-blocks (which replaced
   .drawer-heading as this element's neighbor), reproducing the same 18px
   through the same collapse math.

   4th pass (this one): retired the collapse trick. James wanted the gap
   above the first section increased to 30px, which has no clean route
   through a two-margin collapse (36px isn't a spacing-scale token, and
   the trick only works by pairing a positive value against a negative
   one). margin-bottom here is now 0 — .drawer-blocks' own margin-top
   carries the full 30px directly as the single source of truth, nothing
   left to collapse against. margin-top: 18px above is a separate
   pairing (against .drawer-meta) and is untouched. */
.drawer-live-link {
  display: block;
  width: fit-content;
  margin: 18px 0 0;
  /* Padding, like the margins above, is hand-tuned px rather than
     spacing-scale tokens — three 25% reductions from an original 12px/24px
     (→ 9px/18px → 7px/14px) per James's live review each time, then a
     fourth pass (→ 6px/10px) once he compared it against mockup options
     for the header as a whole (see PLAN.md) and asked for it tighter
     still. border-radius dropped from a full pill to 8px in that same
     pass — a rounded rectangle read as a more deliberate button shape
     than the pill once the header itself stopped being a centered-only
     layout in isolation. */
  padding: 6px 10px;
  background: var(--surface-invert);
  /* Was var(--bg). Moved to the shared dark-on-white value with the work
     card's badge (James, 2026-09-02) so the site's two inverted surfaces
     say the same thing — the badge changed first, this followed. */
  color: var(--text-on-invert);
  border-radius: 8px;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: 500;
  text-align: center;
  animation: drawer-meta-drop 560ms var(--ease) backwards;
}

.drawer-live-link:hover {
  background: var(--surface-invert-hover);
  color: var(--bg);
}

/* The hook — larger than body copy, carries the reader into the piece. */
.drawer-lead {
  font-size: var(--fs-lg);
  color: var(--text-secondary);
  max-width: var(--drawer-measure);
  margin: 0 0 var(--sp-8);
}

/* Closing rule under the centered title/meta/CTA "cover" — full width of
   .drawer-content-inner (var(--content-w)), not capped to
   --drawer-measure like the title/meta/lead above it. Chosen over the alternative of just
   left-aligning the whole header to match .drawer-section's grid (see
   PLAN.md, header layout options): the centered cover reads as a
   deliberate title-page beat rather than a layout that quietly stops
   matching itself, as long as the seam marks the handoff explicitly. No
   margin-bottom here on purpose — .drawer-blocks' own margin-top (30px,
   see the comment on .drawer-live-link's spacing history above for why
   that value isn't just a collapse trick anymore) carries the gap down
   to the first section, the same single-source-of-truth approach used
   for the gap above it. */
.drawer-seam {
  border: none;
  border-top: 1px solid var(--border);
  margin: var(--sp-8) 0 0;
}

/* Ordered sections — each one a heading plus everything up to the next
   heading (grouped by groupBlocksIntoSections(), js/main.js). Desktop/
   tablet: title-left/content-right two-column row per section (see
   .drawer-section below); collapses to the old single-column stack on
   mobile (see the responsive block at the bottom of this file).

   margin-top restores the gap the old .drawer-heading's margin-top used
   to provide before the two-column rework — .drawer-section-title
   (below) has margin: 0, since inter-section spacing now comes from this
   grid's own `gap`, but `gap` only applies *between* grid children, not
   before the first one, so without this the first section ("Context" on
   project-one) sat right up against whatever came before it with no
   breathing room. Reuses the exact same value (--sp-6, 24px) that used
   to live on .drawer-heading, so it collapses with .drawer-live-link's
   margin-bottom (-6px, see the long comment above it) to the identical
   18px result that pairing was already tuned to — same math, just moved
   here since .drawer-blocks is now the element actually adjacent to
   .drawer-live-link/.drawer-lead in normal flow.

   Superseded already: James asked for 30px instead of 18px, which the
   collapse trick has no clean route to (see the fuller retirement note
   on .drawer-live-link above). margin-top here is now a direct 30px,
   not var(--sp-6), and .drawer-live-link's margin-bottom is 0 — nothing
   collapses into this anymore, it's just the value. */
.drawer-blocks {
  display: grid;
  gap: var(--sp-16);
  margin-top: 30px;
}

/* Divider between sections, same treatment as .drawer-seam above (the
   line under "See it live") — reused rather than a new style, so the
   two "this is a deliberate beat" moments in the drawer read as the same
   device. :not(:first-child) skips "Context," which already has its own
   seam directly above it from the header; a second line right under the
   first would read as a mistake, not two intentional dividers.
   .drawer-blocks' gap (above) is the space *before* this line; padding-
   top here is the space *after* it, before the next section's own
   title/content starts — independent knobs so both can be tuned without
   fighting each other. James asked for "more breathing room" generally,
   not a specific number — sp-16/sp-8 is a first pass, easy to move
   either value if it reads as too much or too little once seen live. */
.drawer-section:not(:first-child) {
  border-top: 1px solid var(--border);
  padding-top: var(--sp-8);
}

/* One section: a fixed-width title column next to a content column that
   takes the rest of the row. --section-title-w/--section-gap are declared
   here, not just used inline, so .drawer-section-content's figure-bleed
   rule below can read the same two numbers through custom-property
   inheritance instead of duplicating them — change either one here and
   the bleed math stays correct automatically. align-items: start pins
   the title to the top of the row rather than centering it against a
   much taller content column, matching the reference James sent. */
.drawer-section {
  /* 264px, fourth value in one pass: 200px → 224px → 240px → 264px,
     James 2026-08-26. Each prior step (marked up on a screenshot: gutter
     grows more, title column grows slightly, content column gives back
     the difference) read as too small once seen live alongside the
     --section-gap bump below, repeatedly — "move it all a little more,"
     both knobs together, then "even more" again. Not a spacing-scale
     value — same hand-tuned-px category as --content-pad's own history. */
  --section-title-w: 264px;
  /* --sp-32 (128px) — the token STYLEGUIDE.md §5 had on record as
     "defined but not referenced by any rule yet," now in use. Fourth
     bump in one pass: --sp-8 (32px) → --sp-12 (48px) → --sp-16 (64px) →
     --sp-24 (96px), each read as not enough once James saw it live — his
     ask each time was to move the gutter further, not to let the wider
     --content-w just flow through unchanged into the content column.
     Widening --content-w doesn't touch this on its own; it's a separate
     knob. */
  --section-gap: var(--sp-32);
  display: grid;
  grid-template-columns: var(--section-title-w) 1fr;
  gap: var(--section-gap);
  align-items: start;
}

/* A run of blocks with no heading above it (see groupBlocksIntoSections()
   in js/main.js) — not hit by any current project data, but if it ever
   is, this drops the title column and lets the content span full width
   instead of leaving an empty, oddly-gapped left column. Zeroing the two
   custom properties (not just grid-template-columns) matters: the
   figure-bleed rule below computes its pull from these same variables,
   so a headerless section with no title column to reclaim correctly
   bleeds by 0 instead of shifting content into space that was never
   there. */
.drawer-section--headerless {
  grid-template-columns: 1fr;
  --section-title-w: 0px;
  --section-gap: 0px;
}

.drawer-section-title {
  /* Sans, not the mono .section-title used on the main page — this is
     content, not a page-level label, and should read differently. */
  font-family: var(--font-sans);
  /* 30px, James 2026-08-26 — up from var(--fs-xl) (24px). Now a shared
     token (see --fs-section-heading in the type block): the homepage's
     .section-title was made to match this exactly on 2026-09-02, so the
     size lives in one place rather than being copied here. */
  font-size: var(--fs-section-heading);
  font-weight: 600;
  letter-spacing: -0.01em;
  line-height: 1.25;
  color: var(--text);
  margin: 0;
}

.drawer-section-content {
  display: grid;
  gap: var(--sp-6);
  /* Without this, a wide child (most relevant: a figure before its bleed
     rule below even applies) can push this grid track wider than the 1fr
     it's meant to take — the same min-width: auto default .drawer-
     gallery-item already has to override, for the same reason. */
  min-width: 0;
}

/* Figures/compare/gallery/trio inside a section "bleed" left to reclaim
   the title column + gap, so they still span the section's full width —
   the same full-bleed behavior .drawer-figure/.drawer-compare/.drawer-
   gallery/.drawer-trio already have (rule above), just achieved
   differently now that their immediate parent (.drawer-section-content)
   is narrower than the section itself. Pulls --section-title-w/
   --section-gap from the ancestor .drawer-section via inheritance, so
   this always matches whatever that grid is actually using, including in
   a headerless section (both variables zero there, so this resolves to
   no bleed at all) and on mobile (same two variables zeroed in the
   responsive block below, so the two-column layout and the figure bleed
   collapse together with one change, not two). .drawer-trio added
   2026-08-21, James: the three-column showcase should use the section's
   full width rather than sit inside the narrower copy column — same move
   as every other figure type here, not a special case. Its own inner
   video figures stay unaffected: they're tagged .drawer-figure--contained
   (BLOCK_RENDERERS.trio, js/main.js), which outranks this same descendant
   selector via specificity, so they still bleed-fix themselves to their
   own (now correctly wider) column rather than bleeding a second time. */
.drawer-section-content .drawer-figure,
.drawer-section-content .drawer-compare,
.drawer-section-content .drawer-gallery,
.drawer-section-content .drawer-trio {
  margin-left: calc(-1 * (var(--section-title-w) + var(--section-gap)));
  width: calc(100% + var(--section-title-w) + var(--section-gap));
}

/* A figure/compare/gallery that's the very first thing in a section's
   content column sits at the same vertical position as the title itself
   (align-items: start on .drawer-section puts both at the row's top) —
   bleeding the full title-column width in that specific case pulls the
   figure directly across the title's own space, visually painting over
   it (confirmed live: this is exactly what was clipping "Prioritizing
   with a tight deadline"'s title before its video got a paragraph moved
   in front of it — see the case study notes). Section content should
   lead with a paragraph before a figure precisely to avoid this (also in
   the notes), but this caps a first-child figure's bleed to just the gap
   — never the full title width — as a safety net in case that ordering
   rule ever gets broken by a future edit. Scoped with the direct-child
   combinator (>) so it only matches a figure that's literally the first
   element in .drawer-section-content, not merely first among figures. */
.drawer-section-content > .drawer-figure:first-child,
.drawer-section-content > .drawer-compare:first-child,
.drawer-section-content > .drawer-gallery:first-child,
.drawer-section-content > .drawer-trio:first-child {
  margin-left: calc(-1 * var(--section-gap));
  width: calc(100% + var(--section-gap));
}

/* A figure that should stay within the content column's own width
   instead of bleeding to span the section — James wanted the button-
   drift diagram sized down to sit alongside the paragraph above it,
   rather than dominating the section at full bleed width. Opt-in per
   block (`{ type: 'diagram', contained: true, ... }`, see
   BLOCK_RENDERERS.diagram in js/main.js), not a blanket rule for every
   diagram — the review-cycle diagram in "Keeping it consistent" still
   bleeds full width unless the same flag gets added to it too. Selector
   is .drawer-figure.drawer-figure--contained (three classes, not two),
   so this reliably outranks the general bleed rule above regardless of
   source order. */
.drawer-section-content .drawer-figure.drawer-figure--contained {
  margin-left: 0;
  width: 100%;
}

/* ---------------------------------------------------------------------------
   MINOR TIER — Other Projects entries (`variant: 'minor'`, js/main.js)

   .drawer-content-inner--minor deliberately carries NO layout rules. It is a
   hook, kept because the tier is a real distinction the markup should name,
   and because future tier-specific needs are likely.

   It had four overrides on 2026-09-05 — a 160px title column, a 48px gutter,
   24px section headings and a 72ch prose cap — and all four are gone, at
   James's direction: the two-column title/content grid is a SITE STANDARD, not
   a per-component knob (see §6 of STYLEGUIDE.md, and .drawer-section above).
   Every tier uses --section-title-w 264px and --section-gap --sp-32.

   The other three fell out with it rather than being separate decisions:
     - 24px headings existed only because 30px wrapped to four and five lines
       in a 160px column. At the standard 264px that drops to 1-3 lines, which
       is within reach of the featured tier's 1-2, so the layout reason is
       gone. Two Big Fish headings do still run to three lines — that is now
       purely a copy-length problem, to be fixed by shortening the headings,
       not by retuning type size or column width.
     - The 72ch prose cap existed only because narrowing the title column
       widened the content column to 912px lines. At the standard geometry the
       content column is 728px again, which was never a line-length problem.
     - The block gap (64 → 48px) was reverted for the same reason as the
       gutter: it's the drawer's vertical section rhythm, and there was no
       evidence 48px read better, only that it was smaller.

   So the minor tier now differs from a featured case study in exactly two
   ways, neither of them layout: it is much shorter (see PLAN.md for the
   measured budget), and it drops the "Thanks for reading!" sign-off. That is
   the intended design — the tiers are distinguished by content, not by
   geometry. Do not reintroduce a spacing override here without changing the
   standard itself.

   UPDATE 2026-09-28: the standard did change, for Experiments. Big Fish now
   renders through a separate split layout (`layout: 'split'`, see SPLIT
   LAYOUT further down), from a Claude Design handoff James commissioned for
   exactly this. The paragraph above still holds for any minor entry that
   doesn't opt in: no spacing overrides on this class.
   --------------------------------------------------------------------------- */

.drawer-paragraph {
  font-size: var(--fs-base);
  line-height: 1.7;
  color: var(--text-secondary);
  margin-top: 0;
  margin-bottom: 0;
}

.drawer-list {
  display: grid;
  gap: var(--sp-3);
  margin-top: 0;
  margin-bottom: 0;
  padding-left: 1.25em;
  font-size: var(--fs-base);
  line-height: 1.7;
  color: var(--text-secondary);
}

.drawer-list strong {
  color: var(--text);
  font-weight: 600;
}

/* Icon + title + text, in a bordered box — the "problems/highlights" block
   type. Reuses the elevated-placeholder-tile tokens (--bg-elevated, 1px
   --border, 12px radius — see .drawer-figure-placeholder below and the
   recipe in STYLEGUIDE.md §9) rather than inventing a new card style.
   Sits inside .drawer-blocks like any text block, so it inherits the
   standard --drawer-measure reading width — no break-out override like
   .drawer-figure/.drawer-compare get, since these read closer to a list
   than a figure. Icon rendering follows the same background-image
   convention as every other icon on the site (STYLEGUIDE.md §1) — stroke
   color baked into the file — but unlike the fixed .drawer-meta-icon-*
   classes, the icon here is chosen per card from data (the `cards` case in
   BLOCK_RENDERERS, js/main.js) and set via an inline background-image
   rather than a dedicated CSS class: this block type is meant for reuse in
   future icon+text lists with whatever icons that content needs, not just
   this one section. */
.drawer-cards {
  display: grid;
  gap: var(--sp-3);
}

.drawer-card {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  padding: var(--sp-4) var(--sp-6);
  /* All four cards share one height rather than each hugging its own text
     length — James found the uneven heights distracting. 148px is sized to
     the longest current card ("A problem that compounds", three lines of
     body text); align-items: center keeps shorter cards' content centered
     in the extra room rather than stuck at the top. If a future card's
     text runs longer than this, min-height (a floor, not a cap) just lets
     that one card grow — worth rechecking this number if that happens. */
  min-height: 148px;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: 12px;
  /* Pre-animation state. .is-visible (added by wireDrawerCards() in
     js/main.js, once per card, the first time it scrolls into view) plays
     the reveal below and leaves it at its resting state via
     animation-fill-mode: forwards. */
  opacity: 0;
  transform: translateY(-16px);
}

.drawer-card.is-visible {
  animation: drawer-card-drop 620ms var(--ease) forwards;
}

.drawer-card-icon {
  flex-shrink: 0;
  display: block;
  width: 28px;
  height: 28px;
  /* Masked like .util-icon (see there); the renderer (js/main.js) writes
     an inline mask-image: var(--icon-<name>) from the icon registry. */
  background-color: var(--icon-color);
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
}

.drawer-card-body {
  display: grid;
  gap: var(--sp-1);
}

.drawer-card-title {
  font-size: var(--fs-base);
  font-weight: 600;
  color: var(--text);
  margin: 0;
}

.drawer-card-text {
  font-size: var(--fs-sm);
  line-height: 1.6;
  color: var(--text-secondary);
  margin: 0;
}

.drawer-card-text strong {
  color: var(--text);
  font-weight: 600;
}

/* Compact variant — a bullet-point replacement (James avoids bare
   bulleted lists), built as a modifier on the same .drawer-card rather
   than a separate component, so it inherits the border/background/
   animation/observer wiring for free. A small white tab (.drawer-card-
   tab, below) replaces the icon, and text uses the same stacked title/
   body structure as the default cards — previously one inline paragraph
   with a bold lead-in, changed because it read too close to the default
   "Challenges to overcome" cards just above it on the page. Tighter
   padding than the default cards, but the same equal-height rule (see
   below). First use: the three root causes in "Finding the issues." */
.drawer-cards--compact {
  gap: var(--sp-2);
}

.drawer-card--compact {
  /* Matches the default cards' equal-height treatment, just at compact
     scale: all cards in the group share one height rather than each
     hugging its own text length. 92px is sized to the current tallest
     ("Limited review capacity", three lines at this measure); the
     inherited align-items: center keeps shorter cards' content centered
     in the extra room. Same caveat as the default cards' 148px — this is
     a floor, not a cap, so a longer future card just grows past it and
     breaks the match. Recheck the number if the copy changes. */
  min-height: 92px;
  padding: var(--sp-3) var(--sp-4);
  gap: var(--sp-3);
}

.drawer-card--compact .drawer-card-text {
  font-size: var(--fs-sm);
  line-height: 1.5;
}

/* Tab replaces the icon for compact cards — see BLOCK_RENDERERS.cards
   (js/main.js) and STYLEGUIDE.md §9. align-self: stretch overrides the
   shared .drawer-card's align-items: center just for this child, so the
   tab's height tracks the row's tallest item (the title+body text)
   rather than sitting centered at a fixed size the way the icon did.
   Color was literal white at first (matching .drawer-live-link's pure-
   white accent) — James found that too bright next to the card's own
   text once he saw it live, so it now matches .drawer-card-text's own
   color exactly (see that rule below) instead of standing out as a
   separate accent. */
.drawer-card-tab {
  flex-shrink: 0;
  align-self: stretch;
  width: 4px;
  border-radius: 999px;
  /* Was #ffffff — James found it too bright next to the card's own text.
     Matches .drawer-card-text's color exactly now, rather than standing
     out as a separate, brighter accent. */
  background: var(--text-secondary);
}

/* Same drop-and-settle shape as .drawer-meta-item's keyframe (see the note
   there), scaled up for a full-width content card instead of a small meta
   chip — bigger drop distance, longer duration. Per-leg easing for the same
   reason established there: a snappy curve restarting on the settle leg
   reads as a stiff snap, not a glide. */
@keyframes drawer-card-drop {
  0% {
    opacity: 0;
    transform: translateY(-16px);
    animation-timing-function: var(--ease);
  }
  55% {
    opacity: 1;
    transform: translateY(4px);
    animation-timing-function: ease-in-out;
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .drawer-card {
    opacity: 1;
    transform: none;
  }

  .drawer-card.is-visible {
    animation: none;
  }

  .drawer-principle {
    opacity: 1;
    transform: none;
  }

  .drawer-principle.is-visible {
    animation: none;
  }
}

/* "Design principles" — a 2x2 grid of numbered items sharing one frame
   (border/background), divided by internal lines, rather than each item
   getting its own independent box like .drawer-card does. Deliberately
   quieter than the problem/compact cards: no bounce, just a settle — by
   this point in the case study a calmer beat reads as more considered,
   not less alive. See BLOCK_RENDERERS.principles (js/main.js) for the
   render logic and the reasoning for a separate block type over a third
   `cards` size. */
.drawer-principles {
  display: grid;
  grid-template-columns: 1fr 1fr;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: 12px;
  overflow: hidden;
}

.drawer-principle {
  padding: var(--sp-6);
  /* Pre-animation state, same convention as .drawer-card above —
     .is-visible (added by wireDrawerCards(), which now watches this class
     too) plays drawer-principle-in and leaves it at rest via
     animation-fill-mode: forwards. */
  opacity: 0;
  transform: translateY(10px);
}

.drawer-principle:nth-child(odd) {
  border-right: 1px solid var(--border);
}

.drawer-principle:nth-child(-n + 2) {
  border-bottom: 1px solid var(--border);
}

.drawer-principle.is-visible {
  animation: drawer-principle-in 550ms var(--ease) forwards;
}

.drawer-principle-number {
  font-size: 40px;
  font-weight: 700;
  line-height: 1;
  /* Faint by design — a watermark, not a focal point. Same color-mix
     technique as the header bar's glass background (STYLEGUIDE.md §2) rather
     than a hardcoded rgba, so it tracks --text if that token ever
     changes. */
  color: color-mix(in oklab, var(--text) 14%, transparent);
}

.drawer-principle-title {
  margin: var(--sp-4) 0 0;
  font-size: var(--fs-base);
  font-weight: 600;
  color: var(--text);
}

.drawer-principle-text {
  margin: var(--sp-2) 0 0;
  font-size: var(--fs-sm);
  line-height: 1.55;
  color: var(--text-secondary);
}

/* A calm fade-and-rise, not a bounce — unlike drawer-card-drop/drawer-
   meta-drop above, no overshoot, no per-leg easing split. Deliberate: by
   "Design principles," the case study has already used the bouncy
   entrance twice (meta row, cards), and a quieter beat here reads as a
   considered choice rather than a missed opportunity for motion. */
@keyframes drawer-principle-in {
  0% {
    opacity: 0;
    transform: translateY(10px);
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}

/* A single short code excerpt — same elevated-tile tokens as .drawer-card/
   .drawer-principles (bg-elevated/border/12px radius), but its own class
   rather than joining the .drawer-figure group below: that group carries
   hover-zoom/click-to-expand behavior meant for media, which a code
   sample shouldn't inherit. First use: Asset Shelf's no-clipboard
   duplicate() snippet, 2026-08-24 — see js/main.js, BLOCK_RENDERERS.code. */
.drawer-code {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: var(--sp-4) var(--sp-6);
  margin: 0;
  /* Same min-width: 0 fix as .drawer-trio-item/.drawer-gallery-item
     (below) — without it, the unwrapped `<pre>` inside (white-space: pre,
     no wrap opportunities) forces this grid item wider than its 1fr
     share of .drawer-section-content, stretching every sibling block in
     the section to match instead of just letting the code scroll
     internally. Caught the same way those were: checking the actual
     rendered width against the viewport, not trusting that overflow-x:
     auto on the child alone would be enough. */
  min-width: 0;
}

.drawer-code-pre {
  margin: 0;
  overflow-x: auto;
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  line-height: 1.6;
  color: var(--text-secondary);
  white-space: pre;
}

.drawer-code-caption {
  margin: var(--sp-2) 0 0;
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  color: var(--text-muted);
}

.drawer-figure,
.drawer-compare,
.drawer-gallery,
.drawer-trio {
  max-width: none;
  margin: var(--sp-4) 0;
  /* Containing block for .drawer-figure-caption below, which positions
     itself absolute over the bottom-left corner of whatever media it
     belongs to rather than sitting in normal flow underneath it. */
  position: relative;
}

.drawer-figure,
.drawer-compare {
  /* Hover/is-expanded scale the whole figure now (see below), not the
     media inside it — the transition has to live wherever the transform
     is actually applied. */
  transition: transform 0.3s var(--ease);
}

.drawer-figure-img,
.drawer-figure-video,
.drawer-figure-placeholder {
  display: block;
  width: 100%;
  border-radius: 12px;
  /* transform lives on the containing .drawer-figure/.drawer-compare now
     (see the hover/is-expanded rules below), not here — nothing left on
     this element to transition it for. */
  transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
}

.drawer-figure-img,
.drawer-figure-video {
  height: auto;
  border: 1px solid var(--border);
  /* No-op everywhere height stays auto-derived from the content's own
     aspect ratio (every figure except the trio, currently) — box ratio
     and content ratio are already identical there, so cover vs. the
     default stretch behavior looks the same either way. Starts to matter
     the moment a box ratio is forced to differ from the content's own,
     which is exactly what the trio's expanded state does (16:9 footage
     into a 21:9 box, wireDrawerTrio()/js/main.js) — without this, that
     mismatch stretches the video vertically instead of cropping it.
     Caught before it ever shipped visibly: every trio video was still a
     flat-color placeholder box (nothing to visibly distort) until the
     first real clip landed (Asset Shelf, 2026-08-24). */
  object-fit: cover;
  /* Default object-position (50% 50%) centers the crop, splitting it
     evenly off the top and bottom — wrong here since cover's job in the
     trio's expanded state is entirely about cropping height (16:9 footage
     into a wider 21:9 box), and the footage itself has more going on near
     the top (document header, mail ID badge) than the blank canvas below
     it. Anchoring top means 100% of the crop comes off the bottom, none
     off the top, same "top-anchored, not centered" call already made for
     the homepage thumbnail crop (§11 below) for the same underlying
     reason — caught the same way, too: invisible until real footage
     existed to reveal it (James, 2026-08-24). */
  object-position: top;
}

.drawer-figure-placeholder {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
}

/* Hover and click-to-grow both scale the whole .drawer-figure/
   .drawer-compare container now, not the media element inside it —
   changed together, same root cause. Both used to target
   .drawer-figure-img/-video/-placeholder directly: harmless for a figure
   with no caption, but the caption overlay (a sibling of the media inside
   that same figure, position: absolute against it — see .drawer-figure-
   caption below) doesn't move when a transform is applied to a *different*
   element. Scaling the media alone grew it visually past the figure's own
   (unchanged) box, leaving the caption pinned to where that box used to
   end instead of where the now-bigger media actually ends. James caught
   this on "Component specs and states" once the gallery's captions
   (correctly figure-anchored via a real reflow, not a transform) made the
   standalone-figure version's drift obvious by comparison.

   Scaling the whole figure as one rigid unit keeps the caption moving in
   lockstep with the media inside it — same fix category as the gallery
   row change below, just via a shared ancestor instead of a real layout
   reflow. border-color still targets the media specifically via a
   descendant selector: only the visible image/video edge should
   highlight, not the (invisible) figure box around it. is-expanded is
   toggled on the figure by the click handler (js/main.js), not the media
   element — see that handler's comment for why. */
@media (hover: hover) {
  /* Same compositing fix as .work-card's (see that rule for the full
     reasoning): held on its own layer at rest, so the browser isn't
     promoting and demoting a layer at the two ends of the transform
     transition and re-rasterizing the contents on each side of the switch.
     James saw that as a 1-2px shift at the tail end of the hover on the
     trio's figures (2026-09-02), the same symptom he'd already caught on
     the work card's title.

     Hover-scoped rather than unconditional, unlike the trio's own rule
     below, and the trade is deliberate: a case study drawer holds a lot of
     figures, and a permanent compositing layer for every one of them is
     real memory. Restricting it to devices that can hover keeps the cost on
     desktop, where the hover-scale is the only thing that runs constantly.
     Click-to-grow (.is-expanded) on a touch device isn't covered by this
     and may still show the artifact — nobody has reported it there, and
     paying for every figure's layer on a phone to pre-empt it isn't a
     trade worth making blind. */
  /* .drawer-split-video (the split layout's clips, SPLIT LAYOUT below)
     joined these three rules on 2026-09-29: the same subtle hover growth
     and edge highlight, read from here rather than copied, so the two
     can't drift. It gets only this hover. There's no click-to-grow in that
     layout, which is why it isn't in the is-expanded rules further down. */
  .drawer-figure,
  .drawer-compare,
  .drawer-gallery-item,
  .drawer-split-video {
    will-change: transform;
  }

  .drawer-figure:hover,
  .drawer-compare:hover,
  .drawer-gallery-item:hover,
  .drawer-split-video:hover {
    transform: scale(1.02);
  }

  .drawer-figure:hover .drawer-figure-img,
  .drawer-figure:hover .drawer-figure-video,
  .drawer-figure:hover .drawer-figure-placeholder,
  .drawer-compare:hover .drawer-figure-img,
  .drawer-compare:hover .drawer-figure-video,
  .drawer-compare:hover .drawer-figure-placeholder,
  .drawer-gallery-item:hover .drawer-figure-img,
  .drawer-gallery-item:hover .drawer-figure-video,
  .drawer-gallery-item:hover .drawer-figure-placeholder,
  .drawer-split-video:hover .drawer-figure-video {
    border-color: var(--border-strong);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4),
                0 0 24px color-mix(in oklab, var(--text) 8%, transparent);
  }
}

@media (hover: hover) and (prefers-reduced-motion: reduce) {
  .drawer-figure:hover,
  .drawer-compare:hover,
  .drawer-gallery-item:hover,
  .drawer-split-video:hover {
    transform: none;
  }
}

.drawer-figure-img,
.drawer-figure-video,
.drawer-figure-placeholder {
  cursor: pointer;
}

.drawer-figure.is-expanded,
.drawer-compare.is-expanded {
  transform: scale(1.12);
}

.drawer-figure.is-expanded .drawer-figure-img,
.drawer-figure.is-expanded .drawer-figure-video,
.drawer-figure.is-expanded .drawer-figure-placeholder,
.drawer-compare.is-expanded .drawer-figure-img,
.drawer-compare.is-expanded .drawer-figure-video,
.drawer-compare.is-expanded .drawer-figure-placeholder {
  border-color: var(--border-strong);
}

@media (prefers-reduced-motion: reduce) {
  .drawer-figure.is-expanded,
  .drawer-compare.is-expanded {
    transform: none;
  }
}

/* Sans/regular, same face and weight as .drawer-paragraph — not mono.
   James has said he wants mono retired sitewide, not just here (see
   STYLEGUIDE.md §1) — this pass only covers figure/video captions;
   the rest (nav pill, meta rows, section labels, etc.) is still pending
   a separate pass. Font-size unchanged (--fs-xs) — only the face/weight/
   color changed, not the scale.

   Went through two treatments before landing here. First pass was a tight
   white chip, same shape as .drawer-live-link — read as "too eye
   catching, too big" once James saw it live. This pass instead overlays
   the figure's own bottom-left corner (position: absolute against the
   parent's position: relative — see .drawer-figure/.drawer-compare
   above, or .drawer-gallery-media below once a gallery item also needs
   flow content of its own, like .drawer-gallery-duration) rather than
   sitting in normal flow beneath it, so it adds no extra vertical space
   and reads as a small label on
   the image rather than a second UI element competing with it. Reuses
   the site's existing glass-pill recipe (color-mix + backdrop-filter,
   same as .site-header) instead of inventing a new translucent surface for
   just this one component. */
.drawer-figure-caption {
  position: absolute;
  left: var(--sp-2);
  bottom: var(--sp-2);
  display: inline-block;
  font-family: var(--font-sans);
  font-weight: 400;
  font-size: var(--fs-xs);
  color: var(--text);
  background: color-mix(in oklab, var(--bg) 72%, transparent);
  border: 1px solid var(--border-strong);
  backdrop-filter: blur(16px) saturate(1.4);
  -webkit-backdrop-filter: blur(16px) saturate(1.4);
  padding: 3px 8px;
  border-radius: 6px;
  margin: 0;
}

/* The button-drift diagram floats directly on the drawer's own background
   — no elevated card wrapper. James found the elevated-card treatment (a
   lighter grey than the page) unnecessary once the diagram itself has real
   color; removed rather than kept as an unused option. */
.drawer-figure--diagram svg {
  display: block;
  width: 100%;
  height: auto;
}

/* Several images/clips shown together as one set — see BLOCK_RENDERERS.
   gallery (js/main.js) for the render logic and why this is a separate
   block type rather than stacking individual image/video blocks. Same
   card-less, break-out-to-full-width treatment as .drawer-compare; each
   item is its own figure so it gets its own caption, unlike compare's one
   caption for the pair.

   2x2 grid, not the equal-share single flex row this was before —
   James wanted the tiles bigger ("so the user can see more"), and two
   per row at this column's width gets a meaningfully bigger tile than
   three ever could. (That flex row itself had superseded an even earlier
   horizontal-scroll version, 2026-08-14 — the scrollbar read as
   unstyled against the rest of the drawer, and tiles sat cropped at the
   edge until scrolled.) grid-template-columns: 1fr 1fr splits evenly;
   items wrap to a new row automatically once there are more than two,
   which a plain flex row can't do without wrapping every item down to
   its own line.

   Click-to-expand changed with it, three times now.

   First pass reused the standalone-figure treatment (transform:
   scale(1.06)) — James found the growth too subtle, asked for it "by a
   significant amount."

   Second pass: one shared grid across every item, grid-column: 1 / -1 on
   the expanded one (full width, a genuine reflow) plus grid-auto-flow:
   dense on the parent so a later item could backfill the gap it left
   behind. Solved the size complaint, but backfilling from a *shared*
   pool of items — rather than a fixed pairing — meant only the first
   item in a row ever had room to expand without moving: the second
   item's row-mate was already occupying the other column, so dense
   packing bumped it down to a new row and pulled a later item up to fill
   the hole. James wanted every item to expand in place, in its own row,
   regardless of which side of the pair it started on.

   This pass: each row is its own independent 2-column grid
   (.drawer-gallery-row below), one per pair of items, stacked in a
   plain column by .drawer-gallery itself. Expanding an item only ever
   affects the row it's already in — there's no shared pool for a
   backfill to reach into, so nothing can jump rows. :has() lets the
   *row* react to its own child's state without any JS beyond the plain
   class toggle already in place (see the click handler, js/main.js) —
   collapses that one row to a single column, the expanded item filling
   the full width (aspect-ratio takes care of height), its row-mate
   capped to half that width and pushed below it via order: -1 on the
   expanded item, so the big one always leads regardless of which side
   it started on. */
.drawer-gallery {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}

/* The gallery's own rhythm (--sp-4 both between its rows and across each
   row) reads as tight and deliberate — then whatever figure follows it
   sits noticeably farther away, since that gap stacks three things on
   top of each other: .drawer-section-content's own grid gap (--sp-6),
   this figure's bottom margin (--sp-4, from the shared .drawer-figure/
   .drawer-compare/.drawer-gallery rule above), and the next figure's own
   top margin (another --sp-4). Caught by James specifically comparing
   the gallery to "Swapping variants on a full page" right below it.
   Zeroing this margin and pulling the next figure up with a negative
   margin (calc'd so the net gap lands on --sp-4, not just "less than
   before") brings that one transition in line with the gallery's own
   internal gutter. Scoped to right-after-a-gallery via the adjacent-
   sibling selector below — figure-to-figure gaps elsewhere in the case
   study (e.g. the button-drift diagram into the sticky-note video) don't
   sit next to a tight internal grid the way this one does, so nothing
   about them reads as inconsistent, and this doesn't touch them. */
.drawer-gallery {
  margin-bottom: 0;
}

.drawer-gallery + .drawer-figure,
.drawer-gallery + .drawer-compare {
  margin-top: calc(var(--sp-4) - var(--sp-6));
}

.drawer-gallery-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--sp-4);
  /* Same reason as .drawer-figure above (§1) — without this, grid's
     default align-items: stretch would stretch the shorter item in a row
     to match its now-expanded row-mate, and the caption overlay —
     anchored to that same box — would land in the empty stretched space
     below the actual image again, the identical bug already caught once
     for the old flex row (see .drawer-gallery-item below). */
  align-items: start;
}

/* Wider gap than the base --sp-4 above, James's request, 2026-08-25 — the
   grid's single `gap` normally does double duty (horizontal between
   columns at rest, vertical between the stacked items once expanded),
   and the tighter value that reads fine side-by-side felt cramped once
   an expanded item's own duration/caption sat right on top of the
   shrunk item's frame below it. Scoped to the expanded state only, so
   the resting 2-column layout is untouched. */
.drawer-gallery-row:has(.drawer-gallery-item.is-expanded) {
  grid-template-columns: 1fr;
  gap: var(--sp-8);
}

.drawer-gallery-row:has(.is-expanded) .drawer-gallery-item:not(.is-expanded) {
  max-width: 50%;
}

.drawer-gallery-item {
  margin: 0;
  /* Same as .drawer-figure/.drawer-compare's own transform transition —
     without it the hover scale(1.02) snaps instantly instead of easing
     in. */
  transition: transform 0.3s var(--ease);
}

/* Wraps just the media + its overlay caption, separately from an
   optional .drawer-gallery-duration below — added 2026-08-25 alongside
   `duration` (see BLOCK_RENDERERS.gallery, js/main.js). Before this
   wrapper existed, .drawer-figure-caption's `position: absolute; bottom:
   var(--sp-2)` resolved against .drawer-gallery-item directly; once
   `duration` became a third flow child of that same item, the item's box
   grew to include it, and "bottom" started meaning the bottom of the
   *duration line* instead of the video's own bottom edge — the caption
   visibly drifted down onto the time. Giving media+caption their own
   `position: relative` container restores the original anchor (the
   caption still overlays the frame it's actually on) while letting
   `duration` live outside it as ordinary flow content. Same reason
   .drawer-figure/.drawer-compare/.drawer-trio-item all need this same
   positioning context for their own captions. */
.drawer-gallery-media {
  position: relative;
}

/* Optional plain-flow label under a gallery item's frame — distinct from
   .drawer-figure-caption, which overlays the media instead of sitting
   below it. First use: Asset Shelf's before/after clip pair (Epiq case
   study), where James wanted each clip's own length shown, since the
   runtime itself is the time-savings evidence (a 1:29 manual clip next
   to a 0:30 plugin one). Centered under the frame and given a full
   --sp-3 gap above (up from an initial --sp-2) per James's same-day
   follow-up, once he saw the tighter default spacing live.

   Font-size bumped from .drawer-card-title's --fs-base to
   .drawer-section-title's --fs-xl (plus its -0.01em tracking), same day
   again — James wanted the time to read at section-header scale, not
   card scale, since it's meant to be the loudest thing in the pair, not
   a caption-level detail. Weight/color still match both recipes (600 /
   --text), so this is really "borrow section-title's size and tracking,
   keep the rest," not a wholesale swap to a different component's type
   treatment. */
.drawer-gallery-duration {
  font-size: var(--fs-xl);
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--text);
  text-align: center;
  /* --sp-3 (12px) read as too tight once the sparkle halo shipped, James,
     2026-08-25 — the topmost sparkles sit roughly 15-16px above the
     duration text itself (see SPARKLE_OFFSETS in js/main.js), so a 12px
     gap left them visually crowding the frame's bottom edge. --sp-6
     (24px) gives the sparkles real clearance instead of just a few
     spare pixels, and applies to both durations in a pair (not only the
     sparkled one) so the two stay visually symmetric. */
  margin: var(--sp-6) 0 0;
}

/* Sparkle halo for one specific duration (Asset Shelf's "0:30", via
   `sparkle: true` on the gallery item — js/main.js's renderDuration()).
   A deliberate, one-off exception to this site's greyscale-only rule
   (STYLEGUIDE.md §2) — James's own call, celebrating the fast number
   rather than the slow baseline next to it. The gold value is a literal
   here, not a `:root` token: it's meant to stay a rare, considered
   accent, not something that reads as an available color other
   components might reach for. Prototyped as four directions in a
   standalone Artifact before this shipped; full history in
   STYLEGUIDE.md's duration recipe (§9). */
.drawer-duration-anchor {
  position: relative;
  display: inline-block;
}

.drawer-spark {
  position: absolute;
  display: block;
  pointer-events: none;
  color: #e8c468;
  opacity: 0;
  filter: drop-shadow(0 0 4px rgba(232, 196, 104, 0.55));
  animation: drawerSparkTwinkle 2.2s ease-in-out infinite;
}

.drawer-spark svg {
  width: 100%;
  height: 100%;
  display: block;
}

@keyframes drawerSparkTwinkle {
  0%, 100% { opacity: 0; transform: scale(0.3) rotate(0deg); }
  45% { opacity: 1; transform: scale(1.15) rotate(-15deg); }
  60% { opacity: 0; transform: scale(0.5) rotate(10deg); }
}

@media (prefers-reduced-motion: reduce) {
  .drawer-spark {
    animation: none;
    opacity: 0.6;
  }
}

.drawer-gallery-item.is-expanded {
  order: -1;
}

.drawer-gallery-item.is-expanded .drawer-figure-img,
.drawer-gallery-item.is-expanded .drawer-figure-video,
.drawer-gallery-item.is-expanded .drawer-figure-placeholder {
  border-color: var(--border-strong);
}

.drawer-compare-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--sp-4);
}

@media (max-width: 640px) {
  .drawer-compare-grid {
    grid-template-columns: 1fr;
  }
}

/* Three plugin showcases side by side — see BLOCK_RENDERERS.trio and
   wireDrawerTrio() (js/main.js) for the render/scale-to-fit logic.
   Epiq case study only, as of this build.

   Grid, not flex — this was a flex-wrap row through most of 2026-08-21's
   build (flex-basis-driven spotlight/expand widths, an item claiming
   flex: 1 1 100% to force the other two onto a wrapped second line). It
   never worked reliably: the row's item widths would stick at a plain
   equal three-way split regardless of what flex-basis/width value was
   set — tried percentages, oversized fixed-px values, and plain `width`,
   all as forceful inline !important overrides, none of it moved that one
   row — while identical markup cloned into a fresh element resized
   correctly every time. Root cause never pinned down (see the working
   doc's changelog, "spotlight interaction, flex debugging marathon," for
   the full trail). Rebuilt on CSS Grid instead: grid-template-columns
   set inline by JS per state, with the expanded item spanning both
   columns of a 2-column, 2-row template — declarative track sizing, no
   grow/shrink math to go sideways. Grid changes can't be smoothly CSS-
   transitioned the way a single flex/width property can, so the smooth
   motion instead comes from the same FLIP technique
   animateGalleryReflow() (js/main.js) already uses for the gallery's own
   un-transitionable grid-column reflow — see wireDrawerTrio()'s own FLIP
   helper, built the same way. */
.drawer-trio {
  /* The logo slot's height, desktop; the <=640px block redefines it. */
  --trio-art-h: 7.5rem;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--sp-6);
  /* Restored, 2026-09-01, same day it was removed — the removal was a
     mistake, not a correction. It was pulled on the strength of a test
     run entirely in this session's own browser automation (set
     grid-template-columns directly, sampled width via setTimeout,
     watched it jump straight to its final value with zero interpolation
     across five samples). That environment has independently proven
     unable to observe live compositing/animation multiple times this
     same session (screenshots refusing to render, requestAnimationFrame
     never firing) — the same root cause almost certainly corrupted that
     test too, not the CSS. James, watching the real thing in a real
     browser, confirmed hover felt like it was snapping, which is
     consistent with this transition never actually having been running
     — i.e., the original code comment's claim ("this is directly
     interpolable") was probably right all along.
     0.45s to match wireDrawerTrio()'s own FLIP_MS (js/main.js) — kept in
     sync deliberately, since hover/spotlight (same 3-track shape, only
     weights differ) now animates through this rule alone, with no JS
     transform involved at all; only expand/collapse (track count
     genuinely changes, CSS can't interpolate that regardless of engine)
     still goes through the JS FLIP path. Grid interpolation only applies
     when the two states have matching track structure — expand/collapse
     falls outside that, so this rule has no effect there either way;
     no conflict with the JS mechanism handling that case. */
  transition: grid-template-columns 0.45s var(--ease);
}

@media (max-width: 640px) {
  /* Deliberately simple for this pass: the spotlight/expand states still
     toggle underneath (wireDrawerTrio() doesn't know or care about the
     breakpoint), but this forces a plain single-column stack over
     whatever JS computed — hover has no real meaning on touch anyway,
     and reflow-beneath doesn't add much when everything's already
     stacked. Revisit if mobile wants its own take on this later ("more
     tweaks will be had," per James, 2026-08-21). */
  .drawer-trio {
    grid-template-columns: 1fr !important;
  }

  .drawer-trio-item {
    grid-column: auto !important;
    grid-row: auto !important;
  }

  /* The desktop 7.5rem (.drawer-trio-art-frame, below) was derived from a
     ~357px desktop column; stacked to one column, mobile's column is the
     full content width instead — a single flat value tuned for a typical
     phone (~390px) rather than the fluid range, matching how this block
     already overrides with flat values, not math. Confirm empirically
     across a couple of real widths (375px-620px) rather than trusting
     this arithmetic alone if it's ever revisited.
     !important for the same reason the two rules above already need it:
     .drawer-trio-art-frame's own unconditional rule sits later in this
     file (same specificity, single class), so without it the cascade's
     "last rule wins" tie-break would pick the desktop height regardless
     of which media query actually matches — caught live, not assumed;
     the override silently lost to the desktop rule until this was added. */
  .drawer-trio {
    --trio-art-h: 6.75rem;
  }

  .drawer-trio-art-frame {
    height: var(--trio-art-h) !important;
  }
}

/* min-width: 0, on the item and its direct children: a grid item's
   default "automatic minimum size" is its own content's min/max-content
   size, not its track's actual share. Without this, .drawer-trio-item (or
   something inside it — in practice, the ASCII art's own unwrapped text
   width) could force the item wider than its share of the row. */
.drawer-trio-item {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-4);
  min-width: 0;
  cursor: pointer;
  /* Slack goes to the bottom of the column, never between the logo and the
     video. Added 2026-09-02, and it fixes two separate symptoms James
     reported with one line, because they share a cause.

     All three items are always the same height — they're grid items in one
     row, and `align-items: normal` stretches them to the tallest. Whatever
     height an item is *given* beyond its own content, `align-content:
     normal` then distributes between its two internal rows, which moves the
     video down. Both of the symptoms are that redistribution:

     1. Hover. The spotlighted column's video gets wider, so it gets taller,
        so the row gets taller (369.88px -> 395px measured), so the other two
        items are stretched and their videos slid down 18.85px. Fixed: they
        now hold at the same y in every state.
     2. Expand/collapse. animateTrioReflow() (js/main.js) pins the row's
        height to its pre-change value and animates it down, which is what
        keeps the section below from snapping. But pinning a height *is*
        giving every item that height: with the row pinned at its expanded
        1300px, the videos measured 460px below where the FLIP's own
        transforms assumed they were, and dropped back as the pin came off.
        Fixed: with the row pinned to 1300px the videos now measure at
        exactly their resting y, so the pin moves nothing at all.

     `start` rather than a fixed row template: the logo slot is already a
     fixed height (.drawer-trio-art-frame) and the video's height follows
     its own aspect ratio, so top-packing is all that's needed for the three
     video tops to line up, which is this component's whole alignment
     premise (see the frame's comment below). */
  align-content: start;
}

.drawer-trio-item > * {
  min-width: 0;
}

.drawer-trio-item:focus-visible {
  outline: 2px solid var(--border-strong);
  outline-offset: 4px;
  border-radius: 4px;
}

/* Fixed-height slot, added 2026-09-01 — see BLOCK_RENDERERS.trio's comment
   (js/main.js) for the full alignment-bug story this fixes. width/margin
   moved here from .drawer-trio-art below, since the frame is now what
   fixes each column's box, not the image. Same height for all three
   items, always — that's the entire mechanism: item-top (already flush —
   .drawer-trio never overrides align-items) + this fixed height + the
   item's existing gap (var(--sp-4), unchanged) is now the same sum in
   every column, which is what actually forces the three video figures'
   tops to align, not just the art.

   7.5rem derived from the tallest-relative-to-width of the three post-
   trim logos (Asset Shelf, artAspect 1602/679 ≈ 2.36:1) rendering at this
   width without touching either edge: --content-w (1120px) minus 2×gap
   (var(--sp-6)) over 3 equal columns ≈ 357px per column, 75% of that
   ≈ 268px, 268 / 2.36 ≈ 113.6px, rounded up for a little breathing room.
   The other two logos (wider/flatter aspect ratios) render shorter than
   this height and sit with slack below them before the gap starts —
   expected, not a bug: with three genuinely different logo shapes, a
   shared fixed slot is the only way to also guarantee aligned video tops
   (see the js/main.js comment for why the alternative — measuring the gap
   from each logo's own ink instead of the slot — can't do both at once).
   Recheck this number if any of the three PNGs gets re-exported at a
   meaningfully different aspect ratio. */
.drawer-trio-art-frame {
  display: block;
  /* Fixed, not 75% of the item, as of 2026-09-01 — James: hovering or
     expanding a column grew its logo too ("we don't need to enlarge the
     logos at all, they can stay the same size when the layout shuffles").
     A percentage width was the resting-state half of that bug: the item's
     own real width genuinely differs between default (~1fr share),
     spotlight (~1.2fr share), and expanded (full row), so 75% of it
     differed right along with it, independent of any animation. 268px is
     today's default-state width, confirmed live (getBoundingClientRect,
     not just the arithmetic below) before locking it in — matches this
     component's own established discipline of verifying sizing numbers
     against real rendering, not trusting arithmetic alone. Derivation for
     the next time this needs rechecking: --content-w (1120px) minus 2×
     var(--sp-6) gap over 3 equal columns ≈357px, ×75% ≈268px.
     width: 100%/max-width: 268px, not the other way around — caught live,
     not assumed: `width: 268px; max-width: 100%;` measured correctly at
     desktop width but silently overflowed its column in the ~640-1000px
     band (item ~195px, frame still 268px). Root cause is a known CSS Grid
     gotcha, not a typo: percentages on a grid item are treated as
     indefinite during the track-sizing algorithm, so `max-width: 100%`
     can't reliably cap a track whose own size is what that percentage
     would need to resolve against — the browser fell back to the
     unambiguous fixed `width: 268px` as the item's intrinsic contribution
     to its own single-column internal grid, inflating that track past the
     item's real 195px box regardless of the percentage max-width.
     Swapping which property carries the percentage sidesteps it
     entirely: `width: 100%` is itself the well-understood, already-
     proven-safe case (this is exactly what the old `width: 75%` did,
     with zero overflow issues, before this fix existed), and `max-width`
     as a plain px value has no such resolve-against-itself ambiguity.
     Confirmed live after the swap: 195px item / 195px frame at 700px
     width (was 268px frame against a 195px item — real overflow, not a
     rounding artifact), zero overflow across the 375-1280px sweep. */
  width: 100%;
  max-width: 268px;
  height: var(--trio-art-h);
  /* Not `margin: 0 auto` as of 2026-09-01 — auto-centering computes
     against the *item's current width*, live, every time the browser
     feels like recalculating it, which turned out to not be every frame
     of a grid-template-columns transition (James caught a 1-2px snap
     right at the tail end of hover — the track itself was animating
     smoothly, but this dependent centering calculation apparently
     wasn't being re-run in step with it, only settling once the
     transition finished).
     First fix froze margin-left at one value, computed once and never
     touched again — wrong call, not just incomplete: it fixed the snap
     by removing centering altogether, so the logo stayed put while its
     column kept resizing around it (visibly off-center once expanded to
     the full row width, and drifting toward one edge on hover as the
     other two columns' share shrank). What was actually needed was
     recentering that *itself* animates smoothly, not a frozen position.
     wireDrawerTrio() now recomputes the target margin-left on every
     state change (hover, expand, collapse — see mutateGridState()) and
     this transition is what turns that new target into a smooth move
     instead of a jump, running in parallel with whatever the FLIP
     transform/opacity fade are doing for size on expand — different
     property, no interference either direction. 0.45s to match the
     row's own grid-template-columns transition and wireDrawerTrio()'s
     FLIP_MS, so all three stay in step rather than settling at different
     times. */
  transition: margin-left 0.45s var(--ease);
  /* Defensive only — object-fit: contain shouldn't overflow its box, this
     just guarantees it can't if a future aspect ratio ever pushes close. */
  overflow: hidden;
}

/* The two elements animateTrioReflow() (js/main.js) drives transforms on:
   the item, and the art frame it counter-scales inside it. Same layer
   reasoning as .work-card and the drawer figures above — but *not*
   hover-scoped, because unlike those two this transform runs on click, so
   it runs on touch devices too. Three items and three frames per trio is a
   small enough number to hold permanently on any device. */
.drawer-trio-item,
.drawer-trio-art-frame {
  will-change: transform;
}

/* object-fit: contain scales the image down (or up) to fit the frame
   without distorting it, preserving its real aspect ratio; object-
   position: top puts 100% of whatever space contain doesn't fill below
   the image, never above — every logo's visible top lands at the frame's
   top regardless of how much shorter it renders than the frame itself.
   That's what makes the three logos "top-aligned and flush," James's own
   words, rather than just occupying same-height boxes. */
.drawer-trio-art {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: top center;
}

.drawer-trio-art-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 96px;
  padding: var(--sp-4);
  background: var(--bg-elevated);
  border: 1px dashed var(--border-strong);
  border-radius: 12px;
  color: var(--text-muted);
  font-size: var(--fs-xs);
  text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  .drawer-trio {
    transition: none;
  }
}

/* Drag-to-reveal before/after — see BLOCK_RENDERERS.slider and
   wireDrawerSlider() (js/main.js) for the render/drag logic.
   .drawer-slider-figure exists so .drawer-figure's bleed-to-section-width
   treatment (above) applies here too, same as every other figure type.

   The "after" image sits in normal flow at full size and defines the
   frame's own dimensions (height: auto, so the frame is exactly as tall
   as the image at its rendered width — no fixed aspect-ratio needed since
   both real screenshots share the same 1440x810 native size). The
   "before" image is identical but wrapped in an absolutely-positioned,
   full-size sibling that gets clipped with clip-path, not resized — a
   width-based approach (shrinking the wrapper to reveal less of it) would
   scale the image down along with the wrapper, which isn't what "reveal
   less of a same-size image" means. clip-path leaves the image itself at
   its true size and only changes how much of it paints. */
.drawer-slider {
  position: relative;
  overflow: hidden;
  border-radius: 12px;
  border: 1px solid var(--border);
  cursor: ew-resize;
  touch-action: pan-y;
  /* Same fix as the carousel's drag track (below) — without this, a
     click-drag on the label text or the gap between the two images
     starts a text selection instead of (or alongside) the intended
     drag, which fights the pointer handling in wireDrawerSlider(). */
  user-select: none;
  -webkit-user-select: none;
}

.drawer-slider:focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 3px;
}

.drawer-slider-img {
  display: block;
  width: 100%;
  height: auto;
  /* No click-to-grow here (unlike .drawer-figure-img elsewhere) — a
     drag gesture and a scale-on-click transform would fight each other
     on the same element. See BLOCK_RENDERERS.slider's comment for why
     this is a separate class rather than reusing .drawer-figure-img. */
  cursor: inherit;
  /* Caught live: without draggable="false" (markup) and this property,
     the browser's native "drag this image out" gesture fires alongside
     wireDrawerSlider()'s own pointer handling, dragging a ghost preview
     of the image instead of moving the reveal boundary. Missed when this
     block was first built — the carousel's images already carry the same
     pairing, this one just didn't get it copied over. */
  -webkit-user-drag: none;
}

.drawer-slider-before {
  position: absolute;
  inset: 0;
  overflow: hidden;
  /* Matches the initial aria-valuenow="50" default in the markup — real
     position is set immediately on wire-up (wireDrawerSlider()'s apply()),
     this only covers the instant before that runs. */
  clip-path: inset(0 50% 0 0);
}

.drawer-slider-label {
  position: absolute;
  top: var(--sp-2);
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  font-weight: 500;
  color: var(--text);
  background: color-mix(in oklab, var(--bg) 72%, transparent);
  border: 1px solid var(--border-strong);
  backdrop-filter: blur(16px) saturate(1.4);
  -webkit-backdrop-filter: blur(16px) saturate(1.4);
  padding: 3px 8px;
  border-radius: 6px;
  pointer-events: none;
  z-index: 2;
}

.drawer-slider-label--before {
  left: var(--sp-2);
}

.drawer-slider-label--after {
  right: var(--sp-2);
}

.drawer-slider-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 2px;
  margin-left: -1px;
  background: #ffffff;
  pointer-events: none;
  z-index: 3;
}

.drawer-slider-grip {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 40px;
  height: 40px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--bg) 72%, transparent);
  border: 1px solid #ffffff;
  backdrop-filter: blur(16px) saturate(1.4);
  -webkit-backdrop-filter: blur(16px) saturate(1.4);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  color: #ffffff;
}

/* Small left/right chevrons signal "drag me" without needing an icon
   file for a single one-off use — same reasoning as the demo notes in
   the prototype this shipped from (PLAN.md). */
.drawer-slider-grip::before {
  content: '\25C0';
  font-size: 0.5rem;
}

.drawer-slider-grip::after {
  content: '\25B6';
  font-size: 0.5rem;
}

/* Closing "Results" row — one inverted-light lead card next to a row of
   dark stat cards. See BLOCK_RENDERERS.outcomes (js/main.js) for the
   data shape and full reasoning. All cards always the same width and
   height — James, 2026-08-25, a firm rule: "let's always keep these as
   the same uniform height and width." Width comes for free from
   `flex: 1 1 0` on every child; height comes from a shared min-height
   sized to the lead card's two-line content, the same "floor, not a
   cap" pattern .drawer-card/.drawer-principle already use elsewhere —
   recheck this number if `lead.text` ever runs longer. */
.drawer-outcomes {
  display: flex;
  gap: var(--sp-4);
  align-items: stretch;
}

.drawer-outcomes-card {
  flex: 1 1 0;
  min-width: 0;
  /* Shrunk from 168px, James, 2026-08-25: he wants centered content back
     (see justify-content below), but a floor that far above the real
     content height is exactly what made centering produce an oversized,
     padding-defying top gap in the first place. Set close to actual
     content+padding height instead — recheck this number if the lead or
     stat copy changes length; it's a floor sized to today's content, not
     a fixed design decision. */
  min-height: 140px;
  border-radius: 16px;
  /* Top/bottom only, James, 2026-08-25, refined twice. First pass cut
     top/bottom below the horizontal padding (18px vs. --sp-4's 16px).
     Second pass corrected the actual target: top/bottom slightly
     *larger* than left/right, not smaller — James's own example, "if
     left/right is 20px, top/bottom should be 25px" (a 1.25x ratio).
     Applied to this card's real 16px horizontal padding: 16 * 1.25 =
     20px. No scale token lands on 20px, so this stays a deliberate
     hand-tuned value, same category of exception as --content-pad
     further up this file. */
  padding: 1.25rem var(--sp-4);
  display: flex;
  flex-direction: column;
  /* Back to center, James, 2026-08-25 (was flex-start briefly — see the
     min-height comment above for why that existed and why it's no
     longer needed). James's diagnosis was right: the actual problem was
     never centering itself, it was a height floor tall enough to give
     centering a large amount of leftover space to distribute. With
     min-height brought down close to the real content height, centering
     has very little slack left to split, so the visual gap stays close
     to padding-top on every card without needing to pin alignment to a
     side. */
  justify-content: center;
}

/* Deliberately not pure white — same "choose a neutral, don't default to
   one" reasoning behind every other color on this site. #14140f isn't
   pure black either, for the same reason. This is the one place on the
   site light-on-dark appears; that's a deliberate contrast move (this
   site has no accent color to reach for — STYLEGUIDE.md §2 — so white
   is the loudest thing this palette can do), not an inconsistency. */
.drawer-outcomes-lead {
  background: #f4f4f2;
  color: #14140f;
}

.drawer-outcomes-lead-title {
  font-size: 1.375rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  line-height: 1.2;
  /* var(--sp-3) (12px) * 1.5, James, 2026-08-25 — matches the icon's
     margin-bottom below getting the same 50% treatment. 18px lands
     between --sp-3/--sp-4, so this stays a hand-tuned value, same
     category as this block's other exceptions above. */
  margin: 0 0 1.125rem;
}

.drawer-outcomes-lead-text {
  font-size: var(--fs-sm);
  line-height: 1.55;
  color: #4a4a42;
  margin: 0;
}

.drawer-outcomes-stat {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
}

/* Same background-image icon convention as .drawer-card-icon
   (STYLEGUIDE.md §1) — stroke color baked into the file, not a mask. */
.drawer-outcomes-stat-icon {
  display: block;
  width: 24px;
  height: 24px;
  /* Masked like .util-icon (see there); the renderer (js/main.js) writes
     an inline mask-image: var(--icon-<name>) from the icon registry. */
  background-color: var(--icon-color);
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
  /* var(--sp-4) (16px) * 1.5 = 24px, James, 2026-08-25 — happens to land
     exactly on --sp-6, unlike the lead title's equivalent 50% bump. */
  margin-bottom: var(--sp-6);
}

.drawer-outcomes-stat-text {
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: 1.45;
  color: var(--text);
  margin: 0;
}

@media (max-width: 640px) {
  .drawer-outcomes {
    flex-direction: column;
  }
}

/* Auto-scrolling filmstrip of screenshots — see BLOCK_RENDERERS.carousel
   and wireDrawerCarousel() (js/main.js) for the render/motion logic.
   .drawer-carousel-figure exists so .drawer-figure's bleed-to-section-width
   treatment (above) applies here too, same as video/image/gallery/compare
   already get.

   Edge fade is a mask-image gradient on the viewport, not a solid-color
   overlay div — works over any image content without a fade block that'd
   fight the picture underneath. Stops started at 12.5%/87.5%, matching the
   reference James pointed to (hellobrianl.in/factor-programs), but read as
   too aggressive once this container's own width was fixed (a much
   narrower viewport than that reference's) — 12.5% of this box eats
   further into the visible tiles than the same percentage does there.
   Narrowed twice since: first to a flat 5%/95%, then — once that read as
   visibly "stepped" — to the 3%-wide, 4-stop eased ramp below. A flat
   2-stop fade (transparent straight to opaque) is a *linear* alpha ramp,
   which reads as an abrupt edge rather than a soft one; human contrast
   perception isn't linear, so linear alpha fades tend to look like they
   "turn on" partway through rather than gradually appearing. The 4 stops
   per side approximate smoothstep (3t² − 2t³) at t = 0, 1/3, 2/3, 1 — the
   standard ease-in-out used for exactly this kind of fade — rather than
   the site's own --ease token, which is a motion (time) curve, not really
   meant for a static, mirrored spatial gradient. Both stops interpolate
   alpha only (rgba(0,0,0,*) throughout, never crossing to a different
   base color), which avoids the separate "grey dead zone" bug some
   transparent-to-opaque gradients get from interpolating RGB channels
   alongside alpha. */
.drawer-carousel-figure {
  /* .drawer-section-content is itself a grid (see below), so this figure
     is a grid item with the same default min-width: auto that .drawer-
     section-content already had to override on itself one level up (see
     that rule's comment). Without this, the track's `width: max-content`
     (below) makes this figure's content-based minimum size the full
     unclipped filmstrip width, and the grid track grows to fit it instead
     of the intended bled column — .drawer-carousel's own overflow: hidden
     only clips *painting*, it doesn't stop this from happening, since the
     oversized descendant is two levels further in. Same root cause
     .drawer-gallery-item's min-width: 0 (above) exists for; caught after
     James reported the carousel running off the page. */
  min-width: 0;
}

.drawer-carousel {
  overflow: hidden;
  cursor: grab;
  touch-action: pan-y;
  -webkit-mask-image: linear-gradient(
    to right,
    rgba(0, 0, 0, 0) 0%,
    rgba(0, 0, 0, 0.26) 1%,
    rgba(0, 0, 0, 0.74) 2%,
    #000 3%,
    #000 97%,
    rgba(0, 0, 0, 0.74) 98%,
    rgba(0, 0, 0, 0.26) 99%,
    rgba(0, 0, 0, 0) 100%
  );
  mask-image: linear-gradient(
    to right,
    rgba(0, 0, 0, 0) 0%,
    rgba(0, 0, 0, 0.26) 1%,
    rgba(0, 0, 0, 0.74) 2%,
    #000 3%,
    #000 97%,
    rgba(0, 0, 0, 0.74) 98%,
    rgba(0, 0, 0, 0.26) 99%,
    rgba(0, 0, 0, 0) 100%
  );
}

.drawer-carousel.is-dragging {
  cursor: grabbing;
}

.drawer-carousel-track {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
  width: max-content;
  user-select: none;
  -webkit-user-select: none;
  /* No `transition` here — wireDrawerCarousel() writes a fresh translateX
     every animation frame, and transitioning a value that already changes
     every frame would just fight the JS with stale easing. */
}

/* Every item, including the last one of each half, carries its own
   trailing margin instead of the track using `gap`. wireDrawerCarousel()'s
   wrap-around assumes both halves of the duplicated item list (see the
   render function) are exactly the same width; `gap` would insert one
   extra gap at the seam between the two halves that a per-item margin
   doesn't, throwing the loop off by half a gap at the wrap point. */
.drawer-carousel-item {
  flex-shrink: 0;
  margin-right: 16px;
}

.drawer-carousel-img {
  display: block;
  height: 325px; /* 260px + 25%, per James's ask */
  width: auto;
  border-radius: 12px;
  border: 1px solid var(--border);
  -webkit-user-drag: none;
}

@media (max-width: 640px) {
  .drawer-carousel-img {
    height: 225px; /* 180px + 25% */
  }
}

/* Closing beat at the end of every case study — fixed template chrome,
   not per-project block data (see renderProject(), js/main.js), since
   "thanks for reading" and where to go next don't vary case study to
   case study. Sibling of .drawer-blocks, not a block inside it, so it's
   centered and capped at the standard --drawer-measure like the title/
   meta/lead rather than breaking out full width — this is closing
   chrome, not a figure. .drawer-blocks has no margin-bottom of its own
   (only internal `gap`), so this element's margin-top applies cleanly
   with nothing to collapse against — unlike .drawer-live-link above,
   which had to work around exactly that collapse. */
.drawer-footer {
  max-width: var(--drawer-measure);
  margin: var(--sp-16) auto 0;
  padding-top: var(--sp-8);
  border-top: 1px solid var(--border);
  text-align: center;
}

.drawer-footer-thanks {
  font-size: var(--fs-lg);
  color: var(--text-secondary);
  margin: 0 0 var(--sp-4);
}

.drawer-footer-links {
  display: flex;
  justify-content: center;
  gap: var(--sp-6);
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
}

/* Back is a <button> (no real destination — it's an action) and Contact
   is an <a href="#contact"> (a real destination, JS-enhanced to also
   close the drawer and scroll smoothly rather than jump — see the click
   handler in js/main.js). One shared class so they read as one matched
   pair despite being different elements underneath; `font: inherit` and
   the background/border reset are here specifically so the <button>
   stops looking like a form control and matches the <a> next to it. */
.drawer-footer-link {
  background: none;
  border: none;
  padding: 0 0 2px;
  font: inherit;
  color: var(--text-secondary);
  cursor: pointer;
  border-bottom: 1px solid transparent;
  transition: color 0.2s var(--ease), border-color 0.2s var(--ease);
}

.drawer-footer-link:hover {
  color: var(--text);
  border-bottom-color: var(--border-strong);
}

/* =============================================================================
   SPLIT LAYOUT — Experiments drawer entries (`layout: 'split'`, js/main.js)

   Claude Design handoff "4a" (desktop) / "4b" (375px), 2026-09-28, first
   used by Big Fish. A lighter layout than the featured case studies' title-
   left/content-right sections: the cover is left-aligned, and each section
   is one row with its media stacked on the left and its heading + paragraph
   in a 400px column on the right. That text column is `position: sticky`,
   so it holds still while the row's media scrolls past and releases at the
   next row (the grid is `align-items: start`, so the text box is only as
   tall as its content and has the whole row to travel in).

   This deliberately replaces the "one layout for every tier" rule recorded
   under MINOR TIER above, for entries that opt in with `layout: 'split'`.
   Maricopa and Epiq don't, and are unchanged.

   Below 1024px the rows stack, text then media (James, 2026-09-28: at
   1024 the media column is ~480px, and narrower than that the fixed 400px
   text column starts starving it). The type drops to the 4b sizes at the
   site's usual 640px breakpoint. The drawer shell itself (bar, padding,
   close button) is shared with every other entry and is not touched.

   Diagram internals respond to the MEDIA COLUMN's width, not the viewport,
   via @container split-media: the wide nibble loop needs the full 656px
   column, and the compact priority chain/traits suit anything under 520px,
   and both of those happen inside the desktop layout as well as on phones.
   Container-query conditions can't read custom properties, so those two
   thresholds are literals below, not tokens.
   ============================================================================= */

.drawer-content-inner--split {
  /* --- Knobs -------------------------------------------------------------- */
  /* Right-hand text column. The media column takes the rest: 656px at the
     full 1120px --content-w, which is exactly what the wide nibble loop
     needs (590px canvas + 2 x 32px figure padding + 2 x 1px border). Widen
     this and that diagram drops to its tall version on desktop too. */
  --split-text-w:     400px;
  --split-col-gap:    var(--sp-16);   /* 64px, media to text */
  --split-stack-gap:  56px;           /* cover, each row, footer */
  --split-row-pad:    var(--sp-8);    /* hairline to row content */
  --split-media-gap:  var(--sp-4);    /* between stacked media items */
  --split-text-gap:   var(--sp-4);    /* heading to paragraph */
  --split-cover-gap:  var(--sp-4);    /* title to meta row */
  /* Meta row to the first row's hairline. --sp-8 (32px) on purpose: it's
     .drawer-seam's margin-top, the same space the featured case studies
     leave between their meta row and their divider (James, 2026-09-29:
     at the 56px stack gap the header read as sitting too high). */
  --split-cover-below: var(--sp-8);
  /* Where the sticky text pins. Sticky offsets are measured inside the
     scroll container's padding, so 0 (the handoff's value) pins it 48px
     under the grab-bar rule, at .drawer-content's padding-top, not flush
     against it. Negative values pull it up toward the bar. */
  --split-sticky-top: 0px;

  display: flex;
  flex-direction: column;
  gap: var(--split-stack-gap);
}

.drawer-split-cover {
  display: flex;
  flex-direction: column;
  gap: var(--split-cover-gap);
  /* The parent's flex gap (--split-stack-gap) still applies below this, so
     the margin is the difference: it nets the space to --split-cover-below
     at every width, including stacked, where the stack gap drops to 28px
     and this goes positive. */
  margin-bottom: calc(var(--split-cover-below) - var(--split-stack-gap));
}

/* Left-aligned, where the featured tier centers both. Size and weight are
   .drawer-title's own (--fs-3xl, 700, -0.02em), unchanged. */
.drawer-content-inner--split .drawer-title {
  max-width: none;
  margin: 0;
  text-align: left;
  line-height: 1.1;
}

.drawer-content-inner--split .drawer-meta {
  justify-content: flex-start;
  max-width: none;
  margin: 0;
}

.drawer-split-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, var(--split-text-w));
  column-gap: var(--split-col-gap);
  align-items: start;
  border-top: 1px solid var(--border);
  padding-top: var(--split-row-pad);
}

/* Text is first in the DOM (see renderSplitSection(), js/main.js), so both
   are placed explicitly into row 1. The heading and paragraph reuse
   .drawer-section-title and .drawer-paragraph as they are. */
.drawer-split-text {
  grid-column: 2;
  grid-row: 1;
  position: sticky;
  top: var(--split-sticky-top);
  display: flex;
  flex-direction: column;
  gap: var(--split-text-gap);
}

.drawer-split-media {
  grid-column: 1;
  grid-row: 1;
  display: grid;
  gap: var(--split-media-gap);
  min-width: 0;
  container: split-media / inline-size;
}

/* Video: the standard .drawer-figure-video (radius, border, top-anchored
   cover crop) plus a fixed 16:9 box and the elevated fill behind it while
   it loads. Caption is the site's glass chip, .drawer-figure-caption, which
   positions itself against this wrapper. */
.drawer-split-video {
  position: relative;
  margin: 0;
  /* For the hover growth, which lives with .drawer-figure's (the
     (hover: hover) block after .drawer-figure-placeholder). Same 0.3s and
     curve as .drawer-figure's own transition. */
  transition: transform 0.3s var(--ease);
}

.drawer-split-video .drawer-figure-video {
  aspect-ratio: 16 / 9;
  background: var(--bg-elevated);
  /* .drawer-figure-video sets pointer for click-to-grow, which this layout
     doesn't have. */
  cursor: default;
}

/* Footage that doesn't exist yet. Shown to visitors by James's call. */
.drawer-split-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 16 / 9;
  border: 1px dashed var(--border-strong);
  border-radius: 12px;
  font-size: var(--fs-xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* The diagram card. Unlike the featured tier's diagrams (bare on --bg, per
   the old "no card wrapper" rule), these sit in a hairline card, which is
   what makes them read as figures in a narrower column. */
.drawer-split-figure {
  /* Knobs for all three Big Fish diagrams. --bf-line-color is every
     connector, chevron and loop-back line (James asked that all diagram
     arrows match). */
  --bf-fig-pad:     32px;
  --bf-line-color:  var(--text-secondary);
  --bf-nudge-dur:   2.4s;
  --bf-nudge-dist:  3px;

  margin: 0;
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  padding: var(--bf-fig-pad);
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--bg);
}

.drawer-split-figcaption {
  margin: 0;
  font-size: var(--fs-sm);
  line-height: 1.6;
  color: var(--text-secondary);
}

/* The footer reuses .drawer-footer/.drawer-footer-links, left-aligned and
   without the featured tier's 64px lead-in (the stack gap spaces it). The
   links are still mono: .drawer-footer-links is on the mono-retirement
   list (STYLEGUIDE.md §1) and that swap goes through James separately. */
.drawer-content-inner--split .drawer-footer {
  max-width: none;
  margin: 0;
  padding-top: var(--sp-6);
  text-align: left;
}

.drawer-content-inner--split .drawer-footer-links {
  justify-content: flex-start;
}

/* ---- Shared diagram pieces ------------------------------------------------
   Straight 1px lines and open chevrons, drawn with CSS the way the handoff
   drew them. Geometry (left/top/width/height) is inline in each diagram's
   markup in js/main.js, the same way an SVG carries its own coordinates;
   everything visual is here. A .bf-line sets only the dimension it runs
   along, and this 1px default supplies the other. */
.bf-line {
  position: absolute;
  width: 1px;
  height: 1px;
  background: var(--bf-line-color);
}

/* A 7x7 box with two borders, turned 45deg: an open arrowhead. The
   modifier picks which two borders, i.e. which way it points. */
.bf-chev {
  position: absolute;
  box-sizing: border-box;
  width: 7px;
  height: 7px;
  border: 0 solid var(--bf-line-color);
  transform: rotate(45deg);
}

.bf-chev--right { border-top-width: 1px; border-right-width: 1px; }
.bf-chev--down  { border-right-width: 1px; border-bottom-width: 1px; }
.bf-chev--left  { border-bottom-width: 1px; border-left-width: 1px; }

/* Diagram A's two notes lead with this: Tabler arrow-right, nudging. */
.bf-nudge {
  display: block;
  flex-shrink: 0;
  width: 14px;
  height: 14px;
  background: var(--icon-arrow-right) center / contain no-repeat;
  opacity: 0.7;
  animation: bf-nudge var(--bf-nudge-dur) ease-in-out infinite;
}

@keyframes bf-nudge {
  0%, 60%, 100% { transform: translateX(0); }
  30%           { transform: translateX(var(--bf-nudge-dist)); }
}

@media (prefers-reduced-motion: reduce) {
  .bf-nudge {
    animation: none;
  }
}

/* ---- Diagram A: priority chain -------------------------------------------- */
.bf-chain {
  display: grid;
  grid-template-columns: 28px minmax(0, 1fr);
  column-gap: var(--sp-4);
}

.bf-chain-end {
  grid-column: 1 / -1;
  font-size: var(--fs-xs);
  color: var(--text-muted);
}

.bf-chain-end--first { grid-row: 1; padding-bottom: 10px; }
.bf-chain-end--last  { grid-row: 3; padding-top: 10px; }

/* The left track: a line the full height of the list, ending in a down
   chevron. Stops 4px short so the chevron's tip lands just past its end. */
.bf-chain-track {
  grid-column: 1;
  grid-row: 2;
  position: relative;
}

.bf-chain-track::before {
  content: '';
  position: absolute;
  left: 13px;
  top: 0;
  bottom: 4px;
  width: 1px;
  background: var(--bf-line-color);
}

.bf-chain-track .bf-chev {
  left: 10px;
  bottom: 4px;
}

.bf-chain-list {
  grid-column: 2;
  grid-row: 2;
  list-style: none;
  margin: 0;
  padding: 0;
}

.bf-chain-row {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  min-height: 48px;
  border-bottom: 1px solid var(--border);
}

.bf-chain-row:last-child {
  border-bottom: 0;
}

.bf-chain-name {
  display: flex;
  align-items: center;
  gap: 14px;
  font-size: var(--fs-lg);
  color: var(--text-secondary);
}

/* Rows 2 and 6, the two the prose leans on. */
.bf-chain-row--em .bf-chain-name {
  color: var(--text);
  font-weight: 500;
}

.bf-chain-num {
  flex-shrink: 0;
  width: 12px;
  font-size: 13px;
  font-weight: 400;
  color: var(--text-muted);
}

.bf-chain-note {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}

/* ---- Diagram B: traits ----------------------------------------------------- */
.bf-traits {
  display: flex;
  flex-direction: column;
  gap: var(--sp-8);
}

.bf-trait {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

.bf-trait-name {
  font-size: var(--fs-lg);
  font-weight: 500;
  color: var(--text);
}

/* 16px tall so the large dot fits; the 1px axis sits at its center. Every
   dot's `left` is inline (js/main.js). */
.bf-trait-track {
  position: relative;
  display: block;
  height: 16px;
}

.bf-trait-track::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 7px;
  height: 1px;
  background: var(--text-muted);
}

.bf-trait-dot {
  position: absolute;
  top: 3px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--text-muted);
}

.bf-trait-dot--this {
  top: 0;
  width: 16px;
  height: 16px;
  background: var(--text);
}

.bf-trait-poles {
  display: flex;
  justify-content: space-between;
  gap: var(--sp-4);
  font-size: var(--fs-sm);
  line-height: 1.4;
  color: var(--text-secondary);
}

.bf-trait-poles > span {
  max-width: 48%;
}

.bf-trait-poles > span:last-child {
  text-align: right;
}

.bf-traits-legend {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2) 10px;
  font-size: 13px;
  color: var(--text-secondary);
}

.bf-traits-legend .bf-trait-dot {
  position: static;
  flex-shrink: 0;
}

.bf-trait-dot--legend-other {
  margin-left: var(--sp-3);
}

/* ---- Diagram C: nibble loop -------------------------------------------------
   Two versions in the markup, one displayed (see the swap below).

   Wide: the handoff's fixed 590px canvas. Every box, line and chevron is
   absolutely placed by inline coordinates (js/main.js).

   Tall: a grid, not the handoff's fixed 285px canvas, so the outcome cards
   can grow when their one-liner wraps (it does at the site's 32px phone
   gutter). Three columns: the spine lane, the boxes, and the loop-back
   lane. The rows reproduce the handoff's vertical rhythm: 44px steps with
   24px drops between, a 16px gap after Roll and 10px between outcomes.
   Back off spans two rows, a fixed 32px one and the rest, so the end of
   that 32px row is a known point on the card that the spine and the
   loop-back can both run to, however tall the card gets.

   In both, the footnote is in normal flow under the diagram, so a footnote
   that wraps grows the figure instead of spilling over the caption. */
.bf-loop {
  position: relative;
}

.bf-loop--wide {
  width: 590px;
  padding-top: 212px;
}

.bf-loop--wide > * {
  position: absolute;
}

.bf-loop--tall {
  display: none;
}

.bf-loop-step {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 150px;
  height: 52px;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: 10px;
  font-size: var(--fs-lg);
  font-weight: 500;
  color: var(--text);
}

.bf-loop-outcome {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 2px;
  width: 178px;
  height: 84px;
  padding: 0 var(--sp-4);
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: 10px;
}

/* Bite: the outcome the player is waiting for. */
.bf-loop-outcome--em {
  background: none;
  border-color: var(--bf-line-color);
}

.bf-loop-outcome-name {
  font-size: var(--fs-lg);
  font-weight: 500;
  line-height: 1.3;
  color: var(--text);
}

.bf-loop-outcome-text {
  font-size: var(--fs-sm);
  line-height: 1.4;
  color: var(--text-secondary);
}

/* The one curve: a bracket from Back off round to Watch. */
.bf-loop-back {
  border: 1px solid var(--bf-line-color);
}

.bf-loop-back--left {
  border-right: 0;
  border-radius: 10px 0 0 10px;
}

.bf-loop-back--right {
  border-left: 0;
  border-radius: 0 10px 10px 0;
}

.bf-loop-foot {
  display: block;
  font-size: var(--fs-sm);
  line-height: 1.5;
  color: var(--text-secondary);
}

.bf-loop--wide > .bf-loop-foot {
  position: static;
  margin-left: 32px;
}

/* -- Tall version -- */
.bf-loop-grid {
  position: relative;
  display: grid;
  grid-template-columns: 24px minmax(0, 1fr) 24px;
  grid-template-rows: 44px 24px 44px 24px 44px 16px auto 10px auto 10px 32px auto;
}

.bf-loop--tall .bf-loop-step {
  width: auto;
  height: auto;
}

/* A drop between steps: a 22px line down the middle, chevron at its end. */
.bf-loop-drop {
  position: relative;
}

.bf-loop-drop::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 0;
  width: 1px;
  height: 22px;
  background: var(--bf-line-color);
}

.bf-loop-drop .bf-chev {
  left: calc(50% - 3px);
  top: 15px;
}

/* From under Roll down to Back off's 32px mark (the grid-area in the
   markup ends it there). */
.bf-loop-spine {
  justify-self: start;
  width: 1px;
  margin-left: 12px;
  background: var(--bf-line-color);
}

.bf-loop--tall .bf-loop-outcome {
  position: relative;
  width: auto;
  height: auto;
  min-height: 64px;
  /* 8px, not more: a one-line card's content is 45px, and at 8 + 45 + 8 +
     2px border it stays under 64, so min-height decides and the 32px mark
     is its exact middle. The padding only matters once the text wraps. */
  padding: var(--sp-2) var(--sp-4);
}

/* The short arrow from the spine into each card, at its 32px mark. Offsets
   are from the card's padding box, hence the 1px border in each number:
   the spine sits 13px left of it, and top: 30px puts this line on the
   pixel row just above the 32px mark (card top + 31), the same row the
   spine's last pixel and the loop-back's bottom edge land on. */
.bf-loop-feed {
  position: absolute;
  left: -13px;
  top: 30px;
  width: 11px;
  height: 1px;
  background: var(--bf-line-color);
}

.bf-loop-feed .bf-chev {
  right: 0;
  top: -3px;
}

/* Loop-back up the right lane: from Back off's 32px mark (the end of its
   grid-area) to the middle of Watch, 22px down. */
.bf-loop--tall .bf-loop-back {
  justify-self: start;
  width: 12px;
  margin-top: 22px;
}

/* The left-pointing head where the loop-back enters Watch. Absolutely
   placed inside its own grid-area (row 1, the right lane). */
.bf-loop-grid > .bf-chev {
  position: absolute;
}

.bf-loop--tall > .bf-loop-foot {
  margin-top: 12px;
  font-size: 13px;
}

/* ---- Width-driven swaps ------------------------------------------------------
   The wide loop needs 656px of media column (see --split-text-w). */
@container split-media (max-width: 655px) {
  .bf-loop--wide { display: none; }
  .bf-loop--tall { display: block; }
}

/* Compact diagrams (the handoff's 4b values). 520px is a judgment call: the
   priority chain's longest row ("Holding at structure" plus its note) is
   ~360px, and this keeps a margin over that. */
@container split-media (max-width: 519px) {
  .drawer-split-figure {
    --bf-fig-pad: 20px;
  }

  .bf-chain {
    grid-template-columns: 20px minmax(0, 1fr);
    column-gap: var(--sp-3);
  }

  .bf-chain-track::before { left: 9px; }
  .bf-chain-track .bf-chev { left: 6px; }

  /* The note wraps under the name instead of sitting right-aligned. */
  .bf-chain-row {
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: var(--sp-1);
    min-height: 44px;
    padding: 10px 0;
  }

  .bf-chain-name { font-size: var(--fs-base); }
  .bf-chain-note { margin-left: 0; }

  .bf-traits { gap: var(--sp-6); }
  .bf-trait-name { font-size: var(--fs-base); }
  .bf-trait-poles { font-size: 13px; }
}

/* Stacked rows: text, then media. Spacing tightens to the 4b values. */
@media (max-width: 1023px) {
  .drawer-content-inner--split {
    --split-stack-gap: 28px;
    --split-row-pad:   28px;
  }

  /* align-items: stretch is load-bearing. The desktop grid's `start` would
     carry over into this flex column and shrink-wrap both children, and
     .drawer-split-media, being an inline-size container, can't take a width
     from its contents: it collapsed to 0px wide at 375px before this. */
  .drawer-split-row {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--sp-4);
  }

  .drawer-split-text {
    position: static;
  }
}

/* Phone type. The title already drops to 32px here through --fs-3xl. */
@media (max-width: 640px) {
  .drawer-content-inner--split {
    --split-cover-gap: var(--sp-3);
  }

  .drawer-content-inner--split .drawer-meta {
    gap: var(--sp-2) 20px;
  }

  .drawer-content-inner--split .drawer-section-title {
    font-size: var(--fs-xl);
  }
}

/* Body scroll lock while a drawer is open. Toggled on <body> by JS. */
body.drawer-open {
  overflow: hidden;
}

/* =============================================================================
   OTHER PROJECTS — lightweight rows

   James's Figma comp (2026-09-04), option A of three mocked against this
   stylesheet before any of it landed here. A row is a thumbnail, a title and
   a tag pill, on the same hairlines the old text rows used.

   Replaces a three-column text row (name / description / year, five of them).
   The description and the year are both gone from the design, which retires
   `.project-year` — the last mono element left in this section
   (STYLEGUIDE.md §1). Retired by deletion rather than by a font swap, which
   is why it's called out here: nothing is left to move off --font-mono.

   The row is built on a column grid rather than on offsets measured off the
   comp, at James's request ("use our grids so placement looks intentional").
   Four columns at --sp-8 across --content-w is the two-column .work-list grid
   subdivided in half: same outer edges, same gutter, and — measured in the
   browser, not assumed — all four of the cards' own edges are also column
   edges here (0, 544, 576, 1120). So the row cannot land off-grid relative to
   the cards; the subdivision only adds lines at 256/288 for the thumbnail's
   right edge and the title's left, which the cards themselves don't use. The
   comp's own numbers — 283px thumb, title 74px after it, pill inset 40px from
   the rule — were measured off a raster screenshot and land on none of those
   lines; option B in the mock round reproduced them exactly and James chose
   this instead. One trade worth knowing: the pill is flush to the content
   edge here, where the comp insets it.
   ============================================================================= */

:root {
  /* Row geometry — one place to retune every project row.

     --pr-cols x --pr-gap IS the grid described above. Changing either moves
     the thumbnail and the title together and keeps them on whatever new
     lines result, which is the property worth preserving. Matching --pr-gap
     to .work-list's own gap is what puts the two on shared verticals, so
     they're worth changing together if either moves. */
  --pr-cols:          4;
  --pr-gap:           var(--sp-8);

  /* Which gridline the title + pill block starts on. 2 puts it immediately
     after the thumbnail's column, per the comp. 3 pushes it out to the second
     work card's left edge (option C of the mock round) if it ever wants air. */
  --pr-content-start: 2;

  --pr-row-pad-y:     var(--sp-8);

  /* 16:10 matches .work-thumb, so every image frame on the homepage shares one
     aspect (James, 2026-09-04, choosing it over the comp's 16:9). At a 256px
     column that computes to 160px tall, which is also what the comp measures —
     the row height was already right, the width just snaps to a real column. */
  --pr-thumb-ratio:   16 / 10;
  /* Not derived from a card radius the way --wc-thumb-radius is: this frame
     isn't nested inside a padded card, so there's no concentricity to hold.
     8px, the site's standard tile radius. */
  --pr-thumb-radius:  8px;

  /* Same size and weight as .section-title, per the comp — these titles read
     as headings rather than as card labels. Note the weight is 600, NOT the
     700 .work-title uses: James asked for the section-heading weight
     specifically, so the two are deliberately different. */
  --pr-title-fs:      var(--fs-section-heading);
  --pr-title-weight:  600;

  /* Hover: the image zooms inside its own frame and the pill brightens.
     Deliberately NOT --wc-hover-scale applied to the row itself the way a work
     card scales — a 1120px-wide row growing 2% shoves against the hairlines
     sitting directly above and below it. */
  --pr-hover-scale:   1.04;
}

/* The list's outer edges carry no line (James, 2026-09-23): no hairline above
   the first row, none below the last. The hairlines BETWEEN rows stay. Both
   outer lines are transparent rather than removed, same reason as the
   section dividers: the 1px boxes keep every row exactly where it was. */
.project-list {
  border-top: 1px solid transparent;
}

/* The <li> carries nothing; .project-row (a <button>) is the whole row, so
   the hairline and the hover target are the same box. */
.project-list li {
  display: block;
}

/* A <button>, not list text: the rows are clickable (James, 2026-09-04).
   font/color/background reset for the same reason .work-card does it, and
   padding set explicitly on all four edges because Chrome's UA stylesheet
   otherwise leaves its own `padding: 1px 6px` behind — the exact asymmetry
   .work-card's comment records running into. */
.project-row {
  display: grid;
  grid-template-columns: repeat(var(--pr-cols), 1fr);
  gap: var(--pr-gap);
  align-items: center;
  width: 100%;
  padding: var(--pr-row-pad-y) 0;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--border);
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

/* Last row's bottom hairline — see .project-list above. */
.project-list li:last-child .project-row {
  border-bottom-color: transparent;
}

/* No padding above the first row: that padding is breathing room between
   hairlines, and the first row has no hairline above it. Kept, it stacked
   32px on top of --section-lead-gap and left Experiments' first thumbnail
   56px under its subheader against Work's 24px (measured, 2026-09-29). */
.project-list li:first-child .project-row {
  padding-top: 0;
}

.project-thumb {
  grid-column: 1;
  aspect-ratio: var(--pr-thumb-ratio);
  border-radius: var(--pr-thumb-radius);
  overflow: hidden;
  /* The same placeholder fill an empty .work-thumb gets. The comp's flat grey
     read as a Figma placeholder rectangle rather than a decision, and James
     confirmed against both in the mock round. Static for now: no video here,
     unlike .work-thumb. */
  background: linear-gradient(135deg, #1a1a1a, #0f0f0f);
}

/* A separate inner element purely so the hover zoom happens INSIDE the frame.
   Scaling .project-thumb itself would push the image past the hairlines above
   and below rather than growing within its own box. */
.project-thumb-inner {
  /* It's a <span>, so without this it stays display: inline, ignores both
     dimensions below and measures 0x0 — which is what it did from the day it
     was written until 2026-09-07. Nothing revealed it while the element was
     empty and .project-thumb carried the visible gradient itself, but it also
     meant the hover zoom this element exists for scaled a box with no size and
     did nothing at all. Adding real content inside it is what surfaced both. */
  display: block;
  width: 100%;
  height: 100%;
  background: inherit;
  transition: transform 0.4s var(--ease);
  /* Containing block for .project-thumb-video below. */
  position: relative;
}

/* Same job as .work-thumb-video on the featured card, minus the blur — James
   asked for these unblurred (2026-09-07), which is also why the clip behind it
   is encoded at the pipeline's normal settings instead of the much harsher crf
   the blurred card thumbnail can hide (STYLEGUIDE.md §11).

   No `transform: scale(1.02)` either, for the same reason: that exists on the
   card purely to push a blur's soft edge outside the frame, and with no blur
   there is no soft edge to hide. The hover zoom still works — it lives on
   .project-thumb-inner, this element's parent, so it scales the video with it.

   object-position: top matches the crop anchoring used everywhere else on the
   site (see .drawer-figure-img/.drawer-figure-video and the card thumb's own
   note in STYLEGUIDE.md §11): the 16:9 source is taller than this 16:10 box,
   and there is more happening near the top of the frame than the bottom. */
.project-thumb-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top;
}

.project-body {
  grid-column: var(--pr-content-start) / -1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* Wraps so a long title drops the pill to its own line instead of crushing
     it; the pill's flex-shrink: 0 below is the other half of that. */
  flex-wrap: wrap;
  gap: var(--sp-6);
}

.project-title {
  font-size: var(--pr-title-fs);
  font-weight: var(--pr-title-weight);
  color: var(--text);
  /* Matches .section-title's tracking as well as its size and weight. */
  letter-spacing: -0.01em;
  line-height: 1.25;
}

/* The same pill as .work-tag, read through the same --wc-tag-* tokens rather
   than redeclared with its own numbers, so the homepage's two pill surfaces
   can't drift apart. A shared .tag-pill recipe both classes extend is the
   obvious next step and is deliberately not taken here — it would restyle the
   work cards, which this pass wasn't asked to touch. */
.project-tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--wc-tag-gap);
  flex-shrink: 0;
}

.project-tag {
  color: var(--text-secondary);
  font-size: var(--wc-tag-fs);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  padding: var(--wc-tag-pad-y) var(--wc-tag-pad-x);
  line-height: 1.4;
  transition: color 0.2s var(--ease), border-color 0.2s var(--ease);
}

/* Behind @media (hover: hover) like every other hover rule on the site —
   without it a touch device that fakes :hover leaves a row's thumbnail stuck
   mid-zoom after a tap. */
@media (hover: hover) {
  .project-row:hover .project-thumb-inner {
    transform: scale(var(--pr-hover-scale));
  }

  .project-row:hover .project-tag {
    color: var(--text);
    border-color: var(--text-muted);
  }
}

@media (hover: hover) and (prefers-reduced-motion: reduce) {
  .project-row:hover .project-thumb-inner {
    transform: none;
  }
}

/* -----------------------------------------------------------------------------
   In-progress rows (.project-row--wip) — Claude Design handoff, option 4a,
   2026-09-25

   Rows two and three are unfinished projects. Instead of named placeholders
   they read as "in progress": a blurred blob for a thumbnail, the title
   "In progress" blurred past legibility, and a small spinner where the tag
   pill sits. Hover (or focus) brings the words into focus by crossfading a
   sharp copy over the blurred one. No project names are revealed and there is
   no click action: the row is a <div tabindex="0">, not a <button>, and
   carries no data-project, so initWorkDrawer() never binds it.

   Same grid, padding and hairline as every other .project-row; only the
   contents differ. Pure CSS, no JS state.
   -------------------------------------------------------------------------- */

:root {
  /* How far past legibility the resting title sits. 9px keeps the word shape
     readable as "a title" without the letters resolving. */
  --pr-wip-title-blur:   9px;
  /* The thumbnail blob's softness. The blob layer is oversized by
     --pr-wip-blob-bleed on every side so the blur's faded edge falls outside
     the frame instead of showing as a dark rim inside it. */
  --pr-wip-thumb-blur:   18px;
  --pr-wip-blob-bleed:   -20%;
  --pr-wip-thumb-bg:     #111;
  /* The two blob layouts, one per row. Colors are local to this component. */
  --pr-wip-blob-a:       radial-gradient(circle at 35% 45%, #3a3a3a, transparent 45%),
                         radial-gradient(circle at 70% 60%, #262626, transparent 40%);
  --pr-wip-blob-b:       radial-gradient(circle at 60% 40%, #3a3a3a, transparent 45%),
                         radial-gradient(circle at 30% 70%, #262626, transparent 40%);
  /* Blurred-to-sharp crossfade. */
  --pr-wip-fade:         0.35s;
  /* Spinner: size, one full turn, and the second row's head start (half a
     turn at 1.4s) so the two rows never spin in lockstep. */
  --pr-wip-spinner-size: 16px;
  --pr-wip-spin:         1.4s;
  --pr-wip-spin-offset:  -0.7s;
}

.project-row--wip {
  cursor: default;
}

.project-thumb--wip {
  position: relative;
  background: var(--pr-wip-thumb-bg);
}

/* The blob rides the existing inner element rather than a ::before, which
   would sit underneath it. Static: it does not zoom on hover the way a real
   row's thumbnail does (see the hover block below). */
.project-thumb--wip .project-thumb-inner {
  position: absolute;
  inset: var(--pr-wip-blob-bleed);
  width: auto;
  height: auto;
  background: var(--pr-wip-blob-a);
  filter: blur(var(--pr-wip-thumb-blur));
  transition: none;
}

.project-row--wip-alt .project-thumb-inner {
  background: var(--pr-wip-blob-b);
}

/* The blurred copy stays in flow and sizes the box; the sharp copy is laid
   over it, vertically centered. min-height is one line of the 30px title at
   its 1.25 line-height, so the row can't collapse. */
.project-wip-title {
  position: relative;
  display: block;
  flex: 1;
  min-height: 38px;
}

.project-wip-blur,
.project-wip-sharp {
  transition: opacity var(--pr-wip-fade) ease;
}

.project-wip-blur {
  display: block;
  filter: blur(var(--pr-wip-title-blur));
  user-select: none;
  opacity: 1;
}

.project-wip-sharp {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  white-space: nowrap;
  opacity: 0;
}

/* Stands in for the tag pill: a ring in the pill's border color with one
   lighter arc. No text; the row's aria-label covers screen readers. The
   margin-right tucks it in slightly from the content edge. */
.project-wip-spinner {
  width: var(--pr-wip-spinner-size);
  height: var(--pr-wip-spinner-size);
  box-sizing: border-box;
  border-radius: 50%;
  border: 2px solid var(--border-strong);
  border-top-color: var(--text-secondary);
  margin-right: 4px;
  flex-shrink: 0;
  animation: wip-spin var(--pr-wip-spin) linear infinite;
}

.project-row--wip-alt .project-wip-spinner {
  animation-delay: var(--pr-wip-spin-offset);
}

@keyframes wip-spin {
  to { transform: rotate(360deg); }
}

/* The reveal. Keyboard focus gets it everywhere. On a hover-capable device a
   plain :focus (i.e. a mouse click) is left out, or clicking a row would pin
   it sharp until something else took focus. */
.project-row--wip:focus-visible .project-wip-blur {
  opacity: 0;
}

.project-row--wip:focus-visible .project-wip-sharp {
  opacity: 1;
}

@media (hover: hover) {
  .project-row--wip:hover .project-wip-blur {
    opacity: 0;
  }

  .project-row--wip:hover .project-wip-sharp {
    opacity: 1;
  }

  /* Cancels the real rows' thumbnail zoom. Same specificity as that rule,
     so this one wins on source order. */
  .project-row--wip:hover .project-thumb-inner {
    transform: none;
  }
}

/* Touch has no hover, so a tap focuses the row (tabindex="0") and that
   focus does the reveal. Tapping elsewhere blurs it again. */
@media (hover: none) {
  .project-row--wip:focus .project-wip-blur {
    opacity: 0;
  }

  .project-row--wip:focus .project-wip-sharp {
    opacity: 1;
  }
}

/* The static ring, with its lighter top arc, still reads as a spinner. The
   crossfade is opacity only and stays. */
@media (prefers-reduced-motion: reduce) {
  .project-wip-spinner {
    animation: none;
  }
}


/* =============================================================================
   ABOUT

   Restructured 2026-09-14 from a simple two-column (portrait | prose) grid
   into a top row plus a full-width text column below it, matching James's
   own layout reference: .about-top (portrait + two media placeholders) sits
   above .about-body (three numbered sections, full section width, no longer
   the narrow 60ch column it used to be — it now also holds .about-cards,
   which per its own spec wants the full --content-w, not a prose measure).

   The portrait is an interactive Unicorn Studio scene (initAboutPhoto(),
   js/main.js) — a hover-driven pixelation/RGB-split glitch with a
   water-ripple wake, over James's own photo (self-hosted,
   assets/images/about-me.png) — behind a plain <img> of that same file,
   which is what actually ships to anyone without a real hover-capable
   pointer, or under prefers-reduced-motion, or if JS never runs. See
   _about/ABOUT-NOTES.md §8 for that decision's full record.
   ============================================================================= */

:root {
  /* -- About photo geometry --------------------------------------------------
     --about-photo-ratio is not a guess: it's the exported scene's own image
     layer aspect ratio (assets/interactive/about-portrait.json, 957x1190
     natural size). Unchanged by the panels handoff below — the scene stays
     pinned at this ratio regardless of what surrounds it. Sizing history
     (clamp(200px,26vw,320px) originally, through the 2026-09-15 growth to
     470px) now lives with --about-panel-photo-w below, which replaces the
     old --about-photo-w token. */
  --about-photo-ratio: 0.8042016806722688;

  /* -- About panels (replaces .about-media, 2026-09-16 handoff) -----------
     One horizontal band: the pinned portrait plus three hover-to-expand
     photo panels. Full design record: _about/ABOUT-NOTES.md §9.

     --about-panel-photo-w replaces --about-photo-w. Its three clamp stops
     are the old token's own (294px/38vw/470px) scaled by one factor, 0.787,
     so the responsive curve keeps its shape at the portrait's new, smaller
     resting width — the same one-factor-across-all-three-stops methodology
     every previous resize of this photo used (2026-09-11, 2026-09-15). The
     portrait shrinking is a deliberate tradeoff, not a regression: letting
     it flex would resize the WebGL canvas every frame of the panels' 550ms
     expand, and dpi: 1.5 already covers sharpness at the smaller size. */
  --about-panel-photo-w: clamp(232px, 30vw, 370px);
  /* One token for both the portrait-to-band gap and panel-to-panel gap —
     replaces --about-gutter, which existed for exactly this reason (2026-
     09-15) and is retired now that both its consumers are gone. */
  --about-panel-gap: var(--sp-3);
  /* How much a panel takes once hovered, against the other two (which sit
     at their unhovered flex-grow: 1 base). At --content-w (1120px): ~389px
     hovered vs ~162px each for the other two. All three panels rest equal
     with no hover in play — see .about-panel below, 2026-09-17. */
  --about-panel-grow: 2.4;
  /* Mobile accordion's total height. */
  --about-panel-m-h: 380px;
  /* Mobile grow factor — higher than desktop's 2.4, since a 3-item vertical
     stack has less room to give per panel. */
  --about-panel-m-grow: 3;

  /* -- About ruled columns (the 02 section's "how I work" row) ------------
     Off the --sp-* scale on purpose, per the handoff's own explicit
     instruction (2026-09-16): "do not round these up to 16px; the tight
     rule-to-headline distance is the point of the revision." Real
     component tokens rather than bare literals, so they're named and
     findable the way every other tunable value on this site is. */
  --about-rule-pad: 0.75rem; /* 12px, top rule to headline */
  --about-rule-gap: 0.5rem;  /*  8px, headline to body copy */

  /* -- About rows (replaces .about-section, 2026-09-17 handoff) -----------
     Hover/tap-to-open copy rows. Full design record: _about/ABOUT-NOTES.md
     §9. All seven values are the handoff's own defaults, high-fidelity —
     nothing here was tuned after the fact. */
  --about-row-label-w: 150px;   /* left label rail — the measured floor that
                                    keeps "03 / Wired This Way" on one line
                                    at the current &nbsp; spacing; 0px at
                                    ≤640px, where the label moves above the
                                    headline instead. */
  --about-row-gap: var(--sp-8); /* label rail -> content gutter (32px).
                                    Deliberately not the drawer's own
                                    --section-gap, which STYLEGUIDE.md §6
                                    marks as a site standard, not something
                                    to retune per component. */
  --about-row-pad: 1.75rem;     /* 28px, each row's own top/bottom padding
                                    — closed rows get air without needing a
                                    gap on .about-rows itself. */
  --about-row-headline-fs: 30px; /* Same number as --fs-section-heading,
                                    kept as its own token so this component
                                    can move independently of
                                    .section-title/.drawer-section-title. */
  --about-row-headline-measure: 44ch; /* 01's headline wraps to two lines
                                          at this measure. */
  --about-row-body-measure: 62ch;     /* revealed-prose measure; 100% at
                                          ≤640px. */
  --about-row-body-pad: var(--sp-6);  /* headline -> revealed body (24px). */
  --about-row-duration: 0.6s;  /* open/close travel — longer than the
                                   drawer's 0.28s on purpose: this moves
                                   more height. Was 0.45s on --ease. */
  /* Symmetric ease-in-out rather than --ease (2026-09-30, James: rows
     opened and closed "a little too suddenly"). --ease is an expo-out,
     which spends almost all its travel in the first ~100ms; this spreads
     it out. Same curve as the tagline's --tagline-sweep-ease. */
  --about-row-ease:     cubic-bezier(0.45, 0, 0.55, 1);
  /* The revealed text fades with the height, so it isn't uncovered at
     full strength. Slightly delayed in, immediate out. */
  --about-row-fade:     0.4s;
}

/* Outer vertical stack: .about-top, then .about-body. Single column by
   nature (no grid-template-columns of its own), so nothing to collapse at
   the mobile breakpoint below — only .about-top's own two columns need to. */
.about-grid {
  display: grid;
  gap: var(--sp-12);
}

/* Was a two-column grid (--about-photo-w | minmax(0,1fr)). Now one flex row,
   2026-09-16 handoff — no align-items and no height, deliberately: flex's
   default stretch is what hands .about-panels the portrait's own aspect-
   ratio height, the same mechanism .about-media relied on before it (the
   2026-09-15 height fix). */
.about-top {
  display: flex;
  gap: var(--about-panel-gap);
}

/* flex: 0 0 auto is the load-bearing line, added with the panels handoff.
   The portrait holds a hosted Unicorn Studio scene authored in a fixed
   0.8042 frame; letting it flex would both crop the scene and force a
   WebGL canvas resize on every frame of the panels' 550ms expand. Only the
   three photo panels flex (see .about-panel below). */
.about-photo {
  position: relative;
  flex: 0 0 auto;
  width: var(--about-panel-photo-w);
  aspect-ratio: var(--about-photo-ratio);
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
}

/* Unicorn Studio's runtime mounts a canvas into this once initAboutPhoto()
   decides the viewer qualifies (js/main.js) — sized to fill the box above,
   never the other way around. */
.about-photo .unicorn-embed { width: 100%; height: 100%; }
.about-photo .unicorn-embed canvas { display: block; }

/* The default/fallback photo. Absolutely positioned over the identical box
   the canvas fills, so whichever one initAboutPhoto() decides to show, the
   layout never shifts. */
.about-photo-fallback {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ABOUT PANELS — replaces .about-media/.about-media-row/.about-media-tile/
   .about-media-photo entirely, 2026-09-16 handoff (design_handoff_about_
   panels, _about/ABOUT-NOTES.md §9). One band of three photo panels beside
   the pinned portrait; hovering one expands it while the other two squeeze
   down. No captions — James asked for photos only, cutting two of the
   handoff's three proposed captions along with the one confirmed fact
   rather than shipping a mix. */
.about-panels {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  gap: var(--about-panel-gap);
}

.about-panel {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
  border-radius: 8px;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  transition: flex-grow 0.55s var(--ease);
}

/* position: absolute + inset: 0, not display:block + width/height:100% —
   a real bug, found live from a screen recording, not caught by any static
   measurement this session took. A percentage height on a normal-flow
   child of an indeterminate-height container doesn't resolve; it falls
   back to the image's own intrinsic aspect ratio instead. That fallback
   only ever mattered for the fishing photo (a portrait-oriented 750x938
   source): at its expanded width, width/0.8 computed taller than the
   pinned portrait's own height, making .about-panels the tallest item in
   the row and stretching everything else — including .about-photo,
   despite its own aspect-ratio — to match. River and plane are landscape-
   oriented sources, so their fallback height never exceeded the
   portrait's, which is why only the rightmost panel ever showed it.
   Absolute positioning removes the image from normal flow entirely, so it
   can't contribute a fallback size to anything — the same mechanism
   .about-photo-fallback already uses one level up. */
.about-panel-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Same convention as .drawer-figure-img/.project-thumb-video (STYLEGUIDE.md
   §11): anchor toward the edge holding the subject, so any further crop
   from object-fit: cover comes off the empty side. Both are portraits with
   the face in the upper half; the river photo has no dominant off-center
   subject and uses cover's own default center. */
.about-panel-photo--plane,
.about-panel-photo--fishing {
  object-position: top;
}

/* Desktop is entirely CSS. All three panels rest equal-width (2026-09-17 —
   James's ask; the first shipped version gave the river panel a resting
   flex-grow boost so the row never read as three untouched slices, same
   as the handoff's own spec, but that made it visibly wider than its
   siblings before any interaction, which he didn't want). Hovering any
   panel is the only thing that grows it now, and un-hovering drops it
   straight back to the shared flex: 1 1 0 base — no reset rule needed,
   since nothing is elevated above that base to begin with. */
@media (hover: hover) {
  .about-panel:hover { flex-grow: var(--about-panel-grow); }
}

/* The expand still works under reduced motion, it just stops travelling —
   same pattern as every other animated component on this site
   (STYLEGUIDE.md §8). */
@media (prefers-reduced-motion: reduce) {
  .about-panel { transition: none; }
}

@media (max-width: 640px) {
  .about-top { flex-direction: column; }
  /* Unchanged from before the panels handoff. */
  .about-photo { width: 100%; max-width: 330px; margin: 0 auto; }
  /* The band turns on its side: a real height (--about-panel-m-h) replaces
     the row height .about-top no longer shares once it's stacked, same
     reasoning .about-media used at this breakpoint before it. Expands on
     tap instead of hover — see initAboutPanels(), js/main.js. */
  .about-panels {
    flex: none;
    flex-direction: column;
    height: var(--about-panel-m-h);
  }
  /* All three rest equal-height here too (see the desktop hover rule's
     comment above) — tapping one is the only thing that grows it,
     .is-open alone is enough, same reasoning as .about-panel:hover. */
  .about-panel.is-open { flex-grow: var(--about-panel-m-grow); }
}

/* Full section width, not a 60ch prose column. .about-rows is its only
   child (the old section-to-section gap went with .about-section; the
   .about-gallery placeholders moved into row 03's reveal 2026-09-17 and
   were removed 2026-09-30). */
.about-body {
  display: grid;
  color: var(--text-secondary);
  font-size: var(--fs-lg);
}

/* =============================================================================
   ABOUT ROWS — replaces .about-section/.about-section-label/.about-section-
   headline entirely, 2026-09-17 handoff (design_handoff_about_hover_rows,
   _about/ABOUT-NOTES.md §9). Same copy verbatim; closed, each row shows only
   its label and headline, hover (desktop) or tap (touch, initAboutRows(),
   js/main.js) reveals the paragraph(s) below. .about-columns nests inside
   row 02's revealed body now, unchanged in its own right — see its own
   block further down.
   ============================================================================= */

.about-rows {
  display: grid;
  gap: 0;
  color: var(--text-secondary);
}

/* The open/close mechanism: a two-row grid whose second track animates
   0fr -> 1fr. No max-height guessing, no JS measuring — grid resolves the
   real content height itself. .about-row-reveal's overflow: hidden is what
   the 0fr track actually clips. */
.about-row {
  display: grid;
  grid-template-rows: auto 0fr;
  padding: var(--about-row-pad) 0;
  border-top: 1px solid var(--border-strong);
  transition: grid-template-rows var(--about-row-duration) var(--about-row-ease);
}

/* No line above row 01 — James's ask, 2026-09-17: it read as a redundant
   divider directly under the portrait/panels row above, which already
   has its own visual edge. The rule between rows (02's and 03's own
   border-top) and the closing line below row 03 (:last-child, below) are
   unaffected. */
.about-row:first-child {
  border-top: none;
}

@media (hover: hover) {
  .about-row:hover {
    grid-template-rows: auto 1fr;
  }
  .about-row:hover .about-row-body {
    opacity: 1;
    transition-delay: 0.1s;
  }
}

/* Touch/no-hover: initAboutRows() toggles .is-open on tap, same job
   .about-panel.is-open does for the panels band above. */
.about-row.is-open {
  grid-template-rows: auto 1fr;
}
.about-row.is-open .about-row-body {
  opacity: 1;
  transition-delay: 0.1s;
}

@media (prefers-reduced-motion: reduce) {
  .about-row,
  .about-row-body {
    transition: none;
  }
}

.about-row-head {
  display: grid;
  grid-template-columns: var(--about-row-label-w) minmax(0, 1fr);
  gap: var(--about-row-gap);
  align-items: baseline;
}

.about-row-label {
  margin: 0;
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--text-muted);
}

.about-row-headline {
  margin: 0;
  font-size: var(--about-row-headline-fs);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.015em;
  color: var(--text);
  max-width: var(--about-row-headline-measure);
  text-wrap: pretty;
}

.about-row-reveal {
  overflow: hidden;
}

.about-row-body {
  display: grid;
  grid-template-columns: var(--about-row-label-w) minmax(0, 1fr);
  gap: var(--about-row-gap);
  padding-top: var(--about-row-body-pad);
  opacity: 0;
  transition: opacity var(--about-row-fade) var(--about-row-ease);
}

/* Empty first cell keeps the revealed copy on the headline's x-position. */
.about-row-body-inner {
  grid-column: 2;
  display: grid;
  gap: var(--sp-4);
}

.about-row-text {
  margin: 0;
  font-size: var(--fs-lg);
  line-height: 1.7;
  color: var(--text-secondary);
  max-width: var(--about-row-body-measure);
}

@media (max-width: 640px) {
  :root {
    /* Label moves above the headline instead of sitting in its own rail. */
    --about-row-label-w: 0px;
    --about-row-headline-fs: 22px;
    --about-row-body-measure: 100%;
  }
  .about-row-head,
  .about-row-body {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--sp-2);
  }
  .about-row-body-inner {
    grid-column: 1;
  }
}

/* =============================================================================
   ABOUT COLUMNS — three short "how I work" columns under the 02 section's
   own copy. Second treatment of this component: superseded the bordered
   ABOUT CARDS above (2026-09-14 to 2026-09-16) on James's request to try a
   ruled, boxless direction instead. High-fidelity spec from a Claude Design
   handoff (`design_handoff_about_ruled_columns/`, deleted after extraction
   — see _about/ABOUT-NOTES.md for the full spec and the boxed version's own
   history, kept there rather than erased). No background, no border box, no
   radius — a single 1px top rule is the only chrome, so the wider grid gap
   (--sp-8, double the boxed version's --sp-4) is what actually separates
   one column from the next. Do not shrink that gap to match the old cards.
   ============================================================================= */

.about-columns {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  /* Column-to-column gutter. Was --sp-8 (32px); James asked for "a little
     more," 2026-09-16 — one step up the scale to --sp-12 (48px). With no
     border box, this gutter is the only thing separating one column from
     the next (the ruled-columns handoff's own point), so it's the one gap
     in this component that's expected to keep growing if it ever reads as
     tight again, rather than the usual last resort. */
  gap: var(--sp-12);
  /* Extra space between 02's paragraph and this row, on top of
     .about-section's own --sp-4 gap before it. Was --sp-4 (16px, 32px
     total); bumped one step to --sp-6 (24px, 40px total) alongside the
     gutter above, same request. Still scoped to just this row via a
     margin here rather than a bump to .about-section's shared gap, so
     01/03 and 02's own label/headline/paragraph rhythm stay untouched. */
  margin-top: var(--sp-6);
}

.about-column {
  display: flex;
  flex-direction: column;
  gap: var(--about-rule-gap);
  padding-top: var(--about-rule-pad);
  border-top: 1px solid var(--border-strong);
  transition: border-top-color 0.3s var(--ease);
  /* No min-height, no align-items override, deliberately — the handoff's
     own instruction: with no visible box, ragged bottoms from uneven copy
     length are correct here, not a bug to fix the way the boxed cards'
     mismatched heights were. */
}

/* Behind @media (hover: hover) like every other hover rule on the site.
   Brighter than the boxed version's hover (#1f1f1f -> #2a2a2a): with only
   one hairline to carry the state, it has to reach --text to register at
   all — the handoff's own note. */
@media (hover: hover) {
  .about-column:hover {
    border-top-color: var(--text);
  }
}

.about-column-title {
  margin: 0;
  font-size: var(--fs-base);
  font-weight: 700;
  line-height: 1.35;
  color: var(--text);
}

.about-column-text {
  margin: 0;
  font-size: var(--fs-sm);
  line-height: 1.6;
  color: var(--text-secondary);
  text-wrap: pretty;
}

@media (max-width: 640px) {
  .about-columns {
    grid-template-columns: 1fr;
    /* Reduced from --sp-8 (32px) — the handoff's own call: stacked, 32px
       between a paragraph's last line and the next rule reads as more air
       than a ruled (boxless) row needs. --sp-6 (24px) was its recommended
       value pending a confirm; used as given rather than left blocking. */
    gap: var(--sp-6);
  }
}

/* =============================================================================
   CONTACT
   ============================================================================= */

/* Step-by-step form, Claude Design handoff "1e" (2026-09-23). Heading, lead
   and links sit in a narrow left column; the form takes the right. The form
   markup is the no-JS version (all four questions stacked); initContactForm()
   (js/main.js) shows one step at a time by toggling `hidden`.

   The knobs: */
:root {
  --contact-question-fs: var(--fs-section-heading); /* 30px, same as .section-title */
  --contact-input-fs:    var(--fs-xl);              /* 24px, name + email lines */
  --contact-progress-h:  2px;
  /* Floor for the form's height, so the Back/Next row (margin-top: auto)
     sits in the same place on every step instead of jumping as the field
     under it changes from pills to a line to a textarea. */
  --contact-min-h:       300px;
}

.contact-body {
  display: grid;
  grid-template-columns: minmax(0, 4fr) minmax(0, 8fr);
  gap: var(--sp-16);
}

.contact-form {
  display: flex;
  flex-direction: column;
  gap: var(--sp-8);
  min-height: var(--contact-min-h);
  min-width: 0;
}

/* Anything carrying `hidden` here also has a display rule of its own, which
   would otherwise beat the UA's [hidden] { display: none }. */
.contact-form [hidden] {
  display: none;
}

.contact-progress {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
}

.contact-progress-label {
  font-size: var(--fs-sm);
  color: var(--text-muted);
  white-space: nowrap;
}

.contact-progress-track {
  flex: 1;
  height: var(--contact-progress-h);
  background: var(--border);
  border-radius: 2px;
  overflow: hidden;
}

.contact-progress-fill {
  display: block;
  height: 100%;
  width: 25%;
  background: var(--text);
  transition: width 0.4s var(--ease);
}

@media (prefers-reduced-motion: reduce) {
  .contact-progress-fill { transition: none; }
}

/* A fieldset for step 1 (its legend is the question), a div for the rest.
   Block layout rather than flex, and the question carries its own bottom
   margin, because a rendered <legend> doesn't take part in flex gap. */
.contact-step {
  margin: 0;
  padding: 0;
  border: 0;
  min-width: 0;
}

/* Styled like .section-title. The <label>/<legend> of each step's field, so
   focusing the field announces the question. */
.contact-question {
  display: block;
  padding: 0;
  margin-bottom: var(--sp-8);
  font-size: var(--contact-question-fs);
  font-weight: 600;
  letter-spacing: -0.01em;
  line-height: 1.25;
  color: var(--text);
}

.contact-question:focus {
  outline: none;
}

.contact-pills {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
}

/* The radio is .visually-hidden (absolutely positioned); this keeps it
   anchored to its own pill. */
.contact-pill-wrap {
  position: relative;
}

/* Same pill language as .hero-btn / .work-tag, one size up. */
.contact-pill {
  display: inline-block;
  padding: 10px 20px;
  border-radius: 999px;
  font-size: var(--fs-base);
  color: var(--text);
  border: 1px solid var(--border-strong);
  cursor: pointer;
  transition: border-color 0.2s var(--ease), background-color 0.2s var(--ease),
              color 0.2s var(--ease);
}

.contact-pill:hover {
  border-color: var(--text-muted);
}

.contact-pill-input:checked + .contact-pill {
  background: var(--surface-invert);
  color: var(--text-on-invert);
  border-color: var(--surface-invert);
}

.contact-pill-input:focus-visible + .contact-pill {
  outline: 2px solid var(--text-muted);
  outline-offset: 3px;
}

.contact-input,
.contact-textarea {
  display: block;
  width: 100%;
  font: inherit;
  color: var(--text);
  outline: none;
  transition: border-color 0.2s var(--ease);
}

.contact-input {
  padding: 8px 0;
  font-size: var(--contact-input-fs);
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--border-strong);
  border-radius: 0;
}

.contact-textarea {
  padding: 12px 14px;
  font-size: var(--fs-base);
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: 8px;
  resize: vertical;
}

.contact-input::placeholder,
.contact-textarea::placeholder {
  color: var(--text-muted);
  opacity: 1;
}

/* Greyscale on purpose, like the error copy: the availability dot is the
   site's only non-grey mark, so an invalid field goes lighter, not red. */
.contact-input[aria-invalid="true"],
.contact-textarea[aria-invalid="true"] {
  border-color: var(--text-secondary);
}

/* Chrome paints autofilled fields pale blue with dark text, which on this
   background reads as a broken white box. An inset shadow is the only thing
   that covers it. */
.contact-input:-webkit-autofill {
  -webkit-box-shadow: 0 0 0 1000px var(--bg) inset;
  -webkit-text-fill-color: var(--text);
  caret-color: var(--text);
}

.contact-sent-note {
  font-size: var(--fs-base);
  color: var(--text-secondary);
}

/* Sits 16px under the field rather than a full form gap away. */
.contact-error {
  margin-top: calc(-1 * var(--sp-4));
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}

.contact-error a {
  color: var(--text);
  border-bottom: 1px solid var(--border-strong);
}

/* Pushed to the bottom of the form's min-height, so it doesn't move
   between steps. */
.contact-actions {
  display: flex;
  gap: var(--sp-3);
  margin-top: auto;
}

/* .hero-btn was written for <a>; these are real buttons. */
.contact-actions .hero-btn {
  font: inherit;
  font-size: var(--fs-sm);
  font-weight: 500;
  cursor: pointer;
}

.contact-submit:disabled {
  opacity: 0.6;
  cursor: default;
}

@media (max-width: 640px) {
  .contact-body {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--sp-12);
  }

  .contact-form {
    min-height: auto;
  }
}

.contact-links {
  display: grid;
  gap: var(--sp-2);
  font-size: var(--fs-sm);
}

.contact-links a {
  color: var(--text-secondary);
  border-bottom: 1px solid transparent;
  padding-bottom: 2px;
}

.contact-links a:hover {
  color: var(--text);
  border-bottom-color: var(--border-strong);
}


/* =============================================================================
   FOOTER
   ============================================================================= */

.site-footer {
  padding: var(--sp-8) var(--content-pad);
  border-top: 1px solid var(--border);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
}

.footer-inner {
  max-width: var(--content-w);
  margin: 0 auto;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--sp-4);
}


/* =============================================================================
   RESPONSIVE
   ============================================================================= */

@media (max-width: 640px) {
  :root {
    --sp-24: 4rem;
    --sp-32: 6rem;
    --fs-4xl: 2.5rem;
    --fs-3xl: 2rem;
    --fs-2xl: 1.5rem;
  }

  /* No .hero-tagline font-size override here - its own clamp() (see that
     rule) already lands at its 34px floor around this breakpoint, so a
     different fixed mobile value would fight it: whichever number didn't match
     the clamp's natural size at 640px would make the type visibly jump *larger*
     as the viewport gets *smaller* crossing this exact boundary. One formula
     owns the whole range. It wraps to two or three lines on a real phone, which
     is fine - the "Interfaces" overlay's labels are solid chips, so a wrapped
     line under them doesn't make them illegible (see .tagline-fig-name).

     The module itself does need mobile numbers. Its 620px desktop minimum is
     most of a phone viewport on its own, and the padding has to come in or the
     tagline has almost no width left to set in. */
  :root {
    --hero-module-pad:   var(--sp-6);
    --hero-module-min-h: 520px;
    /* Was inherited automatically while --hero-divider-below read --sp-24,
       which this block redefines 6rem -> 4rem. Now that it is a px literal
       that link is gone, so the mobile value is stated here to keep the
       spacing James already signed off on. Delete this line if you want the
       full 96px on phones too. */
    --hero-divider-below: 80px;
    /* Fewer, larger cells: at 15px the grid is denser than a phone can
       resolve and it costs fragments for texture nobody sees. */
    --ascii-cell:        13px;
  }

  /* The button row and the availability pill stop competing for one line and
     stack, pill last, rather than wrapping into a ragged two-line row. */
  .hero-actions {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--sp-4);
    padding-top: var(--sp-12);
  }

  /* Two-row header on mobile, per ozgur.design's own (James's reference):
     logo and gear on the top row at the outer edges, the section links
     centered on a second row underneath. Four links plus a logo plus a
     gear can technically be squeezed onto one 375px row, but only at a
     type size small enough that the links stop reading as the primary
     navigation — the second row buys back the width instead.

     --nav-h stays a real length here rather than going `auto`: it feeds
     html's scroll-padding-top and .section--hero's padding-top (see
     both), and neither can resolve an intrinsic keyword. 128px is the two
     rows plus their padding. */
  :root {
    --nav-h:         128px;
    --nav-link-gap:  var(--sp-6);
    --nav-link-fs:   var(--fs-sm);
    --nav-btn-size:  40px;
    --nav-logo-size: 26px;
    --nav-gear-size: 20px;
    --nav-icon-size: 20px;
  }

  /* Same grid, different area map — the markup doesn't change, so there's
     no second copy of the header to keep in sync. Columns are `1fr auto`
     so the logo takes the slack and the gear stays hugged to the right;
     the links row spans both and centers itself within them.

     DOM order stays logo → links → gear, which is the order that makes
     sense to a screen reader and to tab focus, even though the links
     render below both here. */
  .nav-inner {
    grid-template-columns: 1fr auto;
    grid-template-areas:
      "logo  settings"
      "links links";
    align-content: center;
    row-gap: var(--sp-3);
  }

  .nav-links {
    justify-self: center;
  }

  .work-list {
    grid-template-columns: 1fr;
  }

  .drawer-gallery-row {
    grid-template-columns: 1fr;
  }

  /* Two-column sections collapse back to the original single-column
     stack — title above content, both full width. Zeroing the same two
     custom properties .drawer-section sets for desktop (rather than a
     separate reset rule) automatically collapses the figure-bleed rule
     right along with the grid, since that rule computes its pull from
     these same variables — one change, not two kept in sync by hand. */
  .drawer-section {
    grid-template-columns: 1fr;
    --section-title-w: 0px;
    --section-gap: 0px;
    gap: var(--sp-4);
  }

  .drawer-blocks {
    gap: var(--sp-8);
  }

  /* Collapsing to one column stacks the row on its own: the thumbnail takes
     the full width and .project-body's `/ -1` lands underneath it as a second
     grid row, alongside the site's other multi-column grids above. Retuning
     the row's own tokens rather than rewriting its grid, the same trick
     .drawer-section uses further up this block — but note --pr-content-start
     has to come with it, because a `2 / -1` on a single-column grid would
     conjure an implicit second column instead of wrapping. --pr-gap becomes
     the vertical gap between the two once stacked, where --sp-8 is too much. */
  .project-row {
    --pr-cols: 1;
    --pr-content-start: 1;
    --pr-gap: var(--sp-4);
  }

  .footer-inner {
    flex-direction: column;
    align-items: flex-start;
  }
}


/* =============================================================================
   LIGHT THEME — component exceptions
   Everything built on the color tokens switches through the token block near
   the top of this file (:root[data-theme="light"]) and needs nothing here.
   This section is only the pieces that carry their own color. Selected Work's
   white badge is deliberately NOT here: James wants it unchanged in both
   themes.
   ============================================================================= */

/* Hero tagline. The gradient's stops deepen one step so the yellow still
   reads on paper (#ffe14d on #f4f3ef all but vanishes), same hues in the
   same order. The Systems editor takes VS Code Light+ chrome, with the same
   deepened stops as syntax colors, matching how dark mode reuses the
   gradient's own. */
:root[data-theme="light"] .hero-tagline {
  --tagline-code-prop:  #0284c7;
  --tagline-code-num:   #a16207;
  --tagline-code-fn:    #db2777;
  --tagline-code-sel:   #a78bfa40;
  --tagline-code-fg:    #3b3b3b;
  --tagline-code-ln:    #8a8a8a;
  --tagline-code-ln-on: #1a1917;
  --tagline-code-line:  #00000014;
  --tagline-code-caret: #1a1917;
  --tagline-code-pop-bg:     #f8f8f8;
  --tagline-code-pop-border: #c8c8c8;
  --tagline-code-pop-on:     #dbeafe;
}

:root[data-theme="light"] .tagline-word--interactivity {
  background-image: linear-gradient(
    90deg,
    #dc2626, #ea580c, #ca8a04, #16a34a,
    #0284c7, #7c3aed, #db2777, #dc2626
  );
}

:root[data-theme="light"] .tagline-sys-suggest {
  box-shadow: 0 2px 8px #0000001f;
}

/* #4ade80 is a dark-background green; this is the same green a step down. */
:root[data-theme="light"] .hero-availability-dot {
  background: #16a34a;
}

/* Shadows tuned for a black page read as smudges on paper. */
:root[data-theme="light"] .settings-menu {
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}

:root[data-theme="light"] .drawer {
  box-shadow: 0 -16px 48px rgba(0, 0, 0, 0.12);
}

:root[data-theme="light"] .drawer-backdrop {
  background: rgba(0, 0, 0, 0.25);
}

/* The outcomes callout is dark mode's one light "paper" card. On a paper
   page it would disappear, so it inverts: an ink card with paper text. */
:root[data-theme="light"] .drawer-outcomes-lead {
  background: var(--text);
  color: var(--bg);
}

:root[data-theme="light"] .drawer-outcomes-lead-text {
  color: var(--border-strong);
}

/* Experiments' "in progress" placeholder thumbnails: near-black tiles with
   dark blobs, heavy on paper. Same structure, a paper-toned version: the
   tile a step darker than the page, the blobs a step darker again. */
:root[data-theme="light"] {
  --pr-wip-thumb-bg:     #e6e3dc;
  --pr-wip-blob-a:       radial-gradient(circle at 35% 45%, #cdc9c0, transparent 45%),
                         radial-gradient(circle at 70% 60%, #d8d4cc, transparent 40%);
  --pr-wip-blob-b:       radial-gradient(circle at 60% 40%, #cdc9c0, transparent 45%),
                         radial-gradient(circle at 30% 70%, #d8d4cc, transparent 40%);
}

/* Epiq's plugin title cards. The logos are light-grey ASCII-art PNGs,
   invisible on paper, so each column renders a second, ink-coloured copy
   (-light.png, made by _tools/recolor-ascii-logos.py) and the theme picks
   which one shows. This replaced a dark plate behind the logos the same day
   (James: use recoloured logos instead of relying on a black background). */
.drawer-trio-art--light {
  display: none;
}

:root[data-theme="light"] .drawer-trio-art:not(.drawer-trio-art--light) {
  display: none;
}

:root[data-theme="light"] .drawer-trio-art--light {
  display: block;
}
