/*
  TBaguette’s Atelier — design system
  =========================================
  One stylesheet, hand-authored. Token layer (primitive -> semantic) feeds every
  component below it; components reference roles (--surface, --text-primary, ...),
  never raw primitives (--crumb-850, --cream-100, ...) directly. Dark ("crust") is
  the default theme; [data-theme="flour"] on <html> remaps the semantic layer only.

  Contents
  1. Font faces (self-hosted, latin-subset)
  2. Tokens — primitives, semantic (dark default + flour theme)
  3. Reset & base
  4. Utilities (visually-hidden, skip-link, container, measure)
  5. Type
  6. Icons
  7. Header / wordmark / nav / theme toggle / updated-time
  8. Hero + search
  8b. "Fresh from the oven" rail (recently new/updated skills; a turning
      3D ring wherever motion, width, and forced-colors all allow it)
  8c. Update notes (what changed, in words, read from UPDATES.md)
  9. Category sections + card grid + card (incl. change-badge: new/updated)
  10. Breadcrumb
  11. Article: lede, prose, tables, code, lists (incl. skill cross-references)
  12. Tabs (formidable)
  13. Prev/next + see-also
  14. Footer
  15. Motion + reduced-motion
  16. Code block (install-verification page: line numbers, syntax tokens)
  17. Responsive notes (fluid by default; hard breakpoints only where noted)
  18. Update-check modal
  19. The skill graph ("the crumb"): header and skill-page entry points,
      the full-viewport /graph/ page that holds it, and the app's chrome
*/

/* ---------------------------------------------------------------------------
   1. Font faces — self-hosted, latin-subset woff2 pulled from Google Fonts'
   own latin-range files (not the CDN link) so there is no third-party request
   at runtime, no FOUT waterfall, and we control font-display ourselves.
   Fraunces and Work Sans are true variable fonts (one file covers their full
   weight range, Fraunces also opsz); IBM Plex Mono ships static weights only.
   ------------------------------------------------------------------------- */

@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 300 900;
  font-display: swap;
  src: url('fonts/fraunces-variable.woff2') format('woff2-variations'),
       url('fonts/fraunces-variable.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Fraunces';
  font-style: italic;
  font-weight: 300 900;
  font-display: swap;
  src: url('fonts/fraunces-italic-variable.woff2') format('woff2-variations'),
       url('fonts/fraunces-italic-variable.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Work Sans';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('fonts/work-sans-variable.woff2') format('woff2-variations'),
       url('fonts/work-sans-variable.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('fonts/ibm-plex-mono-400.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('fonts/ibm-plex-mono-500.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

/* ---------------------------------------------------------------------------
   2. Tokens
   ------------------------------------------------------------------------- */

:root {
  color-scheme: dark;

  /* --- primitives (tier 1: raw values, never referenced by components) --- */
  --crust-950: #1a130f;
  --crust-900: #22190f;
  --crumb-850: #2b2015;
  --crumb-800: #362819;
  --gold-500: #dda25c;
  --gold-400: #e8b876;
  --gold-700: #a86f34;
  /* --gold-800 is a deviation from the locked palette: measured, gold-700 on
     flour-50/flour-100 is 3.2-3.6:1, which clears the 3:1 floor for large
     text/icons/borders but fails the 4.5:1 floor for body-sized text. Rather
     than reinterpret gold-700's value, we add one darker step so inline links
     and other body-sized accent text in the flour theme have a contrast-safe
     home; gold-700 stays reserved for non-text/large-text uses. See --text-accent
     usage rule below. */
  --gold-800: #83552a;
  --cream-100: #f5ead9;
  --cream-300: #cbb79c;
  /* flour-50/flour-100: nudged one small step darker than the original
     locked-palette values (#faf3e8 / #f2e6d2) — "a tiny bit less bright" in
     the light theme, ~6% lower relative luminance, tuned in OKLCH so hue and
     chroma hold exactly and only lightness moves. Every dependent contrast
     pairing in the flour theme was re-measured against the new values, not
     assumed safe by extrapolation — darkening a light background always
     *reduces* its contrast against fixed-dark text (the gap closes, it
     doesn't widen), which is the opposite of the first intuition and is
     exactly what tightened --bg-deep and --surface-hover below enough to
     need their own mix-ratio corrections. */
  --flour-50: #f3ece1;
  --flour-100: #ebdfcc;
  --ink-900: #241a12;
  /* --ink-700 fills a gap the locked palette left open: it names a primary
     light-theme text color (--ink-900) but no secondary. cream-300 is
     explicitly "warm-tinted, never neutral gray" for the dark theme; ink-700
     is its light-theme counterpart, built the same way (warm brown, not gray),
     measured at >=5.2:1 on both flour surfaces. */
  --ink-700: #6b5642;
  /* ember-500/700: the second hue this palette carries, and the only one.
     Added rather than borrowed because of what it is for -- the panel a skill
     wraps around its one non-negotiable rule -- and gold is this site's
     *ambient* colour. A gold "extremely important" panel reads as one more
     gold thing on a page made of gold things, which is precisely the failure
     mode for the element whose whole job is to be the loudest thing there.
     Built as a sibling of the golds, not an import: same warm family, hue
     pushed from ~65 to ~37 (scorched crust, not a generic alert red), and the
     two steps relate exactly as gold-500/gold-800 do -- a light-on-dark value
     for crust, a dark-on-light one for flour. Every pairing measured in
     OKLCh; the figures are on --critical-surface below. */
  --ember-500: #eb7955;
  --ember-700: #9a2d0a;

  /* --- semantic (tier 2: the only tier components should reference) ---
     Dark ("crust") is the default; no [data-theme] attribute needed. */

  /* usage: page background */
  --bg: var(--crust-950);
  /* usage: footer + any "deep" recessed section */
  --bg-deep: var(--crust-900);
  /* usage: card / input / raised-panel background */
  --surface: var(--crumb-850);
  /* usage: hover/active state of a surface, or a surface nested on a surface */
  --surface-hover: var(--crumb-800);
  /* usage: primary reading text */
  --text-primary: var(--cream-100);
  /* usage: secondary text — captions, meta, summaries, secondary nav */
  --text-secondary: var(--cream-300);
  /* usage: body-sized accent text only — inline links, active tab/nav label.
     Must independently clear 4.5:1; do not reuse --accent here. */
  --text-accent: var(--gold-500);
  /* usage: non-text accent — icon strokes, card borders, tag underlines,
     decorative rules. Only needs to clear 3:1. */
  --accent: var(--gold-500);
  /* usage: hover/active shift for --accent and --text-accent */
  --accent-hover: var(--gold-400);
  /* usage: border on the solid-fill accent button/badge component tier.
     Fixed across both themes on purpose — see .btn-accent notes below. */
  --accent-solid: var(--gold-500);
  --accent-solid-hover: var(--gold-400);
  --text-on-accent-solid: var(--crust-950);
  /* usage: the non-negotiable-rule callout -- panel rule, glyph, label fill.
     Severity, not decoration: nothing else on the site may reach for it, or
     it stops meaning anything. Never used as text on --bg or --surface, only
     on --critical-surface, which is the only backdrop it was measured on. */
  --critical: var(--ember-500);
  /* Tint percentages differ per theme for the same reason .milestone's do,
     and were derived the same way: every percent of the accent mixed into
     the surface closes the gap against anything painted in that accent.
     Crust at 10% -- panel rule and glyph 4.88:1, body text 11.64:1, an
     inline gold link 6.28:1, label ink on the fill 6.47:1.
     Flour is the tighter theme and 4% is not a round-number choice. The
     binding constraint is a gold link inside the panel (--text-accent is
     gold-800 there): 4.58:1 at 4%, 4.51:1 at 5% -- passing, but sitting on
     the 4.5:1 floor exactly, where any later nudge to flour-100 or gold-800
     breaks it silently -- and outright failing at 7% (4.39:1). 4% buys back
     a real margin and costs almost nothing visible, because the tint was
     never what carries this panel in flour; the 3px rule is. No callout
     currently contains a link, so this is the pairing being kept safe for
     the first one that does. Everything else has room -- panel rule and
     glyph 5.49:1, body text 12.27:1, label on the fill 6.50:1. */
  --critical-surface: color-mix(in oklch, var(--critical) 10%, var(--surface));
  /* usage: label text on a --critical fill, nowhere else */
  --text-on-critical: var(--crust-950);
  /* usage: hairline dividers, card borders at rest */
  --border-subtle: rgb(245 234 217 / 10%);
  --border-strong: rgb(245 234 217 / 20%);
  /* usage: focus outline, always painted with an offset onto the surrounding
     bg so it never has to fight the focused element's own fill for contrast */
  --focus-ring: var(--gold-400);
  /* usage: shadow color mix-ins */
  --shadow-ink: 20 10% 3%;
  /* usage: the full-screen scrim behind a modal. Deliberately NOT
     --shadow-ink: that one is tuned *lighter* in the flour theme so cast
     shadows stay soft on a bright page, which is the opposite of what a
     scrim needs. Mixed against cream at any sane opacity, a 14%-lightness
     ink lands on a muddy mid-gray — dark enough to dim the page, not dark
     enough to read as deliberate. One value for both themes, because a
     scrim's job (make the card unmistakably foreground) doesn't change
     with the theme. */
  --scrim: 24 18% 5%;

  /* usage: Python syntax highlighting on the install-verification page.
     Each independently verified >=4.5:1 against --surface (the code
     block's own background). .tok-number (numeric literals) deliberately
     reuses --text-accent directly rather than adding a seventh hue — one
     gold thread running through variables everywhere on the site, not a
     separate code-only accent. */
  --code-comment: #a08868;
  --code-string: #e2916a;
  --code-keyword: #a7b98a;
  --code-const: #d4a5a5;

  /* --- space: 4px base steps, ordinal (never t-shirt sizes — see naming-things) --- */
  --space-1: 0.25rem;   /* 4px  */
  --space-2: 0.5rem;    /* 8px  */
  --space-3: 0.75rem;   /* 12px */
  --space-4: 1rem;      /* 16px */
  --space-5: 1.5rem;    /* 24px */
  --space-6: 2rem;      /* 32px */
  --space-7: 3rem;      /* 48px */
  --space-8: 4rem;      /* 64px */
  --space-9: 6rem;      /* 96px */

  /* --- radius: soft/organic, not sharp-corporate, not pill-everything --- */
  --radius-sm: 8px;
  --radius-md: 14px;
  --radius-lg: 22px;
  --radius-full: 999px;

  /* --- type scale: ~1.25 through body sizes, opening up to ~1.4 at the
     display end (type.md sanctions this for editorial/display work) --- */
  --text-1: 0.8rem;      /* 12.8px — meta, mono tags, fine print   */
  --text-2: 0.9rem;      /* 14.4px — secondary UI, breadcrumb      */
  --text-3: 1rem;        /* 16px   — body                          */
  --text-4: 1.25rem;     /* 20px   — lede, h3, card name            */
  --text-5: 1.75rem;     /* 28px   — h2                             */
  --text-6: 2.5rem;      /* 40px   — h1 / skill-name page title     */
  --text-7: clamp(2.25rem, 1.4rem + 3.6vw, 4rem); /* hero headline  */

  --font-display: 'Fraunces', ui-serif, Georgia, 'Times New Roman', serif;
  --font-body: 'Work Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;

  --leading-tight: 1.1;
  --leading-normal: 1.5;
  --leading-relaxed: 1.65;

  /* --- motion: 3 durations + instant, 3 easings (tokens.md minimum set) --- */
  --duration-instant: 0ms;
  --duration-micro: 130ms;   /* hover, toggle, press      */
  --duration-enter: 220ms;   /* element enter/exit        */
  --duration-view: 360ms;    /* layout/view transition    */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in: cubic-bezier(0.7, 0, 0.84, 0);
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);

  --measure-prose: 66ch;
  --container-wide: 78rem;
}

[data-theme='flour'] {
  color-scheme: light;

  --bg: var(--flour-50);
  /* 9% -> 6%: with the darker flour-50 above, 9% pulled bg-deep close enough
     to --text-accent's own luminance to drop its contrast against it below
     4.5:1 (measured: 4.46:1) — the header's "Updated" label sits directly on
     this. 6% restores a real margin (4.77:1) while staying visibly deeper
     than --bg, which is the one thing this token exists to do. */
  --bg-deep: color-mix(in oklch, var(--ink-900) 6%, var(--flour-50));
  --surface: var(--flour-100);
  /* 8% -> 6%, same reasoning as --bg-deep above, against the darker
     flour-100: at 8% this measured 4.42:1 for --text-secondary (a card's
     summary text on hover) against a 4.5:1 floor — a real regression, and
     one that turned out to already be live before this change too (8%
     against the *original* flour-100 was 4.35:1, already failing; nobody
     had measured it since gold-800 was tuned against flour-50/100 directly,
     never against this derived token). 6% clears both --text-secondary
     (4.64:1) and --text-accent, held to the 3:1 non-text floor here since
     .card__arrow is a directional glyph, not read text (4.27:1). */
  --surface-hover: color-mix(in oklch, var(--ink-900) 6%, var(--flour-100));
  --text-primary: var(--ink-900);
  --text-secondary: var(--ink-700);
  --text-accent: var(--gold-800);
  --accent: var(--gold-700);
  --accent-hover: var(--gold-800);
  /* accent-solid / text-on-accent-solid intentionally NOT remapped: the solid
     gold-500 fill + near-black label is the one brand mark that stays
     identical in both themes, like a foil stamp — see .btn-accent notes. */
  --critical: var(--ember-700);
  --critical-surface: color-mix(in oklch, var(--critical) 4%, var(--surface));
  --text-on-critical: var(--flour-50);
  --border-subtle: rgb(36 26 18 / 10%);
  --border-strong: rgb(36 26 18 / 20%);
  --focus-ring: var(--gold-700);
  --shadow-ink: 30 25% 14%;

  --code-comment: #6f5b3e;
  --code-string: #96461f;
  --code-keyword: #4c5c34;
  --code-const: #8f4a4a;
}

/* ---------------------------------------------------------------------------
   3. Reset & base
   ------------------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  min-height: 100dvh;
  background: var(--bg);
  color: var(--text-primary);
  font-family: var(--font-body);
  font-size: var(--text-3);
  line-height: var(--leading-normal);
  transition: background-color var(--duration-view) var(--ease-in-out),
              color var(--duration-view) var(--ease-in-out);
}

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

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

button, input {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
}

ul, ol { padding-inline-start: 1.25em; }
li + li { margin-top: var(--space-2); }

table { border-collapse: collapse; }

/* One focus treatment, everywhere, always keyboard-reachable. Offset so the
   ring is measured against the surrounding surface, never the element's own
   fill (this is what keeps a ring on a solid gold button legible in both
   themes without a special case). */
:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 3px;
  border-radius: var(--radius-sm);
}

::selection {
  background: var(--accent);
  color: var(--text-on-accent-solid);
}

/* Custom scrollbars, everywhere a box scrolls — the page and every inner
   overflow container alike (the install command, wide tables in prose).
   Firefox via the standard properties on the root; WebKit via the unscoped
   pseudo-elements below, which is why they carry no selector prefix. Resting
   thumb is a raised-surface tone, quiet but visible against the page; it
   goes to the accent on hover/active, matching the same at-rest/interactive
   language every other control on this site already uses. */
html {
  scrollbar-gutter: stable;
  scrollbar-width: thin;
  scrollbar-color: var(--surface-hover) transparent;
}

::-webkit-scrollbar { width: 10px; height: 10px; }

::-webkit-scrollbar-track { background: transparent; }

::-webkit-scrollbar-thumb {
  background-color: var(--surface-hover);
  border: 2px solid transparent;
  border-radius: var(--radius-full);
  background-clip: padding-box;
}

::-webkit-scrollbar-thumb:hover,
::-webkit-scrollbar-thumb:active {
  background-color: var(--accent);
}

::-webkit-scrollbar-corner { background: transparent; }

/* ---------------------------------------------------------------------------
   4. Utilities
   ------------------------------------------------------------------------- */

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

.skip-link {
  position: absolute;
  z-index: 100;
  top: var(--space-2);
  inset-inline-start: var(--space-2);
  padding: var(--space-2) var(--space-4);
  background: var(--surface);
  color: var(--text-primary);
  border-radius: var(--radius-sm);
  transform: translateY(-150%);
  transition: transform var(--duration-micro) var(--ease-out);
}

.skip-link:focus {
  transform: translateY(0);
}

.container {
  width: 100%;
  max-width: var(--container-wide);
  margin-inline: auto;
  padding-inline: var(--space-5);
}

@media (min-width: 640px) {
  .container { padding-inline: var(--space-8); }
}

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

/* ---------------------------------------------------------------------------
   5. Type
   ------------------------------------------------------------------------- */

h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-weight: 600;
  line-height: var(--leading-tight);
  text-wrap: balance;
  overflow-wrap: anywhere;
}

h1 { font-size: var(--text-6); font-weight: 650; }
h2 { font-size: var(--text-5); font-weight: 600; margin-top: var(--space-8); margin-bottom: var(--space-4); }
h3 { font-size: var(--text-4); font-weight: 600; margin-top: var(--space-6); margin-bottom: var(--space-3); }

p { margin-top: var(--space-4); }
* + h2, * + h3 { margin-top: var(--space-8); }

strong { font-weight: 700; color: var(--text-primary); }

/* direction/unicode-bidi: code stays LTR regardless of surrounding prose —
   a shell command or a file path shouldn't reorder around its neutral
   characters (~, /, -) no matter what's next to it. Element-wide rather
   than a per-call-site dir="ltr" attribute, so it covers every <code>
   including ones not yet written. */
code {
  font-family: var(--font-mono);
  font-size: 0.9em;
  background: var(--surface-hover);
  padding: 0.15em 0.4em;
  border-radius: 6px;
  direction: ltr;
  unicode-bidi: isolate;
}

/* ---------------------------------------------------------------------------
   6. Icons — single-stroke line marks, one consistent weight
   ------------------------------------------------------------------------- */

.icon {
  width: 1.5em;
  height: 1.5em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  flex-shrink: 0;
}

/* ---------------------------------------------------------------------------
   7. Header / wordmark / nav / theme toggle / updated-time
   ------------------------------------------------------------------------- */

.site-header {
  position: relative;
  z-index: 10;
  isolation: isolate;
}

/* A separate layer behind .site-header__inner rather than background on the
   header itself, so the gradients can bleed past the container's own edges
   without a second wrapping element in the markup. */
.site-header__band {
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--bg-deep);
}

/* The glow gradients live on their own pseudo-element, blurred, rather than
   directly on .site-header__band's background: a gradient this large (up to
   220% radius) fading from a low-alpha color to fully transparent has almost
   no delta per pixel, and both browsers tested rasterize that as visible
   concentric banding rather than a smooth ramp — worse the larger and
   subtler the gradient, which this is. The blur is a low-pass filter over
   that same stepped ramp, which is what actually removes the bands (a
   softer-looking glow is a side effect, not the goal). Kept off ::after
   below so the gilded rule stays a crisp line rather than a smear. */
.site-header__band::before {
  content: '';
  position: absolute;
  inset: 0;
  background:
    radial-gradient(140% 220% at 6% 0%,
      color-mix(in oklch, var(--accent) 20%, transparent),
      transparent 65%),
    radial-gradient(90% 160% at 100% 0%,
      color-mix(in oklch, var(--accent) 12%, transparent),
      transparent 60%);
  filter: blur(16px);
}

/* The "gilded rule": a soft gold line along the header's bottom edge that
   fades at both ends rather than running hard edge-to-edge — a considered
   accent, not a ruler line underneath a form. */
.site-header__band::after {
  content: '';
  position: absolute;
  inset-inline: 6%;
  bottom: 0;
  height: 1px;
  background: linear-gradient(to right,
    transparent,
    color-mix(in oklch, var(--accent) 65%, transparent) 25%,
    color-mix(in oklch, var(--accent) 65%, transparent) 75%,
    transparent);
}

.site-header__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  row-gap: var(--space-3);
  padding-block: var(--space-5);
}

.wordmark {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-weight: 680;
  font-size: clamp(var(--text-4), 0.95rem + 1.1vw, var(--text-5));
  letter-spacing: -0.015em;
  color: var(--text-primary);
  border-radius: var(--radius-sm);
  text-shadow: 0 2px 20px color-mix(in oklch, var(--accent) 30%, transparent);
  transition: color var(--duration-micro) var(--ease-out),
              text-shadow var(--duration-enter) var(--ease-out);
}

.wordmark:hover {
  color: var(--text-accent);
  text-shadow: 0 2px 28px color-mix(in oklch, var(--accent) 50%, transparent);
}

.wordmark__icon {
  width: 1.35em;
  height: 1.35em;
  color: var(--accent);
  filter: drop-shadow(0 1px 6px color-mix(in oklch, var(--accent) 45%, transparent));
}

/* "'s Atelier" reads as part of the same title, not a caption bolted onto
   it, so it stays the same size as "TBaguette" — the italic and reduced
   opacity are what carry the hierarchy instead, and both inherit color
   (rather than pinning one of their own) so .wordmark:hover's color swap
   above still moves the whole title as one piece. */
.wordmark__suffix {
  font-style: italic;
  font-weight: 500;
  opacity: 0.72;
}

/* Brand and version are one flex item, so the header's own wrap breaks between
   brand and actions before it breaks between title and version. Below 332px
   (measured, not guessed) there is genuinely no room for both on one line and
   the version does take its own row -- checked at 320px, where it lands on the
   same left edge the wrapped "updated" metadata and language switcher already
   use, so it reads as part of that stack rather than as a stray. Baseline
   alignment, not centre: the version sits on the title's own baseline.
   `last baseline` specifically, because .wordmark is an inline-flex whose
   *first* baseline comes from the wheat icon and would hang the number high. */
.site-header__brand {
  display: flex;
  align-items: last baseline;
  flex-wrap: wrap;
  gap: var(--space-2);
}

/* --text-1 is the scale's designated meta/mono step, and this reuses the
   same mono + muted treatment the header's "last updated" metadata already
   uses, so the two read as one system rather than two ideas. No glow and no
   hover response: the wordmark above owns both, and a version competing with
   the title it annotates would be a hierarchy failure. */
.wordmark-version {
  font-family: var(--font-mono);
  font-size: var(--text-1);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
  color: var(--text-secondary);
  white-space: nowrap;
}

.site-header__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-4);
}

.site-header__updated {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5em;
  font-size: var(--text-1);
  color: var(--text-secondary);
}

.site-header__updated-label {
  font-family: var(--font-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-accent);
}

.site-header__updated-value { font-variant-numeric: tabular-nums; }

/* The header's only nav link. A pill rather than the mono/uppercase register
   its two neighbours use, and deliberately: everything else in this corner is
   metadata *about* the page (version, build time) and reads as label-plus-value.
   This is the one thing here you can go somewhere with, so it borrows the
   footer's category-pill treatment instead — same border, same radius, same
   hover — which is the shape this site already uses to mean "navigable". */
.site-header__nav-link {
  display: inline-block;
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-2);
  color: var(--text-secondary);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  white-space: nowrap;
  transition: color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out);
}

.site-header__nav-link:hover {
  color: var(--text-accent);
  border-color: var(--border-strong);
}

/* On its own page the link stays a link (it still scrolls you back to the
   top of it) but stops advertising itself as somewhere to go. Colour plus
   border, not colour alone: aria-current already carries this for a screen
   reader, and the visual half should not rest on hue for everyone else. */
.site-header__nav-link[aria-current="page"] {
  color: var(--text-accent);
  border-color: var(--border-strong);
  background: color-mix(in oklch, var(--accent) 10%, transparent);
}

.theme-toggle {
  position: relative;
  flex-shrink: 0;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: var(--radius-full);
  border: 1px solid var(--border-subtle);
  color: var(--text-secondary);
}

.theme-toggle:hover {
  color: var(--text-accent);
  border-color: var(--border-strong);
  background: var(--surface);
}

.theme-toggle__icon {
  grid-area: 1 / 1;
  opacity: 0;
  transform: scale(0.6) rotate(-40deg);
}

.theme-toggle__icon--moon { opacity: 1; transform: none; }
[data-theme='flour'] .theme-toggle__icon--sun { opacity: 1; transform: none; }
[data-theme='flour'] .theme-toggle__icon--moon { opacity: 0; transform: scale(0.6) rotate(40deg); }

/* ---------------------------------------------------------------------------
   8. Hero + search
   ------------------------------------------------------------------------- */

/* The 1.0.0 milestone plaque, first thing in the hero.

   It renders only while the plugin version is exactly 1.0.0 -- see
   templates._render_milestone -- so it retires itself on the next release
   instead of needing a second commit to take it down. That is deliberate:
   a celebration banner nobody remembered to remove is what makes the next
   visitor read the whole site as stale.

   The texture is the one piece of pure decoration here, and it is the reason
   this looks like this site rather than like any other release banner:
   repeating diagonal slashes standing in for the scoring cuts across a
   baguette's crust. It lives on a ::before layer behind the content, masked
   so it fades out well before it reaches the text, and it carries no
   information at all -- everything the plaque says, it says in words. */
.milestone {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-4) var(--space-6);
  margin-bottom: var(--space-7);
  padding: var(--space-5) var(--space-6);
  border: 1px solid var(--border-subtle);
  border-inline-start: 3px solid var(--accent);
  border-radius: var(--radius-md);
  /* The tint is theme-aware because the two themes have completely different
     amounts of room, and a single percentage that suits one sits on a cliff in
     the other. Every percent of gold mixed into the surface closes the gap
     against the gold border and the gold numeral from both ends, since the
     background moves toward the very hue they are made of.

     Crust has room to spare -- measured at 6%: border and numeral 6.5:1, body
     7.4:1 -- so it takes the richer tint.

     Flour does not, and the reason is in the palette rather than in this
     component: --accent resolves to gold-700 there, and gold-700 on a flour
     surface is a ~3.2:1 pairing at *zero* mix (the same measurement the
     --gold-800 note at the top of this file records). 6% spent almost all of
     that on tint and landed at 3.01:1 against a 3:1 floor, with 8% already
     failing at 2.95:1. 3% keeps the tint visible and leaves real margin:
     border 3.11:1, numeral 4.71:1, body 5.12:1. */
  background: color-mix(in oklch, var(--accent) 6%, var(--surface));
  opacity: 0;
  animation: hero-rise var(--duration-view) var(--ease-out) forwards;
}

[data-theme='flour'] .milestone {
  background: color-mix(in oklch, var(--accent) 3%, var(--surface));
}

.milestone::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: repeating-linear-gradient(
    115deg,
    transparent 0 22px,
    color-mix(in oklch, var(--accent) 34%, transparent) 22px 24px
  );
  opacity: 0.3;
  -webkit-mask-image: linear-gradient(to left, #000, transparent 62%);
  mask-image: linear-gradient(to left, #000, transparent 62%);
  pointer-events: none;
}

.milestone__mark {
  flex-shrink: 0;
  font-family: var(--font-display);
  font-optical-sizing: auto;
  /* Fraunces ships oldstyle figures on some axes; a version number wants
     lining, and tabular keeps the two zeroes optically identical. */
  font-variant-numeric: lining-nums tabular-nums;
  font-size: clamp(2rem, 1.3rem + 2.4vw, 3.25rem);
  font-weight: 700;
  line-height: var(--leading-tight);
  letter-spacing: -0.02em;
  color: var(--text-accent);
}

.milestone__body {
  flex: 1 1 24ch;
  min-width: 0;
}

/* The plaque clips (overflow: hidden holds the texture and the sheen inside the
   rounded corners), so a token too long to wrap does not overflow visibly -- it
   silently disappears at the edge. Measured at a 320px viewport: a 57-character
   unbreakable word lost its tail with no scrollbar to reveal it. English copy
   never reaches that, which is exactly why it would have shipped; a translation,
   a pasted URL, or a long skill slug all can. */
.milestone__title,
.milestone__text {
  overflow-wrap: anywhere;
}

.milestone__title {
  font-size: var(--text-4);
  font-weight: 600;
  line-height: var(--leading-tight);
  color: var(--text-primary);
}

.milestone__text {
  margin-top: var(--space-2);
  max-width: 58ch;
  font-size: var(--text-2);
  line-height: var(--leading-relaxed);
  color: var(--text-secondary);
}

.milestone__link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
  padding: var(--space-3) var(--space-5);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-full);
  background: var(--surface);
  color: var(--text-accent);
  font-size: var(--text-2);
  font-weight: 500;
  text-decoration: none;
  transition: background-color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out),
              color var(--duration-micro) var(--ease-out);
}

.milestone__link:hover {
  background: var(--surface-hover);
  border-color: var(--accent);
  color: var(--accent-hover);
}

.milestone__link:active {
  background: var(--surface-hover);
  border-color: var(--accent);
}

.milestone__link-arrow {
  transition: transform var(--duration-micro) var(--ease-out);
}

.milestone__link:hover .milestone__link-arrow {
  transform: translateX(2px);
}

@media (max-width: 33rem) {
  .milestone {
    padding: var(--space-4) var(--space-5);
    gap: var(--space-3) var(--space-5);
  }

  .milestone__link {
    width: 100%;
    justify-content: center;
  }
}

.hero {
  padding-block: var(--space-8) var(--space-9);
}

.hero__headline {
  margin-top: 0;
  max-width: 18ch;
  font-size: var(--text-7);
  font-weight: 620;
  letter-spacing: -0.015em;
  opacity: 0;
  animation: hero-rise var(--duration-view) var(--ease-out) forwards;
}

.hero__lede {
  margin-top: var(--space-5);
  max-width: 52ch;
  font-size: var(--text-4);
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-style: italic;
  font-weight: 400;
  color: var(--text-secondary);
  line-height: var(--leading-relaxed);
  opacity: 0;
  animation: hero-rise var(--duration-view) var(--ease-out) 80ms forwards;
}

.hero__jump {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-5);
  padding: var(--space-3) var(--space-5);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  color: var(--text-secondary);
  font-size: var(--text-2);
  text-decoration: none;
  /* Continues the hero's stagger: headline, lede at 80ms, this at 140ms. Without
     the matching entry in the prefers-reduced-motion block below, opacity: 0 here
     would never be undone and the control would simply never appear. */
  opacity: 0;
  animation: hero-rise var(--duration-view) var(--ease-out) 140ms forwards;
  transition: border-color var(--duration-micro) var(--ease-out),
              color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.hero__jump:hover {
  color: var(--text-accent);
  border-color: var(--border-strong);
  background: var(--surface);
}

/* No transform on :active. hero-rise fills forwards, and an animation's value
   beats a normal author declaration, so a translate here would silently do
   nothing once the entrance has finished. */
.hero__jump:active {
  background: var(--surface-hover);
  border-color: var(--border-strong);
}

.hero__jump-icon {
  flex-shrink: 0;
}

/* Smooth only where the visitor has not asked for less motion; the jump still
   lands in exactly the same place either way. */
@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

/* The jump target. 50px is an explicit request rather than a scale step, which is
   why it is a literal and not a --space-* token. */
#skills {
  scroll-margin-top: 50px;
}

@keyframes hero-rise {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

.install-frame {
  margin-top: var(--space-6);
  max-width: 46rem;
  overflow: hidden;
  background: var(--surface);
  border: 1px solid color-mix(in oklch, var(--accent) 42%, transparent);
  border-radius: var(--radius-lg);
  box-shadow:
    0 0 0 1px color-mix(in oklch, var(--accent) 12%, transparent),
    0 20px 44px -20px color-mix(in oklch, var(--accent) 70%, transparent);
  opacity: 0;
  animation: hero-rise var(--duration-view) var(--ease-out) 40ms forwards;
  transition: border-color var(--duration-enter) var(--ease-out),
              box-shadow var(--duration-enter) var(--ease-out);
}

.install-frame:focus-within {
  border-color: var(--accent);
  box-shadow:
    0 0 0 1px color-mix(in oklch, var(--accent) 25%, transparent),
    0 24px 52px -18px color-mix(in oklch, var(--accent) 85%, transparent);
}

.install-frame__label {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  padding: var(--space-4) var(--space-5);
  background:
    radial-gradient(120% 180% at 0% 0%,
      color-mix(in oklch, var(--accent) 18%, transparent),
      transparent 70%),
    var(--surface-hover);
  border-bottom: 1px solid var(--border-subtle);
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-size: var(--text-3);
  font-weight: 620;
  color: var(--text-primary);
}

.install-frame__label .icon { width: 1.15em; height: 1.15em; color: var(--text-accent); }

.install-frame__body { padding: var(--space-5); }

.install-frame__note {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  margin-top: var(--space-4);
  font-size: var(--text-1);
  color: var(--text-secondary);
}

.install-frame__note .icon {
  flex-shrink: 0;
  width: 1.1em;
  height: 1.1em;
  margin-top: 0.15em;
  color: var(--text-accent);
}

.install-frame__note a {
  color: var(--text-accent);
  text-decoration: underline;
  text-decoration-color: color-mix(in oklch, var(--text-accent) 45%, transparent);
  text-underline-offset: 0.2em;
  border-radius: 3px;
  transition: text-decoration-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.install-frame__note a:hover {
  text-decoration-color: currentColor;
  background: color-mix(in oklch, var(--text-accent) 12%, transparent);
}

/* The three notes above this one all answer "is this safe / what do I do to
   finish installing". This one answers "how do I use it", which is a
   different question and a different destination, so it gets a hairline to
   sit behind rather than becoming a fourth item in a list it isn't part of. */
.install-frame__note--pointer {
  margin-top: var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--border-subtle);
}

.install {
  display: flex;
  align-items: stretch;
  gap: var(--space-2);
}

.install__command {
  flex: 1;
  min-width: 0;
  padding: var(--space-4);
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  font-size: var(--text-2);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  /* The install prompt runs to 7 numbered steps — capped to ~5 lines so the
     frame doesn't dominate the hero, with the rest one scroll away. Height
     is derived from the same tokens the text itself uses (font-size *
     leading, plus the block's own padding and border) rather than a bare
     pixel guess, so it stays correct if either token ever changes. */
  max-height: calc(5 * var(--text-2) * var(--leading-normal) + 2 * var(--space-4) + 2px);
  overflow-y: auto;
}

.install__copy {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
  padding-inline: var(--space-4);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: var(--text-1);
  transition: border-color var(--duration-micro) var(--ease-out),
              color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.install__copy:hover {
  color: var(--text-accent);
  border-color: var(--border-strong);
  background: var(--surface);
}

.install__copy-icons {
  position: relative;
  display: grid;
  place-items: center;
  width: 1.5em;
  height: 1.5em;
}

.install__copy-icon {
  grid-area: 1 / 1;
  transition: opacity var(--duration-micro) var(--ease-out),
              transform var(--duration-micro) var(--ease-out);
}

.install__copy-icon--check { opacity: 0; transform: scale(0.6); }

.install__copy.is-copied .install__copy-icon--copy { opacity: 0; transform: scale(0.6); }
.install__copy.is-copied .install__copy-icon--check { opacity: 1; transform: none; color: var(--text-accent); }
.install__copy.is-copied { color: var(--text-accent); border-color: var(--border-strong); }

.install__hint {
  margin-top: var(--space-2);
  font-size: var(--text-1);
  color: var(--text-secondary);
}

.search {
  margin-top: var(--space-7);
  max-width: 34rem;
}

.search__field {
  position: relative;
  display: flex;
  align-items: center;
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  transition: border-color var(--duration-micro) var(--ease-out),
              box-shadow var(--duration-micro) var(--ease-out);
}

.search__field:focus-within {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 30%, transparent);
}

.search__icon {
  margin-inline-start: var(--space-4);
  color: var(--text-secondary);
}

.search__input {
  flex: 1;
  padding: var(--space-4);
  font-size: var(--text-3);
  color: var(--text-primary);
}

.search__input::placeholder { color: var(--text-secondary); }

/* The field wrapper is this input's focus indicator — :focus-within above
   lights its border and glow — so the input opts out of the global
   :focus-visible outline. Left in, that outline draws a whole second ring:
   the input is the flex child *after* the icon, so its box starts inset from
   the field's left edge, and outline-offset then pushes the ring back out
   over the field's own border. Two mismatched rounded rectangles, where the
   design asks for one border getting brighter. Opting out is safe here
   because the wrapper treatment clears the 3:1 an indicator needs (--accent
   sits at 8.2:1 on --bg) — except under a forced palette, which drops
   box-shadow and overrides border-color outright, leaving :focus-within
   nothing to show. There, put a real outline back on the wrapper. */
.search__input:focus-visible { outline: none; }

@media (forced-colors: active) {
  .search__field:focus-within {
    outline: 2px solid;
    outline-offset: 2px;
  }
}

.search__clear {
  margin-inline-end: var(--space-2);
  padding: var(--space-1) var(--space-3);
  font-size: var(--text-2);
  color: var(--text-secondary);
  border-radius: var(--radius-sm);
}

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

.search__status {
  margin-top: var(--space-3);
  font-size: var(--text-2);
  color: var(--text-secondary);
}

.search__empty {
  margin-top: var(--space-6);
  padding: var(--space-6);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-md);
  color: var(--text-secondary);
}

.search__empty[hidden] { display: none; }

.search__empty-query { color: var(--text-primary); font-weight: 600; }

/* ---------------------------------------------------------------------------
   8b. "Fresh from the oven" — recently new/updated skills, above the search

   Sits between the lede and the search field, directly under .install-frame.
   Its tiles are literally .card — the same component the grid below uses,
   name/badge/summary/category/arrow and all — rather than a rail-only
   design, so this rail reads as a preview of that grid and not a second,
   differently-styled one. "fresh__tile" adds only what a card needs to sit
   in a rail instead of the grid: sizing, and (below) the rail's own
   positioning. Rendered only when something is actually fresh (see
   _render_fresh_section), so there is no empty state to style.

   max-width here used to match .install-frame's own 46rem exactly, so that
   this section's horizontal centre — which the coverflow below centres its
   tiles on — sat directly over the install frame's. Both live inside the
   full-width .container, and with no cap at all that centre lands at the
   container's centre, well to the right of where .install-frame and
   .search, both narrower and left-aligned, actually sit. So a cap is still
   needed; it just no longer has to be 46rem exactly.

   53rem is the width at which the five-card fan stops being clipped, and
   it was measured rather than picked: the outermost pair of cards has
   72px of itself exposed at 46rem, 97px at 49rem, 121px at 52rem and
   126px at 53rem, which is the whole of what it will ever show — 54rem
   and 56rem are identical to 53rem and add only empty rail. Below 53rem
   the rail's own edge is what cuts the outer cards short, which is what
   made the fan read four cards wide with a sliver on the end.

   The cost is that the fan's centre now sits 3.5rem right of the install
   frame's rather than on top of it. That is the same *kind* of drift the
   original cap existed to prevent, but a small fraction of it — an
   uncapped section drifts far further — and it buys the outer pair most
   of its width back. The heading and tag above are left-aligned and do
   not move, so the section still starts on the page's left margin.

   That tray also, wherever motion and space are both welcome, becomes a
   coverflow rather than a flat scroller: one tile flat and full-size at
   centre, its neighbours tilted away and shrinking on each side, stepping
   sideways on a timer (site.js's initFreshCoverflow — the stepping itself
   needs a clock, which is the one thing CSS alone can't keep, so this is
   the rare feature here with a JS half). --cf-offset (set inline by
   templates.py at render time, then kept live by site.js) is each tile's
   signed distance from centre; every visual property below is a plain
   calc() of that one number, so "make tile N active" is just writing new
   offsets, not swapping classes. See the gated rule below for the three
   conditions — motion, width, forced-colors — that all have to hold before
   it forms; missing any one of them leaves the original flat rail exactly
   as it was, not a broken coverflow. A visitor with no JS at all still gets
   the offsets templates.py rendered at build time — a real, if motionless,
   coverflow — never the flat rail by accident.

   The clock isn't the only way a tile becomes active: initFreshCoverflow
   also wires up click-and-drag (grab the ring, spin it, release to settle
   on whichever tile ends up nearest centre) and the prev/next buttons.
   Clicking a tile is not one of those ways — every tile is a link and a
   click on any of them, centred or not, just follows it to its skill.
   cursor/touch-action/user-select below exist for the drag, not for
   anything this file alone renders.
   ------------------------------------------------------------------------- */

.fresh { margin-top: var(--space-7); max-width: 53rem; }

.fresh__head {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  padding-bottom: var(--space-3);
}

/* The gilded rule again — same fading-at-both-ends gesture as the header's
   bottom edge and the update modal's top, so the three read as one idea
   rather than three separate horizontal lines. */
.fresh__head::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: linear-gradient(to right,
    color-mix(in oklch, var(--accent) 70%, transparent),
    color-mix(in oklch, var(--accent) 22%, transparent) 55%,
    transparent);
}

/* icon-grain rather than the loaf: icon-crust is already carrying the
   install frame's label a few hundred pixels up, and the same mark twice in
   one viewport reads as a repeated element rather than a shared system. */
.fresh__icon { color: var(--accent); width: 1.75em; height: 1.75em; }

/* margin: 0 is load-bearing, not tidiness: the global h2 rule carries
   margin-top: var(--space-8), and inside an align-items: center flex row
   that margin is part of the item's box — the box centres, the text does
   not, and the heading drops half a line below the icon beside it. */
.fresh__title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--text-4);
  font-weight: 620;
  color: var(--text-primary);
}

/* Left on the neutral .tag treatment on purpose. Gold in this band belongs
   to the New/Updated badges, which carry information; the timeframe is a
   label, and a third gold pill next to them flattens the distinction. */
.fresh__tag { align-self: center; }

/* margin-inline-start: auto is the whole layout: it pushes the pair to the
   far end of .fresh__head's flex row without needing its own positioning
   context, and wraps under everything else on a narrow measure instead of
   overlapping it. [hidden] still needs the attribute selector even though
   the element also starts with the hidden attribute in markup — browsers
   already do this by default, but section 15 forces display back on for
   reduced-motion overrides elsewhere in this file, and repeating the rule
   locally means this one never depends on cascade order with those. */
.fresh__nav {
  display: flex;
  gap: var(--space-2);
  margin-inline-start: auto;
}

.fresh__nav[hidden] { display: none; }

.fresh__nav-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  font-size: var(--text-4);
  line-height: 1;
  color: var(--text-secondary);
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  transition: color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.fresh__nav-btn:hover,
.fresh__nav-btn:focus-visible {
  color: var(--text-accent);
  border-color: var(--accent);
  background: var(--surface-hover);
}

/* Same scroll-rail mechanics as .tabs__list: horizontal overflow with a
   mask fading the right edge, so a cut-off tile reads as "more this way"
   rather than as a broken layout. With fewer tiles than fill the width,
   nothing reaches the fade zone and the mask is invisible — self-correcting,
   no JS needed to decide whether to draw it. */
.fresh__rail {
  display: flex;
  gap: var(--space-3);
  overflow-x: auto;
  padding-block: var(--space-4);
  scrollbar-width: thin;
  scroll-snap-type: x proximity;
  -webkit-mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%);
  mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%);
}

/* Sizing and rail membership only — .card (section 9) already supplies the
   look (background, padding, border, hover lift) now that the tile carries
   that class too. 17rem matches .card-grid's own minmax() floor, so a rail
   tile is never smaller than the smallest a grid card is ever allowed to
   be. */
.fresh__tile {
  scroll-snap-align: start;
  flex: 0 0 17rem;
}

@media (prefers-reduced-motion: no-preference) {
  .fresh { animation: hero-rise var(--duration-view) var(--ease-out) 120ms backwards; }
}

/* No .fresh__tile-specific reduced-motion or forced-colors rules here on
   purpose: now that every tile is a .card, section 9's own .card handling
   (a plain solid border that forced-colors remaps fine unaided, and the
   sitewide reduced-motion block that already lists .card) already covers
   the rail's tiles. Only .fresh__head still needs its own forced-colors
   rule, for the gradient rule .card never had to begin with. */
@media (forced-colors: active) {
  .fresh__head::after { display: none; }
  .fresh__head { border-bottom: 1px solid CanvasText; }
}

/* Wherever a visitor's browser and stated preferences allow it — motion
   welcome, a viewport with room to spare, no forced system palette — the
   rail becomes the card stack described above instead of staying a flat
   scroller. All three conditions live in one query on purpose: it is the
   single on/off switch for the whole effect, so there is exactly one place
   that decides whether it runs, and every rule above stays a complete,
   already-tested fallback with nothing left to override piecemeal. */
@media (prefers-reduced-motion: no-preference) and (min-width: 640px) and (forced-colors: none) {
  .fresh__rail {
    position: relative;
    display: block;
    height: 22rem;
    perspective: 900px;
    /* The base rule's `overflow-x: auto` has to go -- a scroller would fight
       the drag handler -- but plain `visible` let the fan push the whole page
       sideways. A tile sits 11rem either side of the rail's centre and
       translates up to 280px further, so its far edge lands 456px from centre
       whatever the rail measures; once the rail's own half-width drops below
       that (anywhere under roughly 900px of viewport, worst at the 640px
       breakpoint where this effect switches on) the surplus became real
       document scroll -- measured at 768px: 5px of horizontal scrollbar on a
       page with nothing to scroll to.

       `clip` rather than `hidden` because hidden would make this a scroll
       container and re-introduce the scroller the drag handler replaces, and
       because clip is the axis-independent one: `overflow-y` stays visible
       here without being coerced to auto, which `hidden` would have done.
       The clip margin is what keeps this a scroll fix rather than a visual
       change -- 280px is the translate clamp above, so every tile still
       paints its full spread and simply stops contributing scrollable
       overflow. Bleeding off the viewport edge is the intended look; a
       scrollbar to chase it was never part of it. */
    overflow-x: clip;
    overflow-y: visible;
    overflow-clip-margin: 280px;
    -webkit-mask-image: none;
    mask-image: none;
    cursor: grab;
    /* Lets the page's own vertical scroll keep working with a finger
       starting on the carousel; site.js's pointer handlers claim the
       horizontal axis themselves rather than asking the browser to. */
    touch-action: pan-y;
  }

  .fresh__rail:active { cursor: grabbing; }

  /* --cf-offset (set inline by templates.py, kept live by site.js) is the
     only per-instance number here — everything else is a calc() of it.
     --cf-abs is the max()-of-itself-and-its-negation trick for absolute
     value: scale/opacity/stacking care how *far* a tile is from centre,
     rotation and position care which *side* it's on, so the signed and
     unsigned forms are each used where they mean something. The per-step
     magnitudes (140px, 34deg) are deliberately steep — a flat, prominent
     centre card with neighbours that fall away fast reads as a coverflow;
     gentler steps just look like a slightly skewed row.

     The fan is exactly five cards wide: at 140px a step the clamp is
     reached at offset 2, so 0, ±1 and ±2 are the only distinct positions
     and everything from 3 out stacks precisely on top of the 2s.

     How much of those five actually shows is a separate question, and the
     answer is set by the rail's width, not by the step. The fan wants
     352 + 2x120 + 2x126 = 848px to lay out unclipped, which is why .fresh
     is capped at 53rem above; at the 46rem it used to be, the rail's own
     edge cut the outer pair down to 72px each and the fan read four cards
     wide with a sliver on the end. overflow-clip-margin does not rescue
     that — it widens the paint region, but hit-testing still stops dead at
     the padding box, so anything spilling past the rail edge would be
     visible and unclickable, the wrong trade now that a click navigates.

     Raising the step is the tempting fix and it backfires, measured: at
     170px a step the outermost pair drops from 72px of exposure to 39, at
     200px to 6, because a wider step pushes offset 2 further into the edge
     that is already clipping it. Room comes from the rail, never from
     spreading these further apart.

     So legibility is what the falloffs buy, and the opacity one is
     quadratic rather than linear like everything else here for a specific
     reason: it has to reach *zero* by offset 3 — not merely low — because
     a 3 sits exactly behind a 2 and any residue shows as a doubled, dirty
     edge. A linear ramp hitting zero at 3 is forced to about a third at
     offset 2, and a third is a smudge, not a card; that was the fan
     reading three wide with two ghosts on the ends. Squaring holds the
     near cards up and drops the far ones off a cliff: 1, 0.885, 0.54,
     then 0. Scale softens to match (0.13 a step: 1, 0.87, 0.74) so the
     outer pair keeps some mass instead of shrinking out of legibility.

     Selectors here are the type-qualified `a.fresh__tile`, not the bare
     class, on purpose: since a tile is also a .card, and section 9's
     .card / .card:hover rules sit later in this file, a same-specificity
     bare `.fresh__tile` would lose the cascade to them — the coverflow's
     own transform and transition would be silently overridden by .card's.
     `a.fresh__tile` outranks `.card` regardless of source order, so this
     doesn't depend on staying below section 9 in the file.

     --cf-transform exists as its own custom property, not inlined straight
     into `transform`, purely so the :hover/:focus-visible rule below can
     restate the identical value: a plain hover rule setting `transform`
     directly would replace this positioning outright rather than add to
     it — the trap the ring version's animations sat in, two reworks ago.

     width is explicit and load-bearing, not tidiness: the base rule sizes
     .fresh__tile via `flex: 0 0 17rem`, which only means anything while
     .fresh__rail is still a flex container. The instant this block's
     `.fresh__rail { display: block }` takes over, flex-basis stops doing
     anything, and an absolutely positioned box with no explicit width
     shrink-to-fits — which, for a flex-column card whose summary text has
     no natural wrap point to fall back to, measured out to the entire
     remaining width of the rail rather than anything card-shaped.

     22rem/20rem (not the base rule's 17rem card width) are measured, not
     guessed: with real skill summaries — some 130-180 characters — a card
     narrower than this clips real content under overflow: hidden below,
     the exact bug this size fixes. Measured every currently-fresh tile's
     true unclipped height (transform: none, so an in-progress transition
     retargeting mid-scale doesn't read as shorter than the tile actually
     is — it very nearly did, the first time this was measured) across
     increasing widths; going past 22rem keeps helping but each further
     rem buys less than the last, so this is a point on a curve, not a
     plateau — picked because it's already a real jump from 17rem and the
     remaining gain per rem past it is small. 20rem then covers that
     width's measured worst case (306px) with a little headroom, so
     overflow: hidden below is a safety net for a pathological future
     summary, not a rule the typical case already needs. */
  a.fresh__tile {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 22rem;
    margin: -10rem 0 0 -11rem;
    height: 20rem;
    overflow: hidden;
    flex: none;
    scroll-snap-align: none;
    /* A drag crossing tile text would otherwise paint a text-selection
       highlight instead of spinning the ring. -webkit-user-drag covers
       Chrome/Safari's native "drag this link" gesture; templates.py's
       draggable="false" is the one that actually holds in Firefox too. */
    user-select: none;
    -webkit-user-drag: none;
    /* A tile is a link first — clicking one follows it to its skill, the
       same as every other card on this page — so it gets `pointer` rather
       than inheriting the rail's `grab`. The rail keeps `grab` for the
       space around the tiles, where dragging is the only thing on offer,
       and the rule below puts `grabbing` back on the tiles themselves for
       as long as a press is actually down on one. */
    cursor: pointer;
    --cf-abs: max(var(--cf-offset), calc(var(--cf-offset) * -1));
    --cf-transform:
      translateX(calc(clamp(-280px, calc(var(--cf-offset) * 140px), 280px) + var(--cf-shift, 0px)))
      rotateY(clamp(-38deg, calc(var(--cf-offset) * -34deg), 38deg))
      scale(clamp(0.55, calc(1 - var(--cf-abs) * 0.13), 1));
    transform: var(--cf-transform);
    opacity: clamp(0, calc(1 - var(--cf-abs) * var(--cf-abs) * 0.115), 1);
    z-index: max(1, calc(20 - var(--cf-abs)));
    /* --fresh-spin-duration exists so a momentum-carrying drag release can
       lengthen just this settle without touching the shared rule: site.js
       sets it inline only on the tiles animating out of a flung release,
       proportional to how much momentum carried them, then clears it once
       that transition finishes. Every other trigger (auto-advance, prev/
       next, focus landing on a tile) never sets it, so it stays at this
       default 0.7s for them — same speed as before momentum existed. */
    --fresh-spin-duration: 0.7s;
    transition: transform var(--fresh-spin-duration) var(--ease-out),
                opacity var(--fresh-spin-duration) var(--ease-out),
                border-color var(--duration-enter) var(--ease-out),
                background-color var(--duration-enter) var(--ease-out),
                box-shadow var(--duration-enter) var(--ease-out);
  }

  /* Held down on a tile, either gesture is still live — the press becomes
     a spin or a click depending on whether it moves — and `grabbing` is
     the honest cursor for that moment. Outranks the base rule's `pointer`
     on specificity alone, so it doesn't depend on source order. */
  .fresh__rail:active a.fresh__tile { cursor: grabbing; }

  a.fresh__tile:hover,
  a.fresh__tile:focus-visible {
    transform: var(--cf-transform);
  }

  /* WCAG 2.2.2: anything that moves on its own for more than five seconds
     needs a way to stop it, and the tile a keyboard visitor lands on must
     be legible rather than tilted and dimmed at the edge of the fan. Both
     live in site.js's initFreshCoverflow (pause on hover/focus-within;
     focus also recentres the focused tile to --cf-offset: 0) rather than
     here, because both are about *when the step timer runs and what it
     writes*, which CSS has no view into — this block only ever renders
     whatever offsets it is handed, by design. */
}

/* ---------------------------------------------------------------------------
   8c. Update notes — what changed, in words, below the rail

   Neighbour to 8b and deliberately its opposite in weight: the rail is the
   loud thing in this band (moving, three-dimensional, twelve cards), so this
   is quiet — one measure of text, no surface, no border box. Two loud
   sections stacked would leave the reader nothing to look at first.

   Shares 8b's head treatment exactly (icon, display-face h2, the gilded rule
   fading right) because they are peers in the same band; repeating it is what
   makes them read as one "what's new" idea rather than two unrelated widgets.

   Native <details> for the older entries. No JS anywhere in this section — it
   sits above the search field, so anything that could fail to initialize here
   would strand the site's primary navigation below a half-rendered block.
   ------------------------------------------------------------------------- */

/* Matches .fresh's measure so the two gilded rules share a right edge — two
   rules of different lengths 350px apart read as a mistake at a glance. */
.notes { margin-top: var(--space-7); max-width: 53rem; }

.notes__head {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  padding-bottom: var(--space-3);
}

.notes__head::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: linear-gradient(to right,
    color-mix(in oklch, var(--accent) 70%, transparent),
    color-mix(in oklch, var(--accent) 22%, transparent) 55%,
    transparent);
}

/* icon-wheat: the third of the three marks, after the install frame's loaf
   and the rail's grain. Same rule as 8b's — no mark appears twice in one
   viewport, or the set reads as a repeated element instead of a system. */
.notes__icon { color: var(--accent); width: 1.75em; height: 1.75em; }

/* margin: 0 for the same load-bearing reason as .fresh__title — the global h2
   margin-top would centre the box and drop the text half a line. */
.notes__title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--text-4);
  font-weight: 620;
  color: var(--text-primary);
}

/* Nothing at the far end of this head, unlike .fresh__head's "Last 48 hours"
   tag. That tag earns its place by naming a real constraint on what the rail
   can contain; this section has no equivalent fact, and inventing something to
   fill the slot would be decoration wearing a label's clothes. */

/* Space between entries, not a border: 8b's tiles already carry every edge
   this band can afford. The date's own weight is what separates one entry
   from the next. */
.notes__list {
  margin: 0;
  /* padding-top, not 0 all round: the head's gilded rule sits on its bottom
     edge, and measured without this the first entry's date began 4px under
     that line -- close enough to read as the rule underlining the date rather
     than closing the head. --space-4 is what .fresh__rail puts under the same
     rule one band up, so the two treat the gesture identically. */
  padding: var(--space-4) 0 0;
  list-style: none;
  display: grid;
  gap: var(--space-6);
}

.notes__list--earlier { margin-top: var(--space-5); }

/* Section 3's `li + li { margin-top: var(--space-2) }` is written for prose
   lists, which are not grids, and it *sums* with the gaps declared here:
   measured before this rule existed, entries sat 32px apart where the scale
   said 24 and bullets 16px apart where it said 8. Neither number is a step
   on the space scale — they are two rules quietly disagreeing. The gap wins,
   because it is the one that knows this is a grid. */
.notes__list > li + li,
.notes__bullets > li + li { margin-top: 0; }

.notes__entry-head {
  margin: 0 0 var(--space-3);
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-1) var(--space-3);
  font-size: var(--text-3);
  font-weight: 500;
  line-height: var(--leading-normal);
}

/* Mono and gold, tabular so a column of dates in the open <details> lines up
   digit-for-digit. The second channel that keeps this from being colour-only
   is the face itself: it is the one monospaced run in the whole band. */
.notes__date {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--text-1);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
  color: var(--text-accent);
  text-transform: uppercase;
}

.notes__entry-title {
  font-family: var(--font-display);
  font-size: var(--text-4);
  font-weight: 600;
  line-height: var(--leading-tight);
  color: var(--text-primary);
  /* break-word, not anywhere: a title is prose and should break at spaces
     wherever it can. This is only the escape hatch for the one long word. */
  overflow-wrap: break-word;
}

/* 66ch, not the section's 53rem: these are sentences, and 53rem of Work Sans
   is ~100 characters a line — past the point where the eye reliably finds the
   start of the next one. The left edge still aligns with everything above it;
   only the ragged right stops short of the rule. */
.notes__bullets {
  margin: 0;
  padding-inline-start: 1.15em;
  max-width: var(--measure-prose);
  display: grid;
  /* Not --space-2: these bullets wrap to two and three lines, and at
     --leading-relaxed the lines inside one bullet already sit ~10px apart.
     An 8px gap would put barely more air between two bullets than between
     two lines of the same bullet, and the group stops reading as a group. */
  gap: var(--space-3);
  color: var(--text-secondary);
  line-height: var(--leading-relaxed);
  /* A changelog is where unbreakable identifiers turn up -- a config key, a
     package name, a flag -- and one of them inside a <code> span is the only
     content here that can be wider than the column it sits in. `anywhere`
     rather than `break-word` because it also shrinks the min-content width,
     which is what stops the token forcing the whole page sideways at 360px
     instead of merely spilling out of its own box. */
  overflow-wrap: anywhere;
}

/* Gold markers, unlike .prose's neutral ones: this list is scanned, not read
   straight through, and the marker column is what the eye returns to. */
.notes__bullets li { padding-inline-start: 0.2em; }
.notes__bullets li::marker { color: var(--accent); }

.notes__bullets code {
  font-family: var(--font-mono);
  font-size: 0.9em;
  color: var(--text-primary);
}

.notes__bullets strong { color: var(--text-primary); font-weight: 600; }

.notes__earlier { margin-top: var(--space-5); }

/* list-style: none removes the disclosure triangle in Firefox and Chrome;
   the ::-webkit- pseudo-element is the same job in older Safari, which
   ignores the property here. Both are needed and neither is redundant. */
.notes__earlier-summary {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) 0;
  list-style: none;
  cursor: pointer;
  font-size: var(--text-2);
  color: var(--text-secondary);
  transition: color var(--duration-micro) var(--ease-out);
}

.notes__earlier-summary::-webkit-details-marker { display: none; }

.notes__earlier-summary:hover,
.notes__earlier-summary:focus-visible { color: var(--text-accent); }

/* Drawn, not a glyph: two borders on a rotated box, so it inherits the
   summary's colour and scales with its type instead of depending on whatever
   the reader's font happens to have at U+203A. Points right when closed, down
   when open — the direction is the state's second channel, alongside the
   label's own "Show N earlier" wording. */
.notes__earlier-summary::before {
  content: '';
  flex: none;
  width: 0.42em;
  height: 0.42em;
  border-top: 1.5px solid currentColor;
  border-right: 1.5px solid currentColor;
  transform: rotate(45deg);
  transform-origin: 55% 45%;
  transition: transform var(--duration-micro) var(--ease-out);
}

.notes__earlier[open] > .notes__earlier-summary::before {
  transform: rotate(135deg);
}

/* ------------------------------------------------------------------ */
/* 8c-bis. Update-notes archive — the dialog the <details> becomes      */
/* ------------------------------------------------------------------ */

/* Everything below only applies once site.js has promoted the disclosure.
   With no script the <details> above is the whole feature and none of this
   is reachable, which is the point: the archive is never a dead control. */

/* A transport wrapper and nothing else: site.js moves this one element to
   carry the list and its footnote into the dialog in a single operation.
   display: contents means it draws no box in either place, so it cannot
   introduce spacing that exists in the dialog and not in the <details>. */
.notes__archive-panel { display: contents; }

/* The promoted trigger. Not the summary's chevron — that glyph means
   "unfolds below," and this no longer does. A framed control with a corner
   mark reads as "opens onto something," which is what happens. */
.notes__archive-trigger {
  margin-top: var(--space-5);
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-4);
  font: inherit;
  font-size: var(--text-2);
  color: var(--text-secondary);
  background: transparent;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.notes__archive-trigger:hover,
.notes__archive-trigger:focus-visible {
  color: var(--text-accent);
  border-color: color-mix(in oklch, var(--accent) 50%, transparent);
  background: color-mix(in oklch, var(--accent) 7%, transparent);
}

/* Two corner strokes rather than a glyph, for the same reason the summary's
   chevron is drawn: it inherits colour and scales with the type instead of
   depending on what the reader's font has at some codepoint. */
.notes__archive-trigger::after {
  content: '';
  flex: none;
  width: 0.55em;
  height: 0.55em;
  border-top: 1.5px solid currentColor;
  border-right: 1.5px solid currentColor;
  border-top-right-radius: 2px;
  transition: transform var(--duration-micro) var(--ease-out);
}

.notes__archive-trigger:hover::after,
.notes__archive-trigger:focus-visible::after { transform: translate(1px, -1px); }

/* --- the dialog itself --------------------------------------------- */

.notes-dialog {
  /* A modal <dialog> is centred by the `margin: auto` in the UA stylesheet,
     against the `inset: 0` it also gets — and this site's reset opens with
     `* { margin: 0 }`, which is an author rule and therefore wins. The result
     is a dialog pinned to the top-left corner with `inset: 0` still resolved,
     so nothing about it looks like a margin problem. Restoring the one
     declaration is the whole of the centring; the mobile sheet below overrides
     it again on purpose.

     Worth knowing for any other element that relies on a UA default: a reset
     with a `*` selector silently removes it, and the symptom shows up nowhere
     near the reset. */
  margin: auto;
  /* max-width is the measure, not a round number: the bullets inside cap at
     --measure-prose and the entry heads run a little wider, so 46rem is the
     narrowest box that never makes a bullet the thing setting the width. */
  width: min(46rem, 100%);
  max-width: min(46rem, 100%);
  /* svh, not vh: on mobile browsers vh is the *largest* viewport, so a
     vh-sized dialog is partly under the address bar until the user scrolls. */
  max-height: min(84svh, 54rem);
  padding: 0;
  overflow: hidden;
  color: var(--text-primary);
  background: var(--surface);
  border: 1px solid color-mix(in oklch, var(--accent) 55%, transparent);
  border-radius: var(--radius-lg);
  /* Same three-layer recipe as .install-frame and .update-modal: a hairline
     gold seat, one real cast shadow from a single light direction, and a wide
     gold ambient so the card looks lit rather than pasted on. */
  box-shadow:
    0 0 0 1px color-mix(in oklch, var(--accent) 12%, transparent),
    0 30px 60px -24px hsl(var(--shadow-ink) / 0.75),
    0 0 80px -28px color-mix(in oklch, var(--accent-solid) 60%, transparent);
}

/* Both states declared, and `display` kept out of the base rule above.
   A closed <dialog> is hidden by exactly one thing: `dialog:not([open]) {
   display: none }` in the UA stylesheet. Any unconditional `display` here is
   an author declaration, so it wins, and the dialog then renders in normal
   flow as the last child of <body> — a second, full-height copy of every
   entry pinned to the bottom of the page, which also makes closing it look
   like it did not work. Both symptoms, one declaration.

   The `:not([open])` rule is belt and braces: it does not depend on the UA
   sheet still saying what it says today, and it is the second time on this
   element that leaning on a UA default has cost a visible bug — the first
   was `margin: auto`, deleted by the `* { margin: 0 }` reset. */
.notes-dialog:not([open]) { display: none; }

.notes-dialog[open] {
  /* Its own scroll container in one row only, so the head and foot stay put
     while the entries move under them. */
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
}

/* The gilded rule, the same gesture the header and .update-modal make —
   fading at both ends rather than running hard corner to corner. */
.notes-dialog::before {
  content: '';
  position: absolute;
  left: 14%;
  right: 14%;
  top: 0;
  height: 1px;
  background: linear-gradient(to right,
    transparent,
    color-mix(in oklch, var(--accent) 80%, transparent) 22%,
    color-mix(in oklch, var(--accent) 80%, transparent) 78%,
    transparent);
}

/* --accent-solid rather than --accent for the bloom, exactly as
   .update-modal-overlay::before explains: everything painted on the scrim is
   lit against a near-black field in *both* themes, and --accent resolves to
   the darker gold-700 in flour, which over this scrim subtracts light instead
   of adding it. */
.notes-dialog::backdrop {
  background:
    radial-gradient(44rem 30rem at 50% 42%,
      color-mix(in oklch, #dda25c 16%, transparent),
      transparent 68%),
    hsl(24 18% 5% / 0.78);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

.notes-dialog__head {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  padding: var(--space-5) var(--space-6) var(--space-4);
}

/* Same fading rule the section head uses one band up, so the dialog reads as
   the same object opened rather than a different component. */
.notes-dialog__head::after {
  content: '';
  position: absolute;
  left: var(--space-6);
  right: var(--space-6);
  bottom: 0;
  height: 1px;
  background: linear-gradient(to right,
    color-mix(in oklch, var(--accent) 70%, transparent),
    color-mix(in oklch, var(--accent) 22%, transparent) 55%,
    transparent);
}

.notes-dialog__icon { color: var(--accent); width: 1.75em; height: 1.75em; flex: none; }

.notes-dialog__title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--text-4);
  font-weight: 620;
  color: var(--text-primary);
}

/* Mono and gold, matching .notes__date — the dialog's one piece of meta text
   should look like the other meta text in the band it came from. */
.notes-dialog__subtitle {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--text-1);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--text-accent);
}

.notes-dialog__close {
  margin-inline-start: auto;
  display: grid;
  place-items: center;
  width: 2.25rem;
  height: 2.25rem;
  flex: none;
  color: var(--text-secondary);
  background: transparent;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  cursor: pointer;
  /* The two strokes below are absolutely positioned; without a containing
     block here they resolve against the dialog and land in its corner. */
  position: relative;
  transition: color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.notes-dialog__close:hover,
.notes-dialog__close:focus-visible {
  color: var(--text-accent);
  border-color: color-mix(in oklch, var(--accent) 50%, transparent);
  background: color-mix(in oklch, var(--accent) 8%, transparent);
}

/* Drawn from two strokes, same reasoning as every other mark in this band --
   there is no close glyph in the sprite, and a Unicode multiplication sign
   would be the one mark on this site not drawn to the same stroke weight.

   Centred from 50%/50% and pulled back by half its own size, rather than left
   to the static position it would get as an abspos child of a grid container.
   That static position is a genuine corner of the grid spec -- it resolves
   through the container's alignment properties -- and engines have disagreed
   about it. Measured, this one does centre them; the failure mode if another
   does not is both strokes in a corner of the button, which reads as a broken
   control rather than as a styling nit. Explicit costs two declarations. */
.notes-dialog__close::before,
.notes-dialog__close::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0.85rem;
  height: 1.5px;
  background: currentColor;
  border-radius: 1px;
}
.notes-dialog__close::before { transform: translate(-50%, -50%) rotate(45deg); }
.notes-dialog__close::after { transform: translate(-50%, -50%) rotate(-45deg); }

/* 2.25rem is a comfortable size for a pointer and under the 44px a finger
   needs, so the circle grows where the pointer is actually coarse. Growing the
   control rather than padding an invisible hit area past it keeps what is
   clickable and what is visible the same shape -- a target that extends beyond
   its own edge is the thing that makes a nearby control feel unreliable, which
   is the complaint this is meant to remove rather than move. */
@media (pointer: coarse) {
  .notes-dialog__close { width: 2.75rem; height: 2.75rem; }
}

.notes-dialog__body {
  position: relative;
  overflow-y: auto;
  /* Scrolling the last entry must not start scrolling the page behind it. */
  overscroll-behavior: contain;
  padding: var(--space-5) var(--space-6) var(--space-6);
}

/* The scroll region is focusable so it can be scrolled from the keyboard —
   without this a keyboard-only reader can tab to the close button and to the
   links, and never move the list. */
.notes-dialog__body:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -4px;
}

/* --- the timeline spine -------------------------------------------- */

/* What makes this read as a record rather than a long list: one hairline
   running the length of the entries, with each date sitting on a node. It
   also gives the scroll a sense of place, which a flat list of twenty dated
   blocks does not have. Fades out at the bottom because the list is a window
   onto UPDATES.md, not the end of it. */
.notes__list--archive {
  position: relative;
  margin-top: 0;
  padding-inline-start: var(--space-6);
}

.notes__list--archive::before {
  content: '';
  position: absolute;
  inset-block: 0.55em 0;
  inset-inline-start: 3px;
  width: 1px;
  background: linear-gradient(to bottom,
    color-mix(in oklch, var(--accent) 55%, transparent),
    color-mix(in oklch, var(--accent) 18%, transparent) 65%,
    transparent);
}

.notes__list--archive > .notes__entry { position: relative; }

.notes__list--archive > .notes__entry::before {
  content: '';
  position: absolute;
  inset-inline-start: calc(-1 * var(--space-6));
  top: 0.4em;
  width: 7px;
  height: 7px;
  border-radius: var(--radius-full);
  background: var(--accent);
  /* A ring in the panel's own colour, so the node reads as sitting on the
     line rather than being threaded by it. */
  box-shadow: 0 0 0 3px var(--surface);
}

/* The newest entry is the one already on the page behind this dialog, so it
   gets the filled node and everything older gets a hollow one — a second
   channel for "you have seen this one" that does not depend on colour. */
/* Full --accent on the ring, not a 70% mix: measured, gold at 70% over the
   flour surface lands around 2.4:1, and while nothing here is carried by the
   node alone -- the date and the ordering already say which entry is newest --
   a marker too faint to resolve is just noise on the spine. At full strength
   it is 3.2:1 in flour and 7.1:1 in crust, both clear of the non-text floor. */
.notes__list--archive > .notes__entry:not(:first-child)::before {
  background: var(--surface);
  border: 1.5px solid var(--accent);
}

.notes__archive-foot {
  margin: var(--space-6) 0 0;
  padding-top: var(--space-4);
  border-top: 1px solid var(--border-subtle);
  font-size: var(--text-2);
  color: var(--text-secondary);
}

/* --- motion --------------------------------------------------------- */

/* The resting state is visible, and @starting-style supplies the from-state
   only at the moment the dialog enters the top layer.

   Written the other way round -- `opacity: 0` in the base rule, `1` under
   [open] -- it animates identically and fails catastrophically: any browser
   that does not run the transition shows an open dialog at zero opacity, so
   the page is scroll-locked behind a scrim with nothing on it and no visible
   way out. This ordering degrades to appearing instantly instead, which is
   the right direction to fail in. The cost is that there is no exit
   animation, since there is no to-state to interpolate toward; an entrance
   is worth having and an exit is not worth that risk. */
@media (prefers-reduced-motion: no-preference) {
  .notes-dialog {
    transition:
      opacity var(--duration-enter) var(--ease-out),
      transform var(--duration-enter) var(--ease-out);
  }
  @starting-style {
    .notes-dialog[open] { opacity: 0; transform: translateY(12px) scale(0.985); }
  }

  .notes-dialog::backdrop {
    transition: opacity var(--duration-enter) var(--ease-out);
  }
  @starting-style {
    .notes-dialog[open]::backdrop { opacity: 0; }
  }
}

/* --- the page behind ------------------------------------------------ */

/* showModal() blocks interaction but does not stop the page scrolling behind
   the scrim, which reads as the dialog sliding around. */
html.has-notes-dialog { overflow: hidden; }

/* --- narrow --------------------------------------------------------- */

/* Below this the panel stops being a card in the middle of a page and becomes
   a sheet held to the bottom edge, which is where a thumb is. */
@media (max-width: 40rem) {
  .notes-dialog {
    max-height: 92svh;
    margin: auto auto 0;
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    border-bottom: 0;
  }
  .notes-dialog__head { padding: var(--space-4) var(--space-4) var(--space-3); }
  .notes-dialog__head::after { left: var(--space-4); right: var(--space-4); }
  .notes-dialog__body { padding: var(--space-4); }
  .notes__list--archive { padding-inline-start: var(--space-5); }
  .notes__list--archive > .notes__entry::before {
    inset-inline-start: calc(-1 * var(--space-5));
  }
}

/* --- forced colours -------------------------------------------------- */

/* Every gradient here is invisible in a forced palette: the gilded rules, the
   spine, and the bloom all disappear. Solid borders in the system's own
   colour are the substitute, and the nodes become CanvasText discs. */
@media (forced-colors: active) {
  .notes-dialog { border: 1px solid CanvasText; }
  .notes-dialog::before { display: none; }
  .notes-dialog__head { border-bottom: 1px solid CanvasText; }
  .notes-dialog__head::after { display: none; }
  .notes__list--archive::before { background: CanvasText; }
  .notes__list--archive > .notes__entry::before {
    background: CanvasText;
    box-shadow: none;
  }
  .notes__list--archive > .notes__entry:not(:first-child)::before {
    background: Canvas;
    border: 1.5px solid CanvasText;
  }
}

/* 160ms: the hero band enters on a 40ms cadence -- headline 0, install 40,
   lede 80, rail 120 -- and this is the next step, not a rounder number. Same
   `backwards` fill as .fresh: the element's resting style is already visible
   (unlike .hero__headline and friends, which set opacity: 0 in their base
   rule), so the from-state has to be applied during the delay or the section
   pops in at full opacity and then animates from nothing. */
@media (prefers-reduced-motion: no-preference) {
  .notes { animation: hero-rise var(--duration-view) var(--ease-out) 160ms backwards; }
}

/* The gradient rule is invisible in a forced palette, same as 8b's — a solid
   border in the system's own colour is the substitute. Nothing else here
   needs help: text, markers and the drawn chevron are all currentColor. */
@media (forced-colors: active) {
  .notes__head::after { display: none; }
  .notes__head { border-bottom: 1px solid CanvasText; }
}

/* ---------------------------------------------------------------------------
   9. Category sections + card grid + card
   ------------------------------------------------------------------------- */

.category-section {
  padding-block: var(--space-8);
  border-top: 1px solid var(--border-subtle);
}

.category-section[hidden] { display: none; }

.category-section__head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-bottom: var(--space-6);
}

.category-section__icon { color: var(--accent); width: 1.75em; height: 1.75em; }

.category-section__title {
  font-size: var(--text-5);
  margin: 0;
}

.category-section__count {
  margin-inline-start: auto;
}

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr));
  gap: var(--space-5);
}

.card {
  display: flex;
  flex-direction: column;
  padding: var(--space-5);
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  transition: transform var(--duration-enter) var(--ease-out),
              border-color var(--duration-enter) var(--ease-out),
              box-shadow var(--duration-enter) var(--ease-out),
              background-color var(--duration-enter) var(--ease-out);
}

.card[hidden] { display: none; }

.card:hover,
.card:focus-visible {
  background: var(--surface-hover);
  border-color: var(--accent);
  transform: translateY(-3px);
  box-shadow:
    0 1px 2px hsl(var(--shadow-ink) / 0.5),
    0 20px 32px -16px color-mix(in oklch, var(--accent) 45%, transparent);
}

.card:focus-visible {
  outline-offset: 4px;
}

.card__name-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}

.card__name {
  font-family: var(--font-mono);
  font-size: var(--text-4);
  font-weight: 500;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

.card__summary {
  margin-top: var(--space-3);
  color: var(--text-secondary);
  font-size: var(--text-3);
  flex: 1;
}

.card__foot {
  margin-top: var(--space-5);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
}

.tag {
  display: inline-flex;
  align-items: center;
  padding: 0.3em 0.7em;
  font-family: var(--font-mono);
  font-size: var(--text-1);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-secondary);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-full);
  white-space: nowrap;
}

.card__arrow {
  color: var(--text-secondary);
  transition: transform var(--duration-micro) var(--ease-out),
              color var(--duration-micro) var(--ease-out);
}

.card:hover .card__arrow,
.card:focus-visible .card__arrow {
  color: var(--text-accent);
  transform: translateX(3px);
}

/* "New"/"Updated" — set by generate.py from a real git status check (see
   its _changed_skill_slugs), shown next to a skill's name on its card
   (.card__name-row) and again on its own page (.skill-article__title-row).
   --new reuses --accent-solid/--text-on-accent-solid deliberately: those
   two are the one pairing this site intentionally keeps identical in both
   themes (see their token comments) — a foil-stamped "new" reads the same
   whether the page is light or dark, which is exactly the point of a badge
   meant to catch the eye first. --updated is calmer on purpose: still
   using the brand accent, but outlined rather than solid-filled, so two
   badges next to each other read as different weights of news, not two
   equally loud alerts. */
.change-badge {
  position: relative;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  padding: 0.25em 0.65em;
  font-family: var(--font-mono);
  font-size: var(--text-1);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  border-radius: var(--radius-full);
  white-space: nowrap;
}

.change-badge--new {
  color: var(--text-on-accent-solid);
  background: var(--accent-solid);
  border: 1px solid var(--accent-solid);
}

.change-badge--updated {
  color: var(--text-accent);
  background: transparent;
  border: 1px solid var(--accent);
}

/* The "shiny" part: a soft diagonal highlight sweeps across once, shortly
   after the page settles, rather than looping forever — a one-time glint
   reads as polish, a looping shimmer reads as nagging. Skipped entirely
   under reduced motion, per this site's motion rule (section 15). */
.change-badge::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg,
    transparent 35%,
    color-mix(in oklch, white 70%, transparent) 50%,
    transparent 65%);
  transform: translateX(-120%);
}

@media (prefers-reduced-motion: no-preference) {
  .change-badge::after {
    animation: badge-shine var(--duration-view) var(--ease-out) 700ms 1 forwards;
  }
}

@keyframes badge-shine {
  from { transform: translateX(-120%); }
  to { transform: translateX(120%); }
}

/* ---------------------------------------------------------------------------
   10. Breadcrumb
   ------------------------------------------------------------------------- */

.breadcrumb {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-2);
  color: var(--text-secondary);
  padding-block: var(--space-5);
}

.breadcrumb a { border-radius: var(--radius-sm); }
.breadcrumb a:hover { color: var(--text-accent); }

.breadcrumb__current {
  font-family: var(--font-mono);
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

.breadcrumb__sep { color: var(--border-strong); }

/* ---------------------------------------------------------------------------
   11. Article: header, lede, prose, tables, code, lists
   ------------------------------------------------------------------------- */

.skill-article { padding-block: var(--space-6) var(--space-9); }

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

.skill-article__title-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
}

.skill-article__title {
  font-family: var(--font-mono);
  font-size: clamp(1.75rem, 1.35rem + 1.8vw, var(--text-6));
  font-weight: 500;
  letter-spacing: -0.01em;
  overflow-wrap: anywhere;
}

.skill-article__tag { margin-top: var(--space-4); }

.lede {
  margin-top: var(--space-6);
  max-width: var(--measure-prose);
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-style: italic;
  font-weight: 400;
  font-size: var(--text-4);
  line-height: var(--leading-relaxed);
  color: var(--text-secondary);
}

.translation-banner {
  margin-top: var(--space-6);
  max-width: var(--measure-prose);
  padding: var(--space-4) var(--space-5);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-md);
  color: var(--text-secondary);
  font-size: var(--text-2);
}

.prose {
  margin-top: var(--space-8);
  max-width: var(--measure-prose);
  font-size: var(--text-3);
  line-height: var(--leading-relaxed);
}

.prose > :first-child { margin-top: 0; }

/* Everything from here down styles content *inside* running prose, and a tab
   panel is running content, so each of these carries .tabs__panel too. The
   container rule above is deliberately not extended: it sets the 66ch measure,
   and a panel is laid out by .tabs, not by the prose column.

   Splitting it that way is the whole lesson from how this was found. The table
   rules further down had drifted out of tab panels unnoticed for as long as
   formidable has had them, because nothing said which half of .prose a panel
   was supposed to inherit — so each new rule got scoped to .prose by default
   and the gap widened one rule at a time. The line is now stated: content
   rules take both selectors, the measure does not. */
.prose h2, .prose h3, .tabs__panel h2, .tabs__panel h3 { scroll-margin-top: var(--space-6); }

.prose p, .tabs__panel p { color: var(--text-primary); }

.prose ul, .prose ol,
.tabs__panel ul, .tabs__panel ol {
  margin-top: var(--space-4);
  padding-inline-start: 1.4em;
}

.prose li, .tabs__panel li { padding-inline-start: 0.2em; }

/* A file path or a skill name inside running prose is one unbreakable token,
   and at 320px `~/.claude/skills/TBaguette/CATALOG.md` is wider than the
   column it sits in — which pushes the whole page sideways, since an inline
   <code> has no overflow container of its own the way .prose-code-block and
   .code-block do. break-word rather than anywhere: it only ever breaks a
   token that would otherwise overflow, so every span that already fits is
   laid out exactly as before. Scoped to .prose so the install frame's own
   command chip, which lives in a real scroll container, keeps scrolling. */
.prose code, .tabs__panel code { overflow-wrap: break-word; }
.prose li::marker, .tabs__panel li::marker { color: var(--text-secondary); }
.prose ul ul, .prose ol ol, .prose ul ol, .prose ol ul,
.tabs__panel ul ul, .tabs__panel ol ol,
.tabs__panel ul ol, .tabs__panel ol ul { margin-top: var(--space-2); }

/* A plain-text code box in running prose: the PowerShell/cmd.exe one-liners
   on the install-verification page, and every fenced ``` block inside a
   skill's own markdown body (commands, short scripts, the update-log
   template — see content_pipeline.py's _consume_fenced_code_block).
   Deliberately not the full .code-block treatment (line numbers, syntax
   highlighting): that component is for displaying real syntax-highlighted
   source, this is supplementary reference plain text, one line or many. */
.prose-code-block {
  display: block;
  margin-top: var(--space-3);
  padding: var(--space-4);
  overflow-x: auto;
  background: var(--surface-hover);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
}

.prose-code-block code {
  padding: 0;
  background: none;
  font-size: var(--text-1);
  white-space: pre;
  color: var(--text-primary);
}

/* The <EXTREMELY-IMPORTANT> block a skill wraps around its one rule that has
   no exceptions -- rendered by content_pipeline's _consume_important_callout.
   Three channels carry "this one is different", so none of them is load-
   bearing alone: the ember hue, the written label, and the panel structure
   itself. Colour is never the only signal, which matters for the ~8% of
   readers with a colour vision deficiency and for anyone in forced-colors.

   Structurally a sibling of .milestone -- same 1px hairline plus a 3px
   inline-start rule, same radius -- because they are the same kind of object
   at different volumes, and the second variant of an idea needs a reason to
   look different, not a reason to look the same. What differs is the hue and
   the label, which is exactly the difference being expressed.

   No entrance animation on purpose. The prose body it sits in does not
   animate, and one component animating alone is a scattered effect rather
   than an authored moment. */
.prose-callout {
  margin-top: var(--space-5);
  padding: var(--space-4) var(--space-5) var(--space-5);
  background: var(--critical-surface);
  border: 1px solid var(--border-subtle);
  border-inline-start: 3px solid var(--critical);
  border-radius: var(--radius-md);
}

/* Deliberately the same metrics as .change-badge: mono, --text-1, weight 500,
   uppercase, 0.06em, full radius. This site already has one small-label
   idiom, and a callout label is not a good enough reason to invent a second.

   Selector carries .prose/.tabs__panel because it has to out-specify
   `.prose p { color: var(--text-primary) }` above -- a bare
   .prose-callout__label loses that tie and the label renders as body text on
   its own ember fill. Not left to source order: order is not a guarantee
   anyone reading this file would know they were relying on. */
.prose .prose-callout__label,
.tabs__panel .prose-callout__label {
  display: inline-flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.45em;
  /* A chip is an inline-flex box, and an inline-flex box sizes to its text
     and then refuses to shrink -- the anonymous flex item holding the label
     is floored at its longest word. Measured at 320px under 400% text-only
     zoom, that put the chip 554px past the prose column while a heading and
     a paragraph beside it sat at 0. `anywhere` rather than `break-word` is
     the operative half: only `anywhere` actually lowers the min-content
     size, which is the number doing the damage. Same fix the base h1-h4
     rule already uses, for the same reason. */
  max-width: 100%;
  overflow-wrap: anywhere;
  margin-top: 0;
  padding: 0.3em 0.75em 0.3em 0.6em;
  color: var(--text-on-critical);
  background: var(--critical);
  font-family: var(--font-mono);
  font-size: var(--text-1);
  font-weight: 500;
  line-height: var(--leading-normal);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  border-radius: var(--radius-full);
}

/* A mask rather than a sprite <use>, and this is the one component on the
   site where that is the right call. content_pipeline emits this markup and
   deliberately knows nothing about the deployment's base path (see its
   make_skill_mention_resolver note), so it cannot write a correct
   /assets/icons.svg href -- while a stylesheet can, because a URL in here
   resolves against the stylesheet's own location. Painted with
   `background: currentColor` so it inherits the label's colour in both
   themes and remaps correctly under forced-colors, exactly as the sprite's
   currentColor strokes do. stroke-width 2 rather than the sprite's 1.5:
   at ~15px the 1.5 weight goes visibly thin against 500-weight mono, and
   optical weight beats nominal weight. */
.prose-callout__label::before {
  content: "";
  width: 1.15em;
  height: 1.15em;
  flex-shrink: 0;
  background: currentColor;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3.6 22 20.4H2Z'/%3E%3Cpath d='M12 10v4.2'/%3E%3Cpath d='M12 17.6h.01'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3.6 22 20.4H2Z'/%3E%3Cpath d='M12 10v4.2'/%3E%3Cpath d='M12 17.6h.01'/%3E%3C/svg%3E");
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-size: contain;
  mask-size: contain;
}

/* Tight inside the group: the label and the rule it introduces are one
   thought, so they sit closer together than the --space-4 a bare `p` would
   take, and closer than the panel's own padding sets them from everything
   outside it. */
.prose-callout__body > :first-child { margin-top: var(--space-3); }

/* In a forced palette the fill and the panel tint both collapse to Canvas,
   which would leave the label as unbounded text and lose the chip entirely.
   The border restores its shape; the glyph and the inline-start rule survive
   unaided (currentColor and a solid border respectively). */
@media (forced-colors: active) {
  .prose .prose-callout__label,
  .tabs__panel .prose-callout__label { border: 1px solid CanvasText; }
}

.prose a, .tabs__panel a {
  color: var(--text-accent);
  text-decoration: underline;
  text-decoration-color: color-mix(in oklch, var(--text-accent) 45%, transparent);
  text-underline-offset: 0.2em;
  border-radius: 3px;
  transition: text-decoration-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.prose a:hover, .tabs__panel a:hover {
  text-decoration-color: currentColor;
  background: color-mix(in oklch, var(--text-accent) 12%, transparent);
}

/* --- skill cross-references -------------------------------------------
   One skill naming another. Two shapes for the same job, because the corpus
   writes the reference two ways: usually as a `code` chip, and as bare prose
   inside the "Not for: ... (other-skill)" line at the top of most skills.

   Deliberately unscoped (a.skill-link, not .prose a.skill-link): the same
   reference has to look the same in a skill's body, inside formidable's tab
   panels, and in the landing page's update notes, which are three different
   containers. Sitting immediately after .prose a is what settles the tie --
   both are one class and one element, so source order decides, and every
   property .prose a sets is restated here rather than left to leak through.

   The chip form carries no permanent underline -- at 350+ of them across the
   site, most packed into reference tables, permanent underlines turn the
   densest pages into a thicket -- so the channel that says "link" has to be
   something else, and it has to be measurable. It is the ring: a hairline
   every linked chip has and no ordinary `code` chip has, which is presence
   against absence rather than one colour against another, and survives a
   reader who cannot separate gold from cream. The underline arrives on hover
   and focus, where it confirms the target.

   Every value below was measured on the built page in both themes, and the
   two that look arbitrary are not. The fill mixes into --bg rather than into
   --surface-hover because the first attempt did the latter and shipped a real
   failure: in the flour theme that tint *darkens* the chip toward --text-accent
   (gold-800, already the palette's dark accent step), closing the gap to
   3.7:1 against a 4.5:1 floor. Mixing toward the page ground moves the fill
   away from the text in whichever direction that theme needs -- darker in
   crust, lighter in flour -- and lands at 7.2:1 and 4.9:1. The ring at 75% of
   --text-accent is the lowest step that clears the 3:1 non-text floor against
   its own fill in *both* themes (4.7:1 and 3.1:1); 60% passes in crust and
   fails in flour, which is exactly the trap the fill fell into.
   --------------------------------------------------------------------- */

a.skill-link {
  color: var(--text-accent);
  text-decoration: none;
  background: none;
  border-radius: 6px;
}

/* inset box-shadow rather than a real border: a border would add 2px to the
   chip's width and nudge every line of a dense table. The forced-colors block
   below swaps in a real one, since a shadow is dropped entirely there. */
a.skill-link > code {
  color: var(--text-accent);
  background: color-mix(in oklch, var(--accent) 10%, var(--bg));
  box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--text-accent) 75%, transparent);
  text-decoration: inherit;
  text-underline-offset: 0.2em;
  transition: box-shadow var(--duration-micro) var(--ease-out);
}

a.skill-link:hover,
a.skill-link:focus-visible {
  text-decoration: underline;
}

/* Hover firms the ring and adds the underline, and deliberately leaves the
   fill alone: the flour theme has only 0.35 of headroom above the contrast
   floor at rest, and spending it on a fill shift nobody would notice next to
   an underline appearing is a bad trade. */
a.skill-link:hover > code,
a.skill-link:focus-visible > code {
  box-shadow: inset 0 0 0 1px var(--text-accent);
}

/* The bare form has no chip to carry the fill and the ring, so it takes the
   site's ordinary inline-link treatment instead -- a permanent soft underline
   that firms up on hover. Colour alone was never an option here. */
a.skill-link--bare {
  text-decoration: underline;
  text-decoration-color: color-mix(in oklch, var(--text-accent) 45%, transparent);
  text-underline-offset: 0.2em;
  transition: text-decoration-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

a.skill-link--bare:hover,
a.skill-link--bare:focus-visible {
  text-decoration-color: currentColor;
  background: color-mix(in oklch, var(--text-accent) 12%, transparent);
}

/* Forced colours flatten every tint and ring above to the system palette, so
   the chip stops being distinguishable by fill. LinkText plus a real border
   is the substitute -- the same information, drawn with the two things a
   forced palette still honours. */
@media (forced-colors: active) {
  a.skill-link > code {
    color: LinkText;
    border: 1px solid LinkText;
    box-shadow: none;
  }
}

/* body_html is injected verbatim (see templates.py) so we can't wrap a
   <table> in a scroll container without parsing content we've been told not
   to re-parse. Instead the table is its own scroll container: `display:
   block` lets `overflow-x: auto` take effect directly on the table element
   while its row/cell children keep their table-layout roles from the UA
   stylesheet, which is what keeps columns aligned inside the scroll area. */
/* Tab panels carry running content too — formidable alone puts 16 tables in
   them — but they are not .prose, so until this selector list included them
   every one of those tables fell back to the bare `border-collapse: collapse`
   in section 3: 1px of default cell padding, no borders, no radius, no font
   step, and (the visible symptom that found this) no overflow container, so a
   table wider than a 320px viewport pushed the whole page sideways instead of
   scrolling inside its own box the way an identical table three sections up
   already did. */
.prose table, .tabs__panel table {
  display: block;
  margin-top: var(--space-6);
  width: 100%;
  max-width: 100%;
  overflow-x: auto;
  font-size: var(--text-2);
  font-variant-numeric: tabular-nums;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
}

.prose th, .prose td,
.tabs__panel th, .tabs__panel td {
  padding: var(--space-3) var(--space-4);
  text-align: start;
  vertical-align: top;
  border-bottom: 1px solid var(--border-subtle);
}

.prose thead th, .tabs__panel thead th {
  font-family: var(--font-body);
  font-weight: 600;
  color: var(--text-primary);
  background: var(--surface);
  border-bottom: 1px solid var(--border-strong);
  white-space: nowrap;
}

.prose tbody tr, .tabs__panel tbody tr {
  transition: background-color var(--duration-micro) var(--ease-out);
}

.prose tbody tr:hover, .tabs__panel tbody tr:hover { background: var(--surface); }
.prose tbody tr:last-child td,
.tabs__panel tbody tr:last-child td { border-bottom: none; }

/* ---------------------------------------------------------------------------
   12. Tabs (formidable stacks / commands)
   ------------------------------------------------------------------------- */

.formidable-extra {
  margin-top: var(--space-9);
  padding-top: var(--space-8);
  border-top: 1px solid var(--border-subtle);
}

.formidable-extra__group + .formidable-extra__group {
  margin-top: var(--space-9);
}

.formidable-extra__subtitle { font-size: var(--text-5); margin-top: 0; }

.tabs { margin-top: var(--space-5); }

.tabs__list {
  display: flex;
  gap: var(--space-2);
  overflow-x: auto;
  padding-bottom: var(--space-2);
  scrollbar-width: thin;
  scroll-snap-type: x proximity;
  -webkit-mask-image: linear-gradient(to right, black calc(100% - 32px), transparent 100%);
  mask-image: linear-gradient(to right, black calc(100% - 32px), transparent 100%);
}

.tabs__tab {
  scroll-snap-align: start;
  flex-shrink: 0;
  padding: var(--space-2) var(--space-4);
  font-size: var(--text-2);
  color: var(--text-secondary);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  white-space: nowrap;
  transition: color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.tabs__tab:hover { color: var(--text-primary); border-color: var(--border-strong); }

.tabs__tab[aria-selected='true'] {
  color: var(--text-on-accent-solid);
  background: var(--accent-solid);
  border-color: var(--accent-solid);
  font-weight: 500;
}

.tabs__panels { margin-top: var(--space-6); }

.tabs__panel {
  max-width: var(--measure-prose);
  font-size: var(--text-3);
  line-height: var(--leading-relaxed);
  animation: panel-in var(--duration-enter) var(--ease-out);
}

.tabs__panel[hidden] { display: none; }
.tabs__panel > :first-child { margin-top: 0; }

@keyframes panel-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* ---------------------------------------------------------------------------
   13. Prev/next + see-also
   ------------------------------------------------------------------------- */

.prevnext {
  margin-top: var(--space-9);
  padding-top: var(--space-6);
  border-top: 1px solid var(--border-subtle);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
}

.prevnext__link {
  flex: 1 1 16rem;
  padding: var(--space-4) var(--space-5);
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  transition: border-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.prevnext__link:hover, .prevnext__link:focus-visible {
  border-color: var(--accent);
  background: var(--surface-hover);
}

.prevnext__link--next { text-align: end; }

.prevnext__label {
  display: block;
  font-size: var(--text-1);
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.prevnext__name {
  display: block;
  margin-top: var(--space-1);
  font-family: var(--font-mono);
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

.see-also {
  margin-top: var(--space-8);
}

.see-also__title { font-size: var(--text-4); }

.see-also__list {
  margin-top: var(--space-4);
  list-style: none;
  padding-inline-start: 0;
}

.see-also__list li {
  padding-inline-start: 0;
  border-top: 1px solid var(--border-subtle);
}

.see-also__list li:first-child { border-top: none; }
.see-also__list li + li { margin-top: 0; }

.see-also__link {
  display: block;
  padding-block: var(--space-4);
  border-radius: var(--radius-sm);
}

.see-also__link:hover .see-also__name,
.see-also__link:focus-visible .see-also__name { color: var(--text-accent); }

.see-also__name {
  font-family: var(--font-mono);
  color: var(--text-primary);
  transition: color var(--duration-micro) var(--ease-out);
}

.see-also__summary {
  margin-top: var(--space-1);
  color: var(--text-secondary);
  font-size: var(--text-2);
}

/* ---------------------------------------------------------------------------
   14. Footer
   ------------------------------------------------------------------------- */

.site-footer {
  margin-top: var(--space-9);
  background: var(--bg-deep);
  border-top: 1px solid var(--border-subtle);
}

.site-footer__inner {
  padding-block: var(--space-8);
  display: grid;
  gap: var(--space-7);
}

@media (min-width: 720px) {
  .site-footer__inner {
    grid-template-columns: minmax(0, 26rem) 1fr;
  }
}

.site-footer__brand {
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-size: var(--text-4);
  font-style: italic;
  color: var(--text-secondary);
  max-width: 42ch;
}

.site-footer__brand strong {
  font-style: normal;
  color: var(--text-primary);
}

/* Brand and pointer are one grid cell, not two children of the footer grid.
   That grid is two columns at >=720px, so a third child would place this by
   auto-flow accident, and on mobile it would fall *after* the category list —
   the one width where someone scrolling to the bottom is most plausibly
   looking for where to start. */
.site-footer__brand-col {
  display: grid;
  align-content: start;
  gap: var(--space-4);
}

.site-footer__start {
  font-size: var(--text-2);
  color: var(--text-secondary);
}

.site-footer__start a {
  color: var(--text-accent);
  text-decoration: underline;
  text-decoration-color: color-mix(in oklch, var(--text-accent) 45%, transparent);
  text-underline-offset: 0.2em;
  border-radius: 3px;
  transition: text-decoration-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.site-footer__start a:hover {
  text-decoration-color: currentColor;
  background: color-mix(in oklch, var(--text-accent) 12%, transparent);
}

.site-footer__nav-title {
  font-family: var(--font-mono);
  font-size: var(--text-1);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-secondary);
}

.site-footer__categories {
  margin-top: var(--space-4);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  list-style: none;
  padding-inline-start: 0;
}

.site-footer__categories li { margin: 0; padding: 0; }

.site-footer__categories a {
  display: inline-block;
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-2);
  color: var(--text-secondary);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  transition: color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out);
}

.site-footer__categories a:hover {
  color: var(--text-accent);
  border-color: var(--border-strong);
}

/* ---------------------------------------------------------------------------
   15. Motion — opt in, never opt out. Base rules above ship without loop
   animation; the hero rise and panel fade are the only authored entrances,
   and both are removed (not shortened) under reduced motion.
   ------------------------------------------------------------------------- */

/* The milestone plaque's one celebratory flourish: a single sheen crossing the
   crust texture, once, on load. Once is the whole design -- a loop would turn a
   release announcement into an ad, and this band sits directly above the page's
   own headline. It rides the decorative ::after layer behind the content, so it
   never passes over text, and it exists only inside no-preference: there is no
   reduced-motion variant to shorten because the plaque is complete without it. */
@media (prefers-reduced-motion: no-preference) {
  .milestone::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: -45%;
    width: 45%;
    z-index: -1;
    background: linear-gradient(
      100deg,
      transparent,
      color-mix(in oklch, var(--accent) 24%, transparent),
      transparent
    );
    animation: milestone-sheen 1500ms var(--ease-out) 500ms 1 both;
    pointer-events: none;
  }
}

@keyframes milestone-sheen {
  from { transform: translateX(0); }
  to   { transform: translateX(330%); }
}

@media (prefers-reduced-motion: reduce) {
  /* .install-frame animates with the same hero-rise keyframes as the three
     above and was never listed here, so it kept rising for a reader who asked
     for no motion — while everything around it held still, which is worse than
     either behaviour on its own. It matters more now that the frame renders on
     the getting-started page too, i.e. somewhere that is not a hero at all. */
  .hero__headline, .hero__lede, .hero__jump, .install-frame, .milestone {
    animation: none;
    opacity: 1;
    transform: none;
  }

  .tabs__panel { animation: none; }

  .card, .theme-toggle__icon, .search__field, .tabs__tab,
  .prevnext__link, .see-also__name, .site-footer__categories a,
  .site-header__nav-link, .site-footer__start a,
  .skip-link, .prose a, .tabs__panel a,
  .prose tbody tr, .tabs__panel tbody tr,
  .notes__earlier-summary, .notes__earlier-summary::before,
  a.skill-link > code, a.skill-link--bare {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }

  .card:hover, .card:focus-visible {
    transform: none;
  }
}

/* ---------------------------------------------------------------------------
   16. Code block — install-verification page
   One scroll container for the whole block, not one per line, so a long
   line's horizontal scroll stays in sync with every other line rather than
   each drifting independently. The line-number gutter is sticky within
   that same container, so numbers stay pinned to the left edge while the
   code scrolls under them — a real editor's gutter behavior, no JS.
   ------------------------------------------------------------------------- */

.code-block {
  margin-top: var(--space-6);
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
}

.code-block__scroll {
  overflow-x: auto;
  padding-block: var(--space-4);
  border-radius: inherit;
}

.code-block__lines {
  list-style: none;
  counter-reset: line;
  margin: 0;
  padding: 0;
  width: max-content;
  min-width: 100%;
  font-family: var(--font-mono);
  font-size: var(--text-2);
  line-height: 1.7;
}

.code-block__lines li {
  counter-increment: line;
  display: flex;
}

.code-block__line-number {
  position: sticky;
  left: 0;
  flex-shrink: 0;
  width: 3em;
  padding-inline: var(--space-3) var(--space-4);
  text-align: right;
  color: var(--text-secondary);
  opacity: 0.55;
  background: var(--surface);
  user-select: none;
  transition: background-color var(--duration-micro) var(--ease-out);
}

.code-block__line-number::before { content: counter(line); }

.code-block__line-code {
  padding-inline-end: var(--space-6);
  white-space: pre;
  transition: background-color var(--duration-micro) var(--ease-out);
}

.code-block__lines li:hover .code-block__line-number,
.code-block__lines li:hover .code-block__line-code {
  background: var(--surface-hover);
}

/* Comments recede via italic as a second channel, not color alone — the
   one place on this page that isn't purely a color distinction. */
.tok-comment { color: var(--code-comment); font-style: italic; }
.tok-string { color: var(--code-string); }
.tok-const { color: var(--code-const); }
.tok-keyword { color: var(--code-keyword); }
.tok-number { color: var(--text-accent); }

/* ---------------------------------------------------------------------------
   17. Responsive
   Layout is fluid by default (clamp() type, auto-fill grid, flex-wrap) so most
   widths need no explicit rule. The few true reorganizations are declared
   locally above (footer to two columns at 720px, container padding at 640px).
   Verified down to 360px: header, hero, search, card grid, tables (own
   scroll container), tabs (own scroll container), breadcrumb, prev/next.
   ------------------------------------------------------------------------- */

@media (max-width: 420px) {
  .prevnext__link--next { text-align: start; }
}

/* ---------------------------------------------------------------------------
   18. Update-check modal — reload-only, centered, blurred backdrop

   The one moment this site interrupts a reader, so it gets the boulangerie's
   own voice rather than a browser alert's: a gold-sealed card, warm scrim,
   one obvious action. Two icons doing two different jobs — the loaf in the
   seal is brand, the rotate arrow on the button is meaning. Nothing here
   dismisses; that's a deliberate product decision documented in site.js, not
   an oversight in the styling.
   ------------------------------------------------------------------------- */

.update-modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  /* Not align/justify-center: an overflowing flex item centered that way is
     clipped at the top edge with no way to scroll back to it. `margin: auto`
     on the item centers identically and stays reachable. */
  overflow-y: auto;
  padding: var(--space-4);
  /* Warm ink rather than neutral black — a gray scrim over this palette reads
     as a screenshot of a different site. Deeper than the surrounding page so
     the card is unmistakably the foreground. */
  background: hsl(var(--scrim) / 0.78);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

/* Gold bloom behind the card, on its own blurred layer. The blur is not for
   softness — a gradient this large fading to transparent rasterizes as
   concentric bands, exactly as the header's ambient glow did (see
   .site-header__band::before). Blurring the stepped ramp is what removes
   them. */
.update-modal-overlay::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* --accent-solid, not --accent. Everything painted on the scrim is lit
     against a near-black field in *both* themes, so it wants the fixed foil
     gold; --accent resolves to the darker gold-700 in flour, which over this
     scrim subtracts light instead of adding it and the glow just vanishes.
     Card-side gold (border, gilded rule, seal) stays on --accent, where
     being theme-aware is exactly what keeps it legible on the card. */
  background: radial-gradient(44rem 28rem at 50% 46%,
    color-mix(in oklch, var(--accent-solid) 18%, transparent),
    transparent 68%);
  filter: blur(30px);
}

.update-modal {
  position: relative;
  margin: auto;
  width: 100%;
  max-width: 26rem;
  padding: var(--space-6);
  background: var(--surface);
  /* 55%, not the 40% that looked right against the dark theme alone: in
     flour, --accent is the darker gold-700 and the card under it is cream,
     so the same alpha that gilds a dark card barely marks a light one. */
  border: 1px solid color-mix(in oklch, var(--accent) 55%, transparent);
  border-radius: var(--radius-lg);
  /* Same three-layer recipe as .install-frame: hairline gold seat, a real
     cast shadow from one light direction, and a wide gold ambient so the card
     looks lit rather than pasted on. */
  box-shadow:
    0 0 0 1px color-mix(in oklch, var(--accent) 12%, transparent),
    0 30px 60px -24px hsl(var(--shadow-ink) / 0.75),
    0 0 80px -28px color-mix(in oklch, var(--accent-solid) 60%, transparent);
  text-align: center;
}

/* The gilded rule, same gesture as the header's bottom edge — fading at both
   ends rather than running hard corner to corner. */
.update-modal::before {
  content: '';
  position: absolute;
  left: 14%;
  right: 14%;
  top: 0;
  height: 1px;
  background: linear-gradient(to right,
    transparent,
    color-mix(in oklch, var(--accent) 80%, transparent) 22%,
    color-mix(in oklch, var(--accent) 80%, transparent) 78%,
    transparent);
}

.update-modal__seal {
  position: relative;
  display: grid;
  place-items: center;
  width: 4.5rem;
  height: 4.5rem;
  margin: 0 auto var(--space-5);
  overflow: hidden;
  color: var(--text-accent);
  border: 1px solid color-mix(in oklch, var(--accent) 62%, transparent);
  border-radius: var(--radius-full);
  background:
    radial-gradient(circle at 50% 32%,
      color-mix(in oklch, var(--accent) 30%, transparent),
      color-mix(in oklch, var(--accent) 9%, transparent) 72%);
}

.update-modal__seal .icon {
  width: 2.25rem;
  height: 2.25rem;
}

.update-modal__title {
  margin: 0 0 var(--space-3);
  font-family: var(--font-display);
  font-size: var(--text-5);
  font-weight: 620;
  line-height: var(--leading-tight);
  text-wrap: balance;
  color: var(--text-primary);
}

/* Narrower than the card it sits in, on purpose: two balanced short lines
   under a one-line title reads as a considered block, where the same words
   set to the full width give one long line and a two-word orphan. `balance`
   rather than `pretty` — this is a short block, which is exactly what
   balance is for, and it keeps a longer translation from stacking ragged. */
.update-modal__body {
  margin: 0 auto var(--space-6);
  max-width: 28ch;
  color: var(--text-secondary);
  text-wrap: balance;
}

.update-modal__reload {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  width: 100%;
  padding: var(--space-3) var(--space-5);
  border: none;
  border-radius: var(--radius-md);
  background: var(--accent-solid);
  color: var(--text-on-accent-solid);
  font-family: var(--font-body);
  font-size: var(--text-3);
  font-weight: 600;
  cursor: pointer;
  transition: background-color var(--duration-micro) var(--ease-out),
              box-shadow var(--duration-micro) var(--ease-out),
              transform var(--duration-micro) var(--ease-out);
}

.update-modal__reload .icon {
  width: 1.15em;
  height: 1.15em;
}

.update-modal__reload:hover {
  background: var(--accent-solid-hover);
  transform: translateY(-1px);
  box-shadow: 0 10px 24px -12px color-mix(in oklch, var(--accent) 85%, transparent);
}

.update-modal__reload:active {
  transform: translateY(0);
  box-shadow: none;
}

/* No :focus-visible rule here on purpose. The global one in section 3 already
   paints this exact ring, and its offset is what puts it on --surface rather
   than on the button's own gold fill — restating it locally would only be a
   second copy to keep in sync.

   Busy state. The spin is the obvious signal but it cannot be the only one —
   under reduced motion it never runs. `filter: brightness()` scales the fill
   and the label together, so the button visibly settles into a held state
   without the label's contrast against it moving. */
.update-modal__reload[aria-busy='true'] {
  cursor: progress;
  transform: none;
  filter: brightness(0.94);
  box-shadow: inset 0 1px 3px hsl(var(--shadow-ink) / 0.35);
}

@media (prefers-reduced-motion: no-preference) {
  .update-modal-overlay {
    animation: update-overlay-in var(--duration-enter) var(--ease-out);
  }

  /* Ease out from an already-visible default: the card is fully painted with
     no animation at all, and this only adds the arrival. Nothing here can
     leave it stuck invisible. */
  .update-modal {
    animation: update-modal-rise var(--duration-view) var(--ease-out) 60ms backwards;
  }

  /* One glint as the card lands, reusing the change-badge's sweep — a
     one-time catch of the light, never a loop. */
  .update-modal__seal::after {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(115deg,
      transparent 35%,
      color-mix(in oklch, white 55%, transparent) 50%,
      transparent 65%);
    transform: translateX(-120%);
    animation: badge-shine var(--duration-view) var(--ease-out) 420ms 1 forwards;
  }

  .update-modal__reload[aria-busy='true'] .icon {
    animation: reload-spin 900ms linear infinite;
  }
}

@keyframes update-overlay-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes update-modal-rise {
  from { opacity: 0; transform: translateY(14px) scale(0.965); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}

@keyframes reload-spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  .update-modal__reload {
    transition-duration: 0.01ms;
  }

  .update-modal__reload:hover { transform: none; }
}

/* A forced palette drops box-shadow entirely and overrides background-color,
   which takes the scrim, the gold seat and the whole lit-card treatment with
   it — background-image is left alone, so the decorative gradients would
   survive as stray artifacts on top of system colors. Strip the decoration
   and put back a real border, which is all that separates card from page
   there. */
@media (forced-colors: active) {
  .update-modal-overlay { background: Canvas; }

  .update-modal-overlay::before,
  .update-modal::before,
  .update-modal__seal::after { display: none; }

  .update-modal {
    background: Canvas;
    border: 2px solid CanvasText;
  }

  .update-modal__seal { background: none; }

  .update-modal__reload { border: 1px solid ButtonText; }
}

@media (max-width: 400px) {
  .update-modal { padding: var(--space-5); }
}

/* ---------------------------------------------------------------------------
   19. The skill graph ("the crumb")

   A loaf's crumb is the web of cells inside it, which is what this draws of
   the library: every skill, and every place one names another. The canvas is
   drawn by graph.js; everything here is the chrome around it, the header and
   skill-page links that lead to it, and the page that holds it.

   The twelve family colours are the one place this site carries more than
   its gold and its ember, and they are data colours, not decoration: one per
   family, in catalog order, validated as a set in both themes (OKLab ΔE
   between neighbouring families — which the constellation places side by
   side — is at least 11.2 under simulated protanopia and deuteranopia and
   21.3 in full colour, and every step clears 3:1 against both of that
   theme's surfaces). Twelve is past what hue alone can keep apart when any
   two might touch, so colour never carries a family by itself: the families
   are also places (the layout gathers them), named on the canvas, listed in
   the legend, and spelled out in every tooltip and panel.
   ------------------------------------------------------------------------- */

.crumb,
.graph-banner,
.century-banner {
  --graph-cat-1: #c8635d;
  --graph-cat-2: #009bab;
  --graph-cat-3: #ed9658;
  --graph-cat-4: #ad69b1;
  --graph-cat-5: #32c8a9;
  --graph-cat-6: #b49cf7;
  --graph-cat-7: #aa7e00;
  --graph-cat-8: #4abaf4;
  --graph-cat-9: #abb74f;
  --graph-cat-10: #ee8ab2;
  --graph-cat-11: #4e9a52;
  --graph-cat-12: #5f83d4;
}

[data-theme='flour'] :is(.crumb, .graph-banner, .century-banner) {
  --graph-cat-1: #90302e;
  --graph-cat-2: #006777;
  --graph-cat-3: #ad5c15;
  --graph-cat-4: #78377c;
  --graph-cat-5: #008c70;
  --graph-cat-6: #7b62b8;
  --graph-cat-7: #764c00;
  --graph-cat-8: #007eb5;
  --graph-cat-9: #737c00;
  --graph-cat-10: #ad5078;
  --graph-cat-11: #146720;
  --graph-cat-12: #31509d;
}

/* --- entry points ---------------------------------------------------------- */

.site-header__nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

/* The Graph link is a hit target and nothing else; its pill is the inner
   face. That split is what lets site.js swell the face toward an approaching
   pointer: a transform grows what is drawn and, on this element, nothing
   that can be clicked, so a face three times its size still never takes a
   click meant for Getting started beside it. */
.site-header__nav-link--graph,
.site-header__nav-link--graph:hover,
.site-header__nav-link--graph[aria-current="page"] {
  position: relative;
  z-index: 2;
  display: inline-flex;
  padding: 0;
  border-color: transparent;
  background: none;
}

.site-header__graph-face {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding: var(--space-2) var(--space-3);
  color: var(--text-secondary);
  background: var(--bg-deep);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  pointer-events: none;
  /* Grows down into the page rather than up past the top of the window,
     where a header-height pill has nowhere to go. */
  transform-origin: 50% 12%;
  transition: color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out),
              box-shadow var(--duration-enter) var(--ease-out);
}

.site-header__nav-link--graph:hover .site-header__graph-face {
  color: var(--text-accent);
  border-color: var(--border-strong);
}

.site-header__nav-link--graph[aria-current="page"] .site-header__graph-face {
  color: var(--text-accent);
  border-color: var(--border-strong);
  background: color-mix(in oklch, var(--accent) 10%, var(--bg-deep));
}

.site-header__nav-icon {
  width: 1.15em;
  height: 1.15em;
  color: var(--accent);
}

/* The mark: the graph's own vocabulary at icon size — ties, and on them
   travellers; nodes that take a turn to light. At rest it is the plain
   mark. It moves while the graph is new, and under the pointer always. */
.graph-mark__tie { opacity: 0.6; }

.graph-mark__flow {
  stroke: var(--accent-solid);
  stroke-width: 2.4;
  stroke-dasharray: 1.4 8.6;
  opacity: 0;
}

.graph-mark__node {
  fill: var(--bg-deep);
  transform-box: fill-box;
  transform-origin: center;
}

/* While new: the face is gilded, a ring breathes out of it the way a freshly
   changed skill's does in the graph, and a foil tag sits on its shoulder —
   the same "New" the skill cards wear. */
.site-header__nav-link--new .site-header__graph-face {
  color: var(--text-primary);
  border-color: color-mix(in oklch, var(--accent) 70%, transparent);
  box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 14%, transparent),
              0 0 22px -6px color-mix(in oklch, var(--accent-solid) 75%, transparent);
}

.site-header__new {
  position: absolute;
  top: -0.72em;
  right: -0.85em;
  padding: 0.12em 0.45em;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-on-accent-solid);
  background: var(--accent-solid);
  border-radius: var(--radius-full);
  box-shadow: 0 0 0 2px var(--bg-deep);
}

.site-header__nav-link--new .site-header__graph-face::after {
  content: '';
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  border: 1.5px solid var(--accent-solid);
  opacity: 0;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .site-header__nav-link--new .graph-mark__flow,
  .site-header__nav-link--graph:hover .graph-mark__flow {
    opacity: 1;
    animation: graph-mark-flow 1.7s linear infinite;
    animation-delay: calc(var(--i) * -0.34s);
  }

  .site-header__nav-link--new .graph-mark__node,
  .site-header__nav-link--graph:hover .graph-mark__node {
    animation: graph-mark-node 3.2s var(--ease-in-out) infinite;
    animation-delay: calc(var(--i) * 0.8s);
  }

  .site-header__nav-link--new .site-header__graph-face::after {
    animation: graph-ring 3.6s var(--ease-out) infinite;
  }

  .site-header__new { animation: graph-new-bob 2.8s var(--ease-in-out) infinite; }
}

@keyframes graph-mark-flow {
  from { stroke-dashoffset: 10; }
  to { stroke-dashoffset: 0; }
}

@keyframes graph-mark-node {
  0%, 30%, 100% { fill: var(--bg-deep); transform: scale(1); }
  12% { fill: var(--accent-solid); transform: scale(1.3); }
}

@keyframes graph-ring {
  0% { opacity: 0.55; transform: scale(1); }
  70%, 100% { opacity: 0; transform: scale(1.08, 1.3); }
}

@keyframes graph-new-bob {
  0%, 100% { transform: translateY(0) rotate(6deg); }
  50% { transform: translateY(-2px) rotate(-3deg); }
}

@media (forced-colors: active) {
  .site-header__graph-face { border-color: ButtonText; }
  .site-header__new { border: 1px solid ButtonText; }
}

/* --- the announcement banner ------------------------------------------------ */

/* The graph announcing itself at the top of the landing page, in its own
   materials: the stage's dark field and gold bloom, the app bar's gilded
   hairline, its stat tiles and its foil button, and a small live
   constellation of the twelve families drawn by site.js. The copy sits on the
   left over a fade of the page colour, so every word is measured against
   --bg rather than against whatever the canvas happens to be drawing
   behind it. */

.graph-banner,
.release-banner,
.century-banner,
.climb-banner {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  display: flex;
  align-items: center;
  min-height: 16.5rem;
  border: 1px solid color-mix(in oklch, var(--accent) 40%, var(--border-subtle));
  border-radius: var(--radius-lg);
  background:
    radial-gradient(ellipse 55% 95% at 74% 50%,
      color-mix(in oklch, var(--accent-solid) 9%, var(--bg)) 0%,
      var(--bg) 78%);
  box-shadow:
    0 0 0 1px color-mix(in oklch, var(--accent) 10%, transparent),
    0 30px 60px -34px hsl(var(--shadow-ink) / 0.7),
    0 0 90px -40px color-mix(in oklch, var(--accent-solid) 50%, transparent);
}

.graph-banner::before,
.release-banner::before,
.century-banner::before,
.climb-banner::before {
  content: '';
  position: absolute;
  z-index: 2;
  inset-inline: 10%;
  top: 0;
  height: 1px;
  background: linear-gradient(to right, transparent,
    color-mix(in oklch, var(--accent) 75%, transparent) 25%,
    color-mix(in oklch, var(--accent) 75%, transparent) 75%, transparent);
}

.graph-banner__canvas {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  cursor: pointer;
}

.graph-banner__body,
.release-banner__body,
.century-banner__body,
.climb-banner__body {
  position: relative;
  z-index: 1;
  width: min(36rem, 58%);
  padding: var(--space-6) var(--space-6) var(--space-6) var(--space-6);
  pointer-events: none;
}

/* The fade the copy reads against, drawn as its own layer so the canvas can
   run under the whole banner and still never sit behind a letter. */
.graph-banner__body::before,
.release-banner__body::before,
.century-banner__body::before,
.climb-banner__body::before {
  content: '';
  position: absolute;
  inset: 0 -30% 0 0;
  z-index: -1;
  background: linear-gradient(to right, var(--bg) 62%, color-mix(in oklch, var(--bg) 70%, transparent) 82%, transparent);
  border-radius: var(--radius-lg) 0 0 var(--radius-lg);
}

.graph-banner__body a,
.release-banner__body a,
.century-banner__body a,
.climb-banner__body a { pointer-events: auto; }

.graph-banner__title,
.release-banner__title,
.century-banner__title,
.climb-banner__title {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  margin: 0;
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-weight: 650;
  font-size: var(--text-5);
  line-height: var(--leading-tight);
  letter-spacing: -0.015em;
  color: var(--text-primary);
}

.graph-banner__lede,
.release-banner__lede,
.century-banner__lede,
.climb-banner__lede {
  margin-top: var(--space-3);
  max-width: 46ch;
  color: var(--text-secondary);
  line-height: var(--leading-relaxed);
}

.graph-banner__stats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: var(--space-4) 0 0;
}

.graph-banner__stat {
  display: flex;
  flex-direction: column-reverse;
  justify-content: flex-end;
  min-width: 6.5rem;
  max-width: 9.5rem;
  padding: var(--space-2) var(--space-3);
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
}

.graph-banner__stat dd {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--text-4);
  line-height: 1.15;
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
}

.graph-banner__stat dt {
  font-size: 0.72rem;
  line-height: 1.3;
  color: var(--text-secondary);
}

.graph-banner__cta,
.release-banner__cta,
.century-banner__cta,
.climb-banner__cta {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  margin-top: var(--space-5);
  padding: 0.55em 1.1em;
  font-weight: 600;
  color: var(--text-on-accent-solid);
  background: var(--accent-solid);
  border: 1px solid var(--accent-solid);
  border-radius: var(--radius-full);
  box-shadow: 0 10px 26px -14px color-mix(in oklch, var(--accent-solid) 90%, transparent);
  transition: background-color var(--duration-micro) var(--ease-out),
              transform var(--duration-micro) var(--ease-out);
}

.graph-banner__cta:hover,
.release-banner__cta:hover,
.century-banner__cta:hover,
.climb-banner__cta:hover { background: var(--accent-solid-hover); }
.graph-banner__cta-icon,
.release-banner__cta-icon,
.century-banner__cta-icon,
.climb-banner__cta-icon { width: 1.15em; height: 1.15em; }

@media (prefers-reduced-motion: no-preference) {
  .graph-banner__cta:hover,
  .release-banner__cta:hover,
  .century-banner__cta:hover,
  .climb-banner__cta:hover { transform: translateY(-1px); }
}

/* Narrow: the constellation takes a band of its own above the copy rather
   than sharing a row it would have to squeeze into. Measured on the banner's
   own slot, not the viewport (see .hero__banners): half of a desktop row is
   as narrow as a phone to a banner, and gets the same layout. site.js reads
   which one applied off flex-direction, so the picture follows. */
@container banner (max-width: 44rem) {
  .graph-banner,
  .release-banner,
  .century-banner,
  .climb-banner {
    flex-direction: column;
    align-items: stretch;
    min-height: 0;
    padding-top: 11rem;
  }
  .graph-banner__canvas { height: 12rem; }
  .graph-banner__body,
  .release-banner__body,
  .century-banner__body,
  .climb-banner__body {
    display: flex;
    flex-direction: column;
    flex: 1;
    width: auto;
    padding: var(--space-4) var(--space-5) var(--space-5);
  }
  .graph-banner__body::before,
  .release-banner__body::before,
  .century-banner__body::before,
  .climb-banner__body::before {
    inset: 0;
    background: linear-gradient(to bottom, transparent, var(--bg) 2.5rem);
    border-radius: 0 0 var(--radius-lg) var(--radius-lg);
  }
  .graph-banner__title,
  .release-banner__title,
  .century-banner__title,
  .climb-banner__title { font-size: var(--text-4); }
  .graph-banner__stat { min-width: 0; flex: 1 1 5.5rem; }
  /* Side by side, the two banners stretch to one height; the buttons sit on
     the same line at the bottom rather than wherever each copy ran out. */
  .graph-banner__stats,
  .release-banner__prefix { margin-bottom: var(--space-5); }
  .graph-banner__cta,
  .release-banner__cta,
  .century-banner__cta,
  .climb-banner__cta { margin-top: auto; align-self: flex-start; }
}

@media (forced-colors: active) {
  .graph-banner { border: 2px solid CanvasText; }
  .graph-banner::before, .graph-banner__body::before { display: none; }
  .graph-banner__stat { border: 1px solid CanvasText; }
  .graph-banner__cta { border: 1px solid ButtonText; }
}

/* The announcements' row, at the top of the hero: the 2.0 release on the
   left, the graph on the right, one height, one gutter. auto-fit collapses
   the column a retired banner leaves, so the one still up takes the row.
   Each slot is a size container, and each banner lays itself out for the
   width it is given rather than for the viewport's. */
.hero__banners {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
  gap: var(--space-5);
  margin-top: var(--space-5);
  margin-bottom: var(--space-6);
}

.banner-slot {
  container: banner / inline-size;
  display: flex;
  min-width: 0;
}

.banner-slot > * { flex: 1; min-width: 0; }

/* The 2.0 release. The shell -- hairline, fade, title, lede, button, the
   stacked layout -- is the graph banner's, shared above, so the pair reads
   as one set. What is its own is the picture: the numeral as a few thousand
   grains of gold that steam off the top and part around the pointer, drawn
   by site.js over a type-set numeral it measures and then replaces. The
   type-set one is what anyone without the canvas sees, so it is finished
   work in its own right: foil, warm glow, and nothing that depends on JS. */

.release-banner {
  background:
    radial-gradient(ellipse 50% 90% at 76% 52%,
      color-mix(in oklch, var(--accent-solid) 12%, var(--bg)) 0%,
      var(--bg) 78%);
}

.release-banner__stage {
  position: absolute;
  inset: 0;
  z-index: 0;
  background:
    radial-gradient(ellipse 22% 55% at 76% 62%,
      color-mix(in oklch, var(--accent-solid) 22%, transparent),
      transparent 70%);
}

.release-banner__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  cursor: pointer;
}

.release-banner__numeral {
  position: absolute;
  top: 50%;
  left: 76%;
  transform: translate(-50%, -50%);
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-style: italic;
  font-weight: 700;
  font-size: 13rem;
  line-height: 1;
  letter-spacing: -0.04em;
  white-space: nowrap;
  /* The italic 0 leans past its own advance, and a text-clipped background
     paints only inside the box: the padding is room for the lean. */
  padding-inline: 0.14em;
  color: var(--accent);
  background: linear-gradient(172deg, var(--gold-400) 18%, var(--gold-500) 48%, var(--gold-700) 88%);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  filter: drop-shadow(0 0 28px color-mix(in oklch, var(--accent-solid) 40%, transparent));
  user-select: none;
  transition: opacity 1200ms var(--ease-out);
}

[data-theme='flour'] .release-banner__numeral {
  background-image: linear-gradient(172deg, var(--gold-700) 15%, var(--gold-800) 85%);
  filter: drop-shadow(0 6px 18px color-mix(in oklch, var(--gold-700) 30%, transparent));
}

/* site.js sets this once its first frame is on the canvas. */
.release-banner.is-drawn .release-banner__numeral { opacity: 0; }

.release-banner__body { width: min(36rem, 56%); }

.release-banner__surfaces {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: var(--space-4) 0 0;
  padding: 0;
  list-style: none;
}

.release-banner__surface {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  margin: 0;
  padding: 0.3em 0.75em;
  font-size: var(--text-2);
  font-weight: 500;
  line-height: 1.3;
  color: var(--text-secondary);
  background: var(--surface);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-full);
}

.release-banner__surface.is-live {
  color: var(--text-primary);
  background: color-mix(in oklch, var(--accent-solid) 14%, var(--surface));
  border: 1px solid color-mix(in oklch, var(--accent) 55%, transparent);
}

.release-banner__surface-dot,
.release-banner__legend-item::before {
  flex: none;
  width: 0.55em;
  height: 0.55em;
  border: 1.5px solid currentColor;
  border-radius: 50%;
  opacity: 0.65;
}

.release-banner__surface.is-live .release-banner__surface-dot,
.release-banner__legend-item.is-live::before {
  background: var(--accent);
  border-color: var(--accent);
  opacity: 1;
  box-shadow: 0 0 8px color-mix(in oklch, var(--accent) 80%, transparent);
}

.release-banner__legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-4);
  margin: var(--space-2) 0 0;
  font-size: 0.72rem;
  line-height: 1.3;
  color: var(--text-secondary);
}

.release-banner__legend-item {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
}

.release-banner__legend-item::before { content: ''; }

.release-banner__prefix {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  margin-top: var(--space-3);
}

.release-banner__prefix-label {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-secondary);
}

.release-banner__prefix-code {
  padding: 0.25em 0.6em;
  font-family: var(--font-mono);
  font-size: var(--text-2);
  color: var(--text-primary);
  background: var(--bg-deep);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
  overflow-wrap: anywhere;
}

.release-banner__prefix-code del {
  margin-right: 0.6em;
  color: var(--text-secondary);
  text-decoration-thickness: 1.5px;
  text-decoration-color: color-mix(in oklch, var(--critical) 80%, transparent);
}

.release-banner__prefix-code ins {
  text-decoration: none;
  color: var(--text-accent);
}

/* Narrow slot: the numeral takes the band the constellation takes next door,
   at the same height, so the two pictures sit on one line. */
@container banner (max-width: 44rem) {
  .release-banner {
    background: var(--bg);
  }
  .release-banner__stage {
    height: 12rem;
    background:
      radial-gradient(ellipse 45% 60% at 50% 62%,
        color-mix(in oklch, var(--accent-solid) 18%, transparent),
        transparent 72%);
  }
  .release-banner__numeral { left: 50%; font-size: 9.5rem; }
  .release-banner__body { width: auto; }
}

/* Motion: the old prefix is struck through in front of the reader, once, and
   the new one arrives after it. Without motion, both are simply there. */
@media (prefers-reduced-motion: no-preference) {
  .release-banner__prefix-code del {
    text-decoration: none;
    background: linear-gradient(color-mix(in oklch, var(--critical) 80%, transparent), color-mix(in oklch, var(--critical) 80%, transparent))
      no-repeat 0 58% / 0% 1.5px;
    animation: release-strike 520ms var(--ease-out) 1100ms forwards;
  }
  .release-banner__prefix-code ins {
    display: inline-block;
    opacity: 0;
    transform: translateY(0.35em);
    animation: release-arrive 420ms var(--ease-out) 1550ms forwards;
  }
  .release-banner__surface.is-live .release-banner__surface-dot {
    animation: release-live 2.8s var(--ease-in-out) 2s infinite;
  }
}

@keyframes release-strike { to { background-size: 100% 1.5px; } }
@keyframes release-arrive { to { opacity: 1; transform: none; } }
@keyframes release-live {
  0%, 100% { box-shadow: 0 0 8px color-mix(in oklch, var(--accent) 80%, transparent),
                         0 0 0 0 color-mix(in oklch, var(--accent) 45%, transparent); }
  50%      { box-shadow: 0 0 8px color-mix(in oklch, var(--accent) 80%, transparent),
                         0 0 0 5px color-mix(in oklch, var(--accent) 0%, transparent); }
}

@media (forced-colors: active) {
  .release-banner { border: 2px solid CanvasText; }
  .release-banner::before, .release-banner__body::before { display: none; }
  .release-banner__canvas { display: none; }
  .release-banner__numeral,
  .release-banner.is-drawn .release-banner__numeral {
    opacity: 1;
    background: none;
    filter: none;
    -webkit-text-fill-color: CanvasText;
    color: CanvasText;
  }
  .release-banner__surface { border: 1px solid CanvasText; }
  .release-banner__surface.is-live { border-width: 2px; }
  .release-banner__prefix-code del { text-decoration: line-through; background: none; }
  .release-banner__cta { border: 1px solid ButtonText; }
}

/* The hundred. Across the whole row, above the release and the graph, in
   their shell -- hairline, fade, title, lede, button -- so the three read as
   one set. What is its own is an event rather than a figure: the numeral is
   set in type as a plate with a tally under it, and site.js redraws it from
   one bead per skill, streaming each family up from its own point and
   counting them in on the tally. At the hundredth the numeral flares and
   the tally turns gold; the one after it lands a beat later. The type-set
   plate is what anyone without the canvas sees, and it is finished work: the
   foil numeral, and the true count under it. */

.hero__banners--lead { margin-bottom: var(--space-5); }

.century-banner {
  min-height: 20rem;
  background:
    radial-gradient(ellipse 42% 95% at 73% 52%,
      color-mix(in oklch, var(--accent-solid) 10%, var(--bg)) 0%,
      var(--bg) 80%);
}

.century-banner__stage {
  position: absolute;
  inset: 0;
  z-index: 0;
  background:
    radial-gradient(ellipse 26% 62% at 73% 50%,
      color-mix(in oklch, var(--accent-solid) 15%, transparent),
      transparent 70%);
}

.century-banner__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* The numeral and its tally, centred together where the picture goes. */
.century-banner__plate {
  position: absolute;
  top: 50%;
  left: 73%;
  display: flex;
  flex-direction: column;
  align-items: center;
  transform: translate(-50%, -50%);
  pointer-events: none;
}

.century-banner__numeral {
  font-family: var(--font-display);
  /* Left at auto, as a canvas sets it too: the glyph site.js lays the beads
     on has to be the glyph drawn here under them. */
  font-optical-sizing: auto;
  font-weight: 700;
  font-size: min(14rem, 20cqi);
  line-height: 1;
  letter-spacing: 0.02em;
  white-space: nowrap;
  color: var(--accent);
  background: linear-gradient(172deg, var(--gold-400) 18%, var(--gold-500) 48%, var(--gold-700) 88%);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  filter: drop-shadow(0 0 28px color-mix(in oklch, var(--accent-solid) 40%, transparent));
  user-select: none;
  transition: opacity 900ms var(--ease-out);
}

[data-theme='flour'] .century-banner__numeral {
  background-image: linear-gradient(172deg, var(--gold-700) 15%, var(--gold-800) 85%);
  filter: drop-shadow(0 6px 18px color-mix(in oklch, var(--gold-700) 30%, transparent));
}

/* The count under the hundred. Ticks as the beads land (site.js), and says
   the true number at rest: the numeral is the milestone, this is the fact. */
.century-banner__tally {
  display: inline-flex;
  align-items: baseline;
  gap: 0.55em;
  margin-top: -0.35em;
  font-family: var(--font-mono);
  font-size: var(--text-1);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-secondary);
}

.century-banner__tally-count {
  display: inline-block;
  min-width: 3ch;
  font-family: var(--font-display);
  font-size: var(--text-6);
  font-weight: 650;
  line-height: 1;
  letter-spacing: -0.01em;
  text-align: end;
  font-variant-numeric: lining-nums tabular-nums;
  color: var(--text-primary);
  transition: color var(--duration-view) var(--ease-out);
}

.century-banner__tally.is-past .century-banner__tally-count { color: var(--text-accent); }

/* site.js sets this once the beads are on the canvas, and only then is the
   sentence telling a reader to point at them true. The numeral stays, dimmed,
   as the dough the beads are set in: a hundred beads alone do not hold the
   shape of a high-contrast serif, and the number is the point. */
.century-banner.is-drawn .century-banner__numeral { opacity: 0.3; }
[data-theme='flour'] .century-banner.is-drawn .century-banner__numeral { opacity: 0.36; }
.century-banner__hint { display: none; }
.century-banner.is-drawn .century-banner__hint { display: inline; }

.century-banner__body { width: min(38rem, 52%); }

/* The fade the copy reads against stops short of the numeral, which starts
   close to it: at the shared width it dims the "1" and leaves the zeros
   bright, and the number reads as "00". */
.century-banner__body::before { inset: 0 -8% 0 0; }

/* What took it past a hundred: the skills themselves, as links. Hovering or
   focusing one lights its bead in the picture. */
.century-banner__crossers { margin-top: var(--space-4); }

.century-banner__crossers-label {
  margin: 0 0 var(--space-2);
  font-size: var(--text-2);
  font-weight: 600;
  color: var(--text-primary);
}

/* Stacked on one left edge: three names this long wrap two-and-one in the
   column's width, which reads as an accident rather than a list. */
.century-banner__crosser-list {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.century-banner__crosser {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  max-width: 100%;
  padding: 0.4em 0.8em;
  font-family: var(--font-mono);
  font-size: var(--text-1);
  line-height: 1.3;
  color: var(--text-primary);
  text-decoration: none;
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  transition: border-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out),
              transform var(--duration-micro) var(--ease-out);
}

.century-banner__crosser-name { overflow-wrap: anywhere; }

.century-banner__crosser-dot {
  flex: none;
  width: 0.7em;
  height: 0.7em;
  border-radius: 50%;
  background: var(--dot, var(--accent));
  box-shadow: 0 0 0 2px color-mix(in oklch, var(--dot, var(--accent)) 30%, transparent);
}

.century-banner__crosser:hover,
.century-banner__crosser:focus-visible {
  background: color-mix(in oklch, var(--dot, var(--accent)) 14%, var(--surface));
  border-color: color-mix(in oklch, var(--dot, var(--accent)) 70%, var(--border-subtle));
}

@media (prefers-reduced-motion: no-preference) {
  .century-banner__crosser:hover { transform: translateY(-1px); }
}

/* The name of the bead under the pointer, placed by site.js above it. A child
   of the banner rather than of the stage, so it rides over the copy's fade
   instead of under it when the bead sits near the text. */
.century-banner__tip {
  position: absolute;
  z-index: 3;
  top: 0;
  left: 0;
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  padding: 0.3em 0.7em;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  line-height: 1.3;
  white-space: nowrap;
  color: var(--text-primary);
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
  box-shadow: 0 10px 24px -14px hsl(var(--shadow-ink) / 0.7);
  pointer-events: none;
}

.century-banner__tip[hidden] { display: none; }

.century-banner__tip::before {
  content: '';
  flex: none;
  width: 0.6em;
  height: 0.6em;
  border-radius: 50%;
  background: var(--tip-colour, var(--accent));
}

.century-banner__tip-family { color: var(--text-secondary); }

/* The one authored moment: the hundredth bead lands. The numeral's foil
   flashes full and settles back into the dough; the tally's figure gives a
   single beat. Opacity and transform only, once, and never under reduced
   motion (site.js does not set the class then). */
@media (prefers-reduced-motion: no-preference) {
  .century-banner.is-crossing .century-banner__numeral {
    animation: century-flare 1600ms var(--ease-out);
  }
  .century-banner.is-crossing .century-banner__tally-count {
    animation: century-beat 560ms var(--ease-out);
  }
}

@keyframes century-flare {
  0%   { opacity: 1; }
  100% { opacity: 0.3; }
}

@keyframes century-beat {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.22); }
  100% { transform: scale(1); }
}

/* Narrow slot: the plate takes a band of its own above the copy, and the
   beads stream up into it from the band's floor. */
@container banner (max-width: 44rem) {
  .century-banner {
    min-height: 0;
    padding-top: 12rem;
    background: var(--bg);
  }
  .century-banner__stage {
    height: 12.5rem;
    background:
      radial-gradient(ellipse 45% 60% at 50% 48%,
        color-mix(in oklch, var(--accent-solid) 16%, transparent),
        transparent 72%);
  }
  .century-banner__body::before { inset: 0; }
  .century-banner__plate { top: 50%; left: 50%; }
  .century-banner__numeral { font-size: min(8.5rem, 34cqi); }
  .century-banner__tally-count { font-size: var(--text-5); }
  .century-banner__body { width: auto; }
  .century-banner__crossers { margin-bottom: var(--space-5); }
}

@media (forced-colors: active) {
  .century-banner { border: 2px solid CanvasText; }
  .century-banner::before, .century-banner__body::before { display: none; }
  .century-banner__canvas, .century-banner__tip { display: none; }
  .century-banner__numeral,
  .century-banner.is-drawn .century-banner__numeral {
    opacity: 1;
    background: none;
    filter: none;
    -webkit-text-fill-color: CanvasText;
    color: CanvasText;
  }
  .century-banner__crosser { border: 1px solid ButtonText; }
  .century-banner__crosser-dot { forced-color-adjust: none; }
  .century-banner__cta { border: 1px solid ButtonText; }
}

.skill-article__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3) var(--space-4);
  margin-top: var(--space-4);
}

.skill-article__meta .skill-article__tag { margin-top: 0; }

.skill-article__graph-link {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  font-size: var(--text-2);
  color: var(--text-accent);
  border-radius: var(--radius-sm);
  text-decoration: underline;
  text-decoration-color: color-mix(in oklch, var(--accent) 45%, transparent);
  text-underline-offset: 0.22em;
  transition: color var(--duration-micro) var(--ease-out),
              text-decoration-color var(--duration-micro) var(--ease-out);
}

.skill-article__graph-link .icon { width: 1.2em; height: 1.2em; }

.skill-article__graph-link:hover {
  color: var(--accent-hover);
  text-decoration-color: currentColor;
}

/* --- the page ------------------------------------------------------------------ */

/* Before graph.js runs (and if it never does) this is an ordinary page: a
   head saying what the graph shows, a host holding the noscript note, the
   footer. Once the app mounts it adds .graph-live to <body>, and the page
   becomes the instrument: the body is exactly one viewport tall, the host
   takes every pixel below the site header, edge to edge, and the head and
   footer step out of the way. The head is hidden visually only — it is still
   the page's heading for a screen reader. The footer is removed outright:
   below a canvas that spends the wheel on zooming it could only be reached
   by the scrollbar, and everything in it is one click away in the header.

   The height is max(100dvh, 30rem) rather than 100dvh, so a phone held
   sideways scrolls to a usable graph rather than squeezing it flat. */

.graph-page__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2) var(--space-5);
  padding-top: var(--space-6);
  padding-bottom: var(--space-4);
}

.graph-page__title {
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-weight: 650;
  font-size: var(--text-5);
  line-height: var(--leading-tight);
  letter-spacing: -0.015em;
}

.graph-page__lede {
  max-width: var(--measure-prose);
  color: var(--text-secondary);
}

.graph-host {
  width: min(100% - 2 * var(--space-4), var(--container-wide));
  margin: 0 auto var(--space-8);
  background: var(--bg);
}

.graph-host__noscript {
  padding: var(--space-5) 0;
  max-width: var(--measure-prose);
  color: var(--text-secondary);
}

.page-graph.graph-live {
  display: flex;
  flex-direction: column;
  height: max(100dvh, 30rem);
  min-height: 0;
}

.page-graph.graph-live > main {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.page-graph.graph-live .graph-page {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.page-graph.graph-live .graph-page__head {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.page-graph.graph-live .graph-host {
  flex: 1 1 auto;
  width: 100%;
  min-height: 0;
  margin: 0;
  border-top: 1px solid var(--border-subtle);
}

.page-graph.graph-live .site-footer { display: none; }

/* On a phone the site header wraps to four rows, and every one of them is
   height the graph does not get. The build time is metadata every other page
   still shows; here it gives its two rows back, and the nav and the theme
   toggle close up onto one. */
@media (max-width: 40rem) {
  .page-graph.graph-live .site-header__updated { display: none; }
  .page-graph.graph-live .site-header__inner { padding-block: var(--space-3); }
}

/* A phone on its side gets the sheet layout at the height of a toolbar. The
   30rem floor above then scrolled the page, pushing the legend and the sheet
   handle off the bottom, and header and bar took 244 of 390px. Here the page
   fits the screen, the header gives back its build time and padding, and the
   search joins the view tabs on one row. A short window wider than 60rem keeps
   the floor: its legend stands up the left edge and needs the height. */
@media (max-height: 30rem) and (max-width: 60rem) {
  .page-graph.graph-live { height: 100dvh; }
  .page-graph.graph-live .site-header__updated { display: none; }
  .page-graph.graph-live .site-header__inner { padding-block: var(--space-2); }
  .page-graph.graph-live .crumb__search { order: 2; flex: 1 1 12rem; }
  .page-graph.graph-live .crumb [data-crumb-trace] { order: 3; margin-inline-start: 0; }
}

/* --- the app ---------------------------------------------------------------------- */

.crumb {
  container-type: inline-size;
  position: relative;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  height: 100%;
  min-height: 0;
  font-size: var(--text-2);
  color: var(--text-primary);
}

/* The prose rhythm in section 5 — a top margin on every paragraph, 64px
   above any heading that follows something — is right for an article and
   wrong for an app's chrome. Zeroed here, at the specificity of one class,
   so every rule below this one still sets its own spacing. */
.crumb :where(h2, h3, h4, p, blockquote, ol, ul, dl, dd, li + li) { margin: 0; }

.crumb__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--border-subtle);
  background: var(--surface);
  position: relative;
  z-index: 2;
}

/* The gilded hairline the header and the update modal both end on. */
.crumb__bar::after {
  content: '';
  position: absolute;
  inset-inline: 8%;
  bottom: -1px;
  height: 1px;
  background: linear-gradient(to right, transparent,
    color-mix(in oklch, var(--accent) 60%, transparent) 25%,
    color-mix(in oklch, var(--accent) 60%, transparent) 75%, transparent);
}

.crumb__heading {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0 var(--space-3);
  margin-inline-end: auto;
}

.crumb__title {
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-weight: 650;
  font-size: var(--text-4);
  letter-spacing: -0.01em;
}

.crumb__meta {
  font-family: var(--font-mono);
  font-size: var(--text-1);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

.crumb__views {
  display: inline-flex;
  padding: 3px;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  background: var(--bg);
}

.crumb__view {
  padding: 0.35em 0.9em;
  border-radius: var(--radius-full);
  color: var(--text-secondary);
  cursor: pointer;
  transition: color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

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

/* Selected: fill plus weight, never the fill alone. */
.crumb__view[aria-selected='true'] {
  color: var(--text-on-accent-solid);
  background: var(--accent-solid);
  font-weight: 600;
}

.crumb__search {
  position: relative;
  display: flex;
  align-items: center;
}

.crumb__searchnote {
  position: absolute;
  top: calc(100% + 0.35rem);
  left: 0;
  right: 0;
  z-index: 5;
  padding: 0.4rem 0.6rem;
  font-size: 0.8rem;
  line-height: 1.35;
  color: var(--text-primary);
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  border-radius: 0.5rem;
  box-shadow: 0 4px 14px rgb(0 0 0 / 0.18);
}
.crumb__searchnote[hidden] { display: none; }

.crumb__search .icon {
  position: absolute;
  inset-inline-start: 0.7em;
  width: 1.05em;
  height: 1.05em;
  color: var(--text-secondary);
  pointer-events: none;
}

.crumb__search input {
  width: 14rem;
  padding: 0.5em 0.9em 0.5em 2.2em;
  font-size: var(--text-2);
  color: var(--text-primary);
  background: var(--bg);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-full);
}

.crumb__search input::placeholder { color: var(--text-secondary); opacity: 1; }
.crumb__search input:focus-visible { border-color: var(--focus-ring); outline-offset: 2px; }

.crumb__btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.45em;
  min-height: 2.25rem;
  padding: 0.4em 0.9em;
  font-size: var(--text-2);
  color: var(--text-secondary);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  cursor: pointer;
  white-space: nowrap;
  transition: color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.crumb__btn .icon { width: 1.15em; height: 1.15em; }
.crumb__btn:hover { color: var(--text-accent); border-color: var(--border-strong); }

.crumb__btn--icon { width: 2.25rem; padding: 0; }
.crumb__btn--icon .icon { width: 1.25em; height: 1.25em; }

.crumb__btn--solid {
  color: var(--text-on-accent-solid);
  background: var(--accent-solid);
  border-color: var(--accent-solid);
  font-weight: 600;
}

.crumb__btn--solid:hover {
  color: var(--text-on-accent-solid);
  background: var(--accent-solid-hover);
  border-color: var(--accent-solid-hover);
}

.crumb__btn--quiet { border-color: transparent; }
.crumb__btn--small { min-height: 1.9rem; padding: 0.2em 0.6em; font-size: var(--text-1); }

/* The row is pinned to the space the app gives it. Left implicit, it sizes
   to its tallest child — the panel, once a section's quotes open — and the
   stage grows with it, which re-lays the canvas out for a height nobody can
   see. */
.crumb__body {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 22rem;
  grid-template-rows: minmax(0, 1fr);
  min-height: 0;
}

/* --- stage -------------------------------------------------------------------------- */

.crumb__stage {
  position: relative;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
  touch-action: none;
  cursor: grab;
  border-radius: 0;
  background:
    radial-gradient(ellipse 70% 60% at 50% 48%,
      color-mix(in oklch, var(--accent-solid) 7%, var(--bg)) 0%,
      var(--bg) 72%);
}

[data-theme='flour'] .crumb__stage {
  background:
    radial-gradient(ellipse 70% 60% at 50% 48%,
      color-mix(in oklch, white 38%, var(--bg)) 0%,
      var(--bg) 75%);
}

.crumb__stage:focus-visible { outline-offset: -3px; border-radius: 0; }
.crumb__stage--grabbing { cursor: grabbing; }
.crumb--loading .crumb__stage { cursor: progress; }

.crumb__canvas {
  position: absolute;
  inset: 0;
  display: block;
}

.crumb__status {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
}

.crumb__status:empty { display: none; }

/* Several overlays set their own display; the attribute has to win. */
.crumb [hidden] { display: none !important; }

.crumb__loading,
.crumb__failed p {
  font-family: var(--font-display);
  font-style: italic;
  font-size: var(--text-4);
  color: var(--text-secondary);
}

.crumb__failed {
  display: grid;
  justify-items: center;
  gap: var(--space-3);
  pointer-events: auto;
}

@media (prefers-reduced-motion: no-preference) {
  .crumb__loading { animation: crumb-breathe 1.8s var(--ease-in-out) infinite; }
}

@keyframes crumb-breathe {
  50% { opacity: 0.45; }
}

/* Never under the tools or the legend: graph.js measures both and hands over
   the room left for the hint (--crumb-hint-l, -r, -b). Centred within that
   room, it wraps onto balanced lines rather than reach either of them. */
.crumb__hint {
  position: absolute;
  left: var(--crumb-hint-l, var(--space-3));
  right: var(--crumb-hint-r, var(--space-3));
  bottom: var(--crumb-hint-b, calc(var(--space-3) + 0.4rem));
  width: fit-content;
  margin-inline: auto;
  padding: 0.35em 0.9em;
  font-size: var(--text-1);
  color: var(--text-secondary);
  text-align: center;
  text-wrap: balance;
  background: color-mix(in oklch, var(--surface) 85%, transparent);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  pointer-events: none;
  transition: opacity var(--duration-view) var(--ease-out);
}

.crumb__hint--gone { opacity: 0; }
.crumb[data-view='anatomy'] .crumb__hint,
.crumb:is([data-view='list'], [data-view='matrix']) .crumb__hint { display: none; }

/* Trail: where you walked from, so a walk can be retraced a step at a time. */
.crumb__trail {
  position: absolute;
  top: var(--space-3);
  inset-inline-start: var(--space-3);
  max-width: calc(100% - 2 * var(--space-3));
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25em;
  padding: 0.25em;
  background: color-mix(in oklch, var(--surface) 88%, transparent);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  z-index: 1;
}

.crumb__crumb {
  padding: 0.25em 0.65em;
  font-family: var(--font-mono);
  font-size: var(--text-1);
  color: var(--text-secondary);
  border-radius: var(--radius-full);
  cursor: pointer;
}

.crumb__crumb:hover { color: var(--text-accent); background: var(--surface-hover); }
.crumb__crumb--back { display: inline-grid; place-items: center; width: 1.9rem; height: 1.9rem; padding: 0; }
.crumb__crumb--back .icon { width: 1.1em; height: 1.1em; }
.crumb__crumb--here { color: var(--text-primary); font-weight: 500; cursor: default; }
.crumb__crumb--here:hover { color: var(--text-primary); background: none; }
.crumb__crumb-sep { color: var(--text-secondary); opacity: 0.6; }

.crumb__legend {
  position: absolute;
  inset-inline-start: var(--space-3);
  bottom: var(--space-3);
  max-width: min(44rem, calc(100% - 18rem));
  z-index: 1;
}

.crumb__legend-title {
  margin-bottom: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-1);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-accent);
}

.crumb__legend-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
}

.crumb[data-view='anatomy'] .crumb__legend,
.crumb:is([data-view='list'], [data-view='matrix']) .crumb__legend { display: none; }

/* A wide stage has width to spare and height to protect: the key stands up
   along the left edge, the way a map's does, and the graph keeps the full
   height. graph.js reads which way it stands and fits the camera beside it. */
@container (min-width: 60rem) {
  .crumb__legend {
    top: var(--space-4);
    bottom: auto;
    max-width: 15rem;
  }
  .crumb-family { white-space: nowrap; }
  .crumb__legend-list { flex-direction: column; align-items: flex-start; }
}

.crumb-family {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding: 0.2em 0.55em;
  font-size: var(--text-1);
  color: var(--text-secondary);
  background: color-mix(in oklch, var(--surface) 88%, transparent);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: color var(--duration-micro) var(--ease-out),
              border-color var(--duration-micro) var(--ease-out);
}

.crumb-family:hover { color: var(--text-primary); border-color: var(--chip); }

/* Pressed: the family's own colour as the border, plus bolder text — two
   channels, so a filter that is on never rests on hue. */
.crumb-family[aria-pressed='true'] {
  color: var(--text-primary);
  font-weight: 600;
  border-color: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip);
}

.crumb-family__count {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  opacity: 0.75;
}

.crumb-swatch {
  display: inline-block;
  flex-shrink: 0;
  width: 0.7em;
  height: 0.7em;
  border-radius: var(--radius-full);
  background: var(--chip);
}

.crumb__anatomy-key {
  position: absolute;
  inset-inline-start: var(--space-3);
  bottom: var(--space-3);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
  padding: 0.4em 0.9em;
  font-size: var(--text-1);
  color: var(--text-secondary);
  background: color-mix(in oklch, var(--surface) 88%, transparent);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  z-index: 1;
}

.crumb__anatomy-key span { display: inline-flex; align-items: center; gap: 0.4em; }
.crumb__anatomy-key svg { width: 1em; height: 1em; color: var(--text-accent); }

.crumb__tools {
  position: absolute;
  inset-inline-end: var(--space-3);
  bottom: var(--space-3);
  display: flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.3rem;
  background: color-mix(in oklch, var(--surface) 88%, transparent);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-full);
  z-index: 1;
}

.crumb__tools .crumb__btn { border-color: transparent; min-height: 2rem; width: 2rem; }
.crumb:is([data-view='list'], [data-view='matrix']) .crumb__tools { display: none; }

.crumb__size {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding-inline: 0.6em 0.2em;
  font-size: var(--text-1);
  color: var(--text-secondary);
}

.crumb__size select {
  padding: 0.25em 0.4em;
  font: inherit;
  color: var(--text-primary);
  background: var(--bg);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
}

.crumb[data-view='anatomy'] .crumb__size { display: none; }

/* --- tooltip -------------------------------------------------------------------------- */

.crumb__tip {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  width: max-content;
  max-width: min(22rem, calc(100% - 16px));
  padding: var(--space-3) var(--space-4);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  box-shadow: 0 18px 40px -18px hsl(var(--shadow-ink) / 0.7);
  pointer-events: none;
}

.crumb__tip[hidden] { display: none; }

.crumb-tip__name {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--text-3);
  line-height: 1.25;
  overflow-wrap: anywhere;
}

.crumb-tip__meta {
  display: flex;
  align-items: center;
  gap: 0.45em;
  margin-block: 0.15em 0.4em;
  font-size: var(--text-1);
  color: var(--text-secondary);
}

.crumb-tip__body { color: var(--text-secondary); line-height: var(--leading-normal); }
.crumb-tip__stats { margin-top: 0.4em; font-family: var(--font-mono); font-size: var(--text-1); color: var(--text-accent); }

.crumb-tip__rel {
  display: flex;
  align-items: baseline;
  gap: 0.45em;
  margin-top: var(--space-2);
  font-size: var(--text-1);
  color: var(--text-accent);
}

.crumb-tip__rel--in { color: var(--text-secondary); }

.crumb-tip__num,
.crumb-section__num {
  display: inline-grid;
  place-items: center;
  flex-shrink: 0;
  min-width: 1.5em;
  height: 1.5em;
  padding-inline: 0.3em;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-full);
}

.crumb-quote {
  margin: 0.35em 0 0;
  padding-inline-start: 0.75em;
  border-inline-start: 2px solid color-mix(in oklch, var(--accent) 55%, transparent);
  font-family: var(--font-display);
  font-style: italic;
  font-size: var(--text-2);
  line-height: 1.45;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

.crumb-quote mark {
  font-family: var(--font-mono);
  font-style: normal;
  font-size: 0.88em;
  color: var(--text-accent);
  background: color-mix(in oklch, var(--accent) 12%, transparent);
  border-radius: 4px;
  padding: 0 0.2em;
}

/* --- panel -------------------------------------------------------------------------------- */

.crumb__panel {
  display: flex;
  flex-direction: column;
  min-height: 0;
  overflow: hidden;
  background: var(--surface);
  border-inline-start: 1px solid var(--border-subtle);
}

.crumb__sheet-handle { display: none; }

.crumb__panel-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--space-4) var(--space-5) var(--space-6);
}

.crumb-panel__section + .crumb-panel__section {
  margin-top: var(--space-5);
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
}

.crumb-panel__title {
  margin-bottom: var(--space-3);
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: var(--text-1);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-accent);
}

.crumb-panel__count { color: var(--text-secondary); }

.crumb-panel__text {
  color: var(--text-secondary);
  line-height: var(--leading-relaxed);
}

.crumb-panel__hint {
  margin-top: var(--space-3);
  font-size: var(--text-1);
  color: var(--text-secondary);
}

.crumb-notes,
.crumb-roles,
.crumb-sections,
.crumb-inbounds,
.crumb-route {
  list-style: none;
  padding: 0;
}

.crumb-notes li + li,
.crumb-inbounds li + li { margin-top: var(--space-2); }

.crumb-note {
  display: block;
  width: 100%;
  padding: var(--space-3);
  text-align: start;
  line-height: var(--leading-relaxed);
  color: var(--text-secondary);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: border-color var(--duration-micro) var(--ease-out),
              background-color var(--duration-micro) var(--ease-out);
}

.crumb-note b { color: var(--text-primary); font-weight: 600; }
.crumb-note:hover { border-color: var(--border-strong); background: var(--surface-hover); }

.crumb-note[aria-pressed='true'] {
  border-color: var(--accent);
  background: color-mix(in oklch, var(--accent) 9%, var(--surface));
  box-shadow: inset 3px 0 0 var(--accent);
}

.crumb-chip {
  display: inline-flex;
  align-items: center;
  max-width: 100%;
  padding: 0.05em 0.45em;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  line-height: 1.5;
  color: var(--text-primary);
  background: var(--surface-hover);
  border-inline-start: 3px solid var(--chip);
  border-radius: 5px;
  overflow-wrap: anywhere;
  vertical-align: baseline;
}

a.crumb-chip:hover,
.crumb-chip--button:hover { color: var(--text-accent); }

.crumb-chip--button { cursor: pointer; }
.crumb-chip--family { font-family: var(--font-body); font-size: var(--text-1); }

.crumb-roles li + li { margin-top: var(--space-3); }

.crumb-role {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  color: var(--text-secondary);
  line-height: 1.45;
}

.crumb-role b { color: var(--text-primary); font-weight: 600; }
.crumb-role__count { font-family: var(--font-mono); font-size: var(--text-1); color: var(--text-accent); }

.crumb-role-glyph {
  flex-shrink: 0;
  width: 1.5rem;
  height: 1.5rem;
  color: var(--text-accent);
}

/* --- panel: one skill ------------------------------------------------------------------ */

.crumb-skill__name {
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-weight: 650;
  font-size: var(--text-5);
  line-height: 1.12;
  letter-spacing: -0.01em;
  overflow-wrap: anywhere;
}

.crumb-skill__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  margin-block: var(--space-3);
}

.crumb-skill__role {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  font-size: var(--text-1);
  color: var(--text-secondary);
}

.crumb-skill__role .crumb-role-glyph { width: 1.15rem; height: 1.15rem; }

.crumb-skill__flag {
  margin-bottom: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-1);
  color: var(--text-accent);
}

.crumb-stats {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--space-2);
  margin-block: var(--space-4);
}

.crumb-stats div {
  padding: var(--space-2);
  text-align: center;
  background: var(--bg);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
}

.crumb-stats dt {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-secondary);
}

.crumb-stats dd {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--text-4);
  font-variant-numeric: tabular-nums;
}

.crumb-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.crumb-sections li + li { margin-top: 2px; }

.crumb-section__head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  padding: 0.45em 0.5em;
  text-align: start;
  color: var(--text-secondary);
  border-radius: var(--radius-sm);
  cursor: pointer;
}

.crumb-section__head:hover { color: var(--text-primary); background: var(--surface-hover); }
.crumb-section__title { flex: 1; min-width: 0; overflow-wrap: anywhere; }

.crumb-section__cites {
  font-family: var(--font-mono);
  font-size: var(--text-1);
  color: var(--text-accent);
}

.crumb-section__cites::before { content: '→ '; }
.crumb-section--ref .crumb-section__num { border-style: dashed; }

.crumb-section--on > .crumb-section__head {
  color: var(--text-primary);
  background: color-mix(in oklch, var(--accent) 10%, var(--surface));
}

.crumb-section--on .crumb-section__num {
  color: var(--text-on-accent-solid);
  background: var(--accent-solid);
  border-color: var(--accent-solid);
}

.crumb-section__detail {
  display: grid;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-2) var(--space-3) 2.3em;
}

.crumb-cite { display: grid; gap: 0.2em; justify-items: start; }
.crumb-cite + .crumb-cite { margin-top: var(--space-2); }

.crumb-link {
  font-size: var(--text-1);
  color: var(--text-accent);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

.crumb-link:hover { color: var(--accent-hover); }
.crumb-link--button {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  cursor: pointer;
  text-decoration: underline;
}

.crumb-inbound {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: baseline;
  gap: 0 0.55em;
  width: 100%;
  padding: 0.4em 0.5em;
  text-align: start;
  border-radius: var(--radius-sm);
  cursor: pointer;
}

.crumb-inbound:hover { background: var(--surface-hover); }
.crumb-inbound__name { font-family: var(--font-mono); font-size: 0.8rem; overflow-wrap: anywhere; }
.crumb-inbound__from { grid-column: 2; font-size: var(--text-1); color: var(--text-secondary); overflow-wrap: anywhere; }

/* --- panel: trace ----------------------------------------------------------------------- */

.crumb-trace { display: grid; gap: var(--space-3); margin-top: var(--space-3); }

.crumb-trace label { display: grid; gap: 0.3em; font-size: var(--text-1); color: var(--text-secondary); }

.crumb-trace input {
  padding: 0.5em 0.75em;
  font-family: var(--font-mono);
  font-size: 0.85rem;
  color: var(--text-primary);
  background: var(--bg);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
}

.crumb-trace__error:empty { display: none; }
.crumb-trace__error { font-size: var(--text-1); color: var(--text-accent); }

/* The route: a gilded spine with each skill a stop on it. */
.crumb-route { position: relative; padding-inline-start: 1.4em; }

.crumb-route::before {
  content: '';
  position: absolute;
  inset-block: 0.8em 0.8em;
  inset-inline-start: 0.35em;
  width: 2px;
  background: linear-gradient(to bottom, var(--accent-solid), color-mix(in oklch, var(--accent-solid) 30%, transparent));
  border-radius: 2px;
}

.crumb-route__stop { position: relative; display: grid; gap: 0.3em; justify-items: start; }
.crumb-route__stop + .crumb-route__stop { margin-top: var(--space-4); }

.crumb-route__stop::before {
  content: '';
  position: absolute;
  inset-inline-start: -1.35em;
  top: 0.45em;
  width: 0.7em;
  height: 0.7em;
  border-radius: var(--radius-full);
  background: var(--surface);
  border: 2px solid var(--accent-solid);
}

.crumb-route__via { font-size: var(--text-1); color: var(--text-secondary); }

/* --- list view ---------------------------------------------------------------------------- */

.crumb__list {
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  padding: var(--space-5) var(--space-6) var(--space-7);
  background: var(--bg);
  cursor: auto;
  touch-action: auto;
}

.crumb__list[hidden] { display: none; }

.crumb-list__intro { color: var(--text-secondary); margin-bottom: var(--space-5); }

.crumb-list__family + .crumb-list__family { margin-top: var(--space-6); }

.crumb-list__title {
  display: flex;
  align-items: center;
  gap: 0.5em;
  margin-bottom: var(--space-3);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--text-4);
}

.crumb-list__skill {
  padding: var(--space-3) 0;
  border-top: 1px solid var(--border-subtle);
}

.crumb-list__skill h4 {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: var(--text-2);
}

.crumb-list__skill h4 a { color: var(--text-primary); text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 0.2em; }
.crumb-list__skill h4 a:hover { color: var(--text-accent); }

.crumb-list__role {
  display: flex;
  align-items: center;
  gap: 0.35em;
  margin-top: 0.3em;
  font-size: var(--text-1);
  color: var(--text-secondary);
}

.crumb-list__role .crumb-role-glyph { width: 1rem; height: 1rem; }

.crumb-list__refs {
  margin-top: var(--space-2);
  line-height: 2;
  color: var(--text-secondary);
}

.crumb-list__refs b {
  margin-inline-end: 0.4em;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: var(--text-1);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-accent);
}

/* --- matrix view -------------------------------------------------------------------------- */

/* One hue, dim to bright, mixed into the page so both themes keep the number
   legible: at the strongest cell the digits hold 5.2:1 on crust and 6.2:1 on
   flour, which is why the two themes stop at different strengths. The number
   is always printed, so the shade is never the only channel. */
.crumb__matrix {
  --matrix-hue: var(--gold-500);
  --matrix-max: 50%;
}
[data-theme='flour'] .crumb__matrix {
  --matrix-hue: var(--gold-700);
  --matrix-max: 70%;
}

.crumb-matrix__lede {
  max-width: 60ch;
  margin: calc(-1 * var(--space-3)) 0 var(--space-5);
  color: var(--text-primary);
}

.crumb-matrix__scroll { overflow-x: auto; padding-bottom: var(--space-2); }

.crumb-matrix {
  border-collapse: separate;
  border-spacing: 2px;
  font-family: var(--font-mono);
  font-size: var(--text-1);
  font-variant-numeric: tabular-nums;
}

.crumb-matrix th { font-weight: 500; }

.crumb-matrix__corner,
.crumb-matrix__row {
  /* The family column stays put while the grid scrolls under it. */
  position: sticky;
  inset-inline-start: 0;
  z-index: 1;
  background: var(--bg);
}

.crumb-matrix__corner {
  padding: 0 var(--space-2) var(--space-2) 0;
  vertical-align: bottom;
  color: var(--text-secondary);
  white-space: nowrap;
}

.crumb-matrix__col { padding-bottom: var(--space-1); vertical-align: bottom; }
.crumb-matrix__col abbr {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.25em;
  text-decoration: none;
  color: var(--text-secondary);
  cursor: help;
}

.crumb-matrix__row {
  padding-inline-end: var(--space-3);
  text-align: start;
  white-space: nowrap;
}
.crumb-matrix__row .crumb-swatch { margin-inline-end: 0.5em; vertical-align: 0.05em; }
.crumb-matrix__row .crumb-matrix__num { display: inline-block; min-width: 1.6em; color: var(--text-secondary); }
.crumb-matrix__name { font-family: var(--font-body); color: var(--text-primary); }
/* The row header is the family's own switch: it looks like the header it is,
   and answers the pointer and the keyboard like the chips in the legend. */
.crumb-matrix__pick {
  display: block;
  width: 100%;
  min-height: 2.75rem;
  padding: 0 var(--space-2) 0 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: start;
  white-space: nowrap;
  cursor: pointer;
}
.crumb-matrix__pick:hover .crumb-matrix__name { text-decoration: underline; text-underline-offset: 0.2em; }
.crumb-matrix__pick:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }

.crumb-matrix__cell { width: 2.75rem; height: 2.75rem; padding: 0; }
.crumb-matrix__cell--self { box-shadow: inset 0 0 0 1px var(--text-secondary); border-radius: var(--radius-sm); }
/* A family chosen in the legend: its cross is tinted, and its name and number
   are set bold, so the mark does not rest on the tint alone. */
.crumb-matrix__cell--on { background: color-mix(in oklch, var(--text-accent) 12%, transparent); }
.crumb-matrix__row--on,
.crumb-matrix__row--on .crumb-matrix__name,
.crumb-matrix__row--on .crumb-matrix__num,
.crumb-matrix__col--on .crumb-matrix__num { font-weight: 700; color: var(--text-accent); }
.crumb-matrix__lede--family { font-weight: 500; }
.crumb-matrix__cell--self button { box-shadow: inset 0 0 0 1px var(--text-secondary); }

.crumb-matrix__cell button {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--matrix-hue) calc(6% + var(--m) * (var(--matrix-max) - 6%)), var(--bg));
  color: var(--text-primary);
  font: inherit;
  cursor: pointer;
}
.crumb-matrix__cell button:hover { outline: 2px solid var(--text-accent); outline-offset: -2px; }
.crumb-matrix__cell button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }

/* On a phone the family names would take the whole width; the numbers in the
   first column stand for them, as they do along the top. */
@container (width < 40rem) {
  .crumb-matrix__name { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  .crumb-matrix__cell { width: 2.25rem; height: 2.25rem; }
  .crumb-matrix__pick { min-height: 2.25rem; }
  .crumb-matrix__corner span { display: block; }
}

/* --- narrow: the panel becomes a sheet over the stage -------------------------------------- */

/* Below 60rem a side panel leaves the stage too narrow to read — at 50rem it
   was 440px, and the legend, capped at the width the tools leave, stacked
   into a column over the graph. So the panel becomes a sheet wherever the
   legend stops standing up the left edge, and the graph takes the width. */
@container (width < 60rem) {
  .crumb__bar { gap: var(--space-2); padding: var(--space-2) var(--space-3); }
  /* The page's heading names the graph already, and the header's Graph link
     is marked current; the repeated title is a row the stage can have. */
  .crumb__heading { display: none; }
  .crumb__views { order: 1; }
  .crumb [data-crumb-trace] { order: 2; margin-inline-start: auto; }
  .crumb__search { order: 3; flex: 1 1 100%; min-width: 9rem; }
  .crumb__search input { width: 100%; }
  .crumb [data-crumb-trace] span { display: none; }
  .crumb [data-crumb-trace] { width: 2.25rem; padding: 0; }
  .crumb__view { padding: 0.35em 0.7em; }

  .crumb__body { display: block; }
  .crumb__stage { position: absolute; inset: 0; }

  .crumb__panel {
    position: absolute;
    inset-inline: 0;
    bottom: 0;
    z-index: 3;
    max-height: 3.25rem;
    border-inline-start: 0;
    border-top: 1px solid var(--border-strong);
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    box-shadow: 0 -18px 40px -24px hsl(var(--shadow-ink) / 0.7);
    transition: max-height var(--duration-view) var(--ease-out);
  }

  .crumb--sheet-open .crumb__panel { max-height: 72%; }

  .crumb__sheet-handle {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.3em;
    width: 100%;
    min-height: 3.2rem;
    padding: 0.45em var(--space-4) 0.6em;
    font-family: var(--font-mono);
    font-size: var(--text-1);
    color: var(--text-accent);
    cursor: pointer;
    flex-shrink: 0;
  }

  .crumb__sheet-grip {
    width: 2.5rem;
    height: 4px;
    border-radius: var(--radius-full);
    background: var(--border-strong);
  }

  .crumb__panel-body { padding-top: 0; }
  .crumb:not(.crumb--sheet-open) .crumb__panel-body { visibility: hidden; }

  .crumb__legend {
    inset-inline: var(--space-2);
    bottom: calc(3.25rem + var(--space-2));
    max-width: none;
  }

  .crumb__legend-title { display: none; }

  .crumb__legend-list {
    flex-wrap: nowrap;
    overflow-x: auto;
    padding-bottom: 2px;
    scrollbar-width: none;
  }

  .crumb-family { flex-shrink: 0; }

  .crumb__anatomy-key { bottom: calc(3.25rem + var(--space-2)); inset-inline-start: var(--space-2); }

  .crumb__tools {
    top: var(--space-2);
    bottom: auto;
    inset-inline-end: var(--space-2);
    flex-direction: column;
  }

  .crumb__size { display: none; }
  /* The tools have moved to the top and the legend to its own row here, so
     only the edges bound the hint. */
  .crumb__hint { left: 5%; right: 5%; bottom: calc(3.25rem + 3.5rem); }
  .crumb__trail { max-width: calc(100% - 4rem); }
  .crumb__list { padding: var(--space-4) var(--space-4) calc(3.25rem + var(--space-6)); }
  .crumb-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (prefers-reduced-motion: reduce) {
  .crumb__panel,
  .crumb__hint,
  .crumb__view,
  .crumb__btn,
  .crumb-note,
  .crumb-family { transition: none; }
}

@media (forced-colors: active) {
  .crumb-chip,
  .crumb-family,
  .crumb-note,
  .crumb__btn,
  .crumb__views { border: 1px solid ButtonText; }
  .crumb__view[aria-selected='true'],
  .crumb-family[aria-pressed='true'],
  .crumb-note[aria-pressed='true'] { outline: 2px solid Highlight; }
  .crumb-swatch { forced-color-adjust: none; }
}

/* The climb release. The shell is the other banners', shared above. What is
   its own is the picture: the loop the release is about, iteration markers
   rising in steps toward a dashed bar, hollow while the check fails and
   solid when it passes. The SVG is the climb at rest and is finished work in
   its own right; site.js lays a canvas over it that replays the climb and
   hides the drawing once it has drawn its first frame. */

.climb-banner {
  min-height: 19rem;
  background:
    radial-gradient(ellipse 46% 95% at 74% 50%,
      color-mix(in oklch, var(--accent-solid) 10%, var(--bg)) 0%,
      var(--bg) 80%);
}

.climb-banner__stage {
  position: absolute;
  inset: 0 0 0 auto;
  z-index: 0;
  width: 54%;
  padding: var(--space-6) var(--space-6) var(--space-6) 0;
  pointer-events: none;
}

.climb-banner__art,
.climb-banner__canvas {
  position: absolute;
  inset: var(--space-6) var(--space-6) var(--space-6) 0;
  width: calc(100% - var(--space-6));
  height: calc(100% - 2 * var(--space-6));
}

.climb-banner__art { overflow: visible; }
.climb-banner.is-drawn .climb-banner__art { visibility: hidden; }

.climb-banner__bar {
  stroke: var(--text-accent);
  stroke-width: 1.5;
  stroke-dasharray: 6 6;
  opacity: 0.7;
}

.climb-banner__trail {
  fill: none;
  stroke: color-mix(in oklch, var(--text-accent) 55%, var(--text-secondary));
  stroke-width: 2;
  stroke-linejoin: round;
}

.climb-banner__ring {
  fill: var(--bg);
  stroke: color-mix(in oklch, var(--text-accent) 70%, var(--text-secondary));
  stroke-width: 2;
}

.climb-banner__dot { fill: var(--accent-solid); }
.climb-banner__halo { fill: color-mix(in oklch, var(--accent-solid) 22%, transparent); }

.climb-banner__ships-label {
  margin-top: var(--space-4);
  font-size: var(--text-1);
  font-weight: 600;
  color: var(--text-secondary);
}

.climb-banner__ships {
  display: grid;
  gap: var(--space-1);
  max-width: 46ch;
  margin: var(--space-2) 0 0;
  padding: 0;
  list-style: none;
}

.climb-banner__ship-link {
  display: grid;
  grid-template-columns: 0.75rem 1fr;
  column-gap: var(--space-3);
  align-items: baseline;
  padding: var(--space-1) var(--space-2);
  margin-inline: calc(-1 * var(--space-2));
  border-radius: var(--radius-sm);
  color: inherit;
  text-decoration: none;
  transition: background-color var(--duration-micro) var(--ease-out);
}

.climb-banner__ship-link:hover { background: color-mix(in oklch, var(--accent-solid) 10%, transparent); }
.climb-banner__ship-link:focus-visible { outline: 2px solid var(--text-accent); outline-offset: 1px; }

/* The marks are the climb's own: a ring for the work, a solid dot for what
   passes. Drawn in CSS so they match the picture, never a glyph. */
.climb-banner__ship-mark {
  grid-row: span 2;
  align-self: center;
  width: 0.7rem;
  height: 0.7rem;
  border: 2px solid var(--text-accent);
  border-radius: 50%;
}
.climb-banner__ship--mod .climb-banner__ship-mark { background: var(--accent-solid); border-color: var(--accent-solid); }

.climb-banner__ship-name {
  font-family: var(--font-mono);
  font-size: 0.92em;
  font-weight: 600;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

.climb-banner__ship-link:hover .climb-banner__ship-name { color: var(--text-accent); }

.climb-banner__ship-note {
  grid-column: 2;
  font-size: var(--text-1);
  line-height: 1.4;
  color: var(--text-secondary);
}

@container banner (max-width: 44rem) {
  .climb-banner { padding-top: 12rem; }
  .climb-banner__stage {
    inset: 0 0 auto 0;
    width: auto;
    height: 12rem;
    padding: 0;
  }
  .climb-banner__art,
  .climb-banner__canvas {
    inset: var(--space-4) var(--space-5) var(--space-3);
    width: calc(100% - 2 * var(--space-5));
    height: calc(100% - var(--space-4) - var(--space-3));
  }
  .climb-banner__ships { margin-bottom: var(--space-5); }
}

@media (forced-colors: active) {
  .climb-banner { border: 2px solid CanvasText; }
  .climb-banner::before, .climb-banner__body::before { display: none; }
  .climb-banner__canvas { display: none; }
  .climb-banner.is-drawn .climb-banner__art { visibility: visible; }
  .climb-banner__bar, .climb-banner__trail, .climb-banner__ring { stroke: CanvasText; }
  .climb-banner__dot { fill: Highlight; }
  .climb-banner__halo { display: none; }
  .climb-banner__ship-mark { border-color: CanvasText; }
  .climb-banner__ship--mod .climb-banner__ship-mark { background: Highlight; }
}
