/*
 * bailey-forbes.com — single stylesheet.
 *
 * Follows DESIGN.md. Two rules matter most when editing this file:
 *   1. Components use the SEMANTIC role tokens only (B1). The raw palette below
 *      exists to give those roles a value; do not reference it from a component.
 *   2. A bordered surface means "discrete destination" or "repeated record"
 *      (B3). Prose does not get a box.
 */

/*
 * ---------------------------------------------------------------- tokens --
 *
 * One token layer, four renderings. The site has two themes (the dark
 * workshop ground that is its default, and a paper rendering for light
 * mode) and each has a standard and a high-contrast cut. All four are
 * selected here, by attributes the Display control writes onto <html>
 * (src/display.ts) or, with scripting off and nothing chosen, by the
 * visitor's own OS preferences:
 *
 *   data-theme     dark | light       (absent = follow prefers-color-scheme)
 *   data-contrast  standard | high    (absent = follow prefers-contrast)
 *   data-motion    full | reduced     (absent = follow prefers-reduced-motion)
 *   data-text      default | large
 *
 * Components still use the SEMANTIC role tokens only (B1). The raw palette
 * exists to give those roles a value in each rendering; do not reference it
 * from a component.
 *
 * Every text/surface pair below holds >=4.5:1 and every control boundary and
 * focus ring >=3:1 in all four renderings — the figures are in DESIGN.md B1
 * and the check is tools/contrast.py. The dark values are written first as
 * the fallback, then re-declared with light-dark() so one declaration can
 * carry both themes and the switch is just `color-scheme`. light-dark()
 * takes the LIGHT value first.
 */

:root {
  color-scheme: dark;

  /* Raw palette — referenced only by the role tokens directly below it. */
  --p-near-black: #10120f;
  --p-bark: #181a16;
  --p-moss-dark: #1f221c;
  --p-line: #2c2f28;
  --p-line-strong: #3d4036;
  --p-stone: #6d7264;
  --p-bone: #ebe6d8;
  --p-linen: #c4bdae;
  --p-ash: #8f897b;
  --p-copper: #c98a4a;
  --p-copper-soft: #e0b27a;
  --p-patina: #7ea36a;
  --p-patina-soft: #9dc389;
  --p-rust: #e0685c;
  --p-paper: #f3ead8;
  --p-paper-ink: #2a2418;
  --p-paper-ink-soft: #6b5e48;
  --p-ink: #0b0c0a;

  /* The paper rendering's raw palette. */
  --p-paper-page: #f4eee2;
  --p-paper-raised: #fbf7ee;
  --p-paper-sunken: #ebe3d2;
  --p-paper-card: #fffaf0;
  --p-paper-text: #1f1b14;
  --p-paper-muted: #332e24;
  --p-paper-subtle: #453d2e;
  --p-paper-line: #d8cfbb;
  --p-paper-line-strong: #b9ad94;
  --p-paper-stone: #7d7460;
  --p-copper-deep: #8a4612;
  --p-copper-deeper: #6b350c;
  --p-patina-deep: #3f6b33;
  --p-patina-deeper: #2f6a2a;
  --p-rust-deep: #a8281f;

  /* Surfaces */
  --surface-page: var(--p-near-black);
  --surface-raised: var(--p-bark);
  --surface-sunken: var(--p-moss-dark);
  /* Card stock. Used only by .note-card — see DESIGN.md B4. */
  --surface-invert: var(--p-paper);

  /* Text */
  --text: var(--p-bone);
  --text-muted: var(--p-linen);
  --text-subtle: var(--p-ash);
  --text-on-action: var(--p-ink);
  --text-on-invert: var(--p-paper-ink);
  --text-on-invert-muted: var(--p-paper-ink-soft);

  /* Borders. --border-interactive is the only one allowed to be a control's
     visible boundary: it holds >=3:1 against all three surfaces (WCAG 1.4.11). */
  --border: var(--p-line);
  --border-strong: var(--p-line-strong);
  --border-interactive: var(--p-stone);

  /* Action and accent */
  --action: var(--p-copper);
  --action-hover: var(--p-copper-soft);
  --accent: var(--p-patina);
  --focus: var(--p-copper-soft);

  /* Status — always paired with text, never colour alone (R25). */
  --status-ok: var(--p-patina-soft);
  --status-wait: var(--p-copper-soft);
  --status-error: var(--p-rust);

  /* Hairline rules and the ruled ground. The ground is a faint grid drawn
     only in the page margins (see .site-ground), so no text ever sits on it. */
  --rule: 1px;
  --rule-strong: 1px;
  --ground-line: rgba(235, 230, 216, 0.05);

  /*
   * Glass. A tinted, blurred surface with a lit edge, in the manner of Apple's
   * Liquid Glass but tuned so text keeps its contrast on it: the tint is dense
   * enough that the worst backdrop the surface can sit over still gives every
   * text role >=4.5:1 (tools/contrast.py checks that case, not just the flat
   * one). Where blur is unavailable, in either high-contrast cut, or when the
   * OS asks for reduced transparency, every glass token collapses to the opaque
   * surface and the glow behind it disappears.
   */
  --glass-opaque: var(--p-bark);
  --glass-tint: rgba(24, 26, 22, 0.74);
  --glass-tint-bar: rgba(16, 18, 15, 0.86);
  --glass-tint-sheet: rgba(24, 26, 22, 0.93);
  --glass-fill: rgba(255, 255, 255, 0.06);
  --glass-rim: rgba(255, 255, 255, 0.16);
  --glass-hi: rgba(255, 255, 255, 0.22);
  --glass-gloss: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0) 16px);
  --glass-sheen: rgba(255, 255, 255, 0.2);
  --glass-shadow: 0 10px 34px rgba(0, 0, 0, 0.32);
  --glass-blur: 20px;
  --glass-saturate: 1.7;
  --glow-a: rgba(201, 138, 74, 0.18);
  --glow-b: rgba(126, 163, 106, 0.13);
  --scrim: rgba(8, 9, 7, 0.55);
  --radius-sheet: 24px;
  /* Resolved below: opaque until the browser proves it can blur. */
  --glass-bg: var(--glass-opaque);
  --glass-bg-bar: var(--glass-opaque);
  --glass-bg-sheet: var(--glass-opaque);

  /* Spacing scale (4px ramp). Layout rhythm uses these, not one-off values. */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --space-6: 2rem;
  --space-7: 2.75rem;
  --space-8: 3.5rem;
  --space-9: 5rem;

  /* Radii, elevation, motion. The radii are small on purpose: this is a
     drafted document, not a pile of rounded tiles. */
  --radius-sm: 3px;
  --radius-md: 4px;
  --radius-lg: 6px;
  /* Tiles in the overview grid: rounder than a rule-bound record, still far
     from a pill. */
  --radius-tile: 18px;
  --elevation-1: 0 18px 50px rgba(0, 0, 0, 0.35);
  --motion-fast: 120ms;
  --motion-base: 200ms;

  /* Type */
  --font-display: "Fraunces", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
  --font-body: "Sora", "Avenir Next", "Segoe UI", system-ui, sans-serif;
  --font-mono: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;

  /* Measure */
  --measure-page: 1120px;
  --measure-prose: 42rem;
  --tap-min: 24px;
  --tap-comfortable: 44px;

  /*
   * Layer tokens. These carry the COMPACT values; the wide layer at the foot
   * of this file redefines them. Components read only these, never a raw
   * breakpoint, so a component never needs to know which layer it is in.
   */
  --content-padding: 1rem;
  --section-spacing: var(--space-6);
  --nav-h: 56px;
}

/* --- theme: dark | light ---------------------------------------------------- */

/* Which rendering `color-scheme` resolves to. An explicit choice wins; with
   none made the browser's own preference decides, scripting or not. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    color-scheme: light;
  }
}

:root[data-theme="light"] {
  color-scheme: light;
}

:root[data-theme="dark"] {
  color-scheme: dark;
}

@supports (color: light-dark(#fff, #000)) {
  :root {
    --surface-page: light-dark(var(--p-paper-page), var(--p-near-black));
    --surface-raised: light-dark(var(--p-paper-raised), var(--p-bark));
    --surface-sunken: light-dark(var(--p-paper-sunken), var(--p-moss-dark));
    --surface-invert: light-dark(var(--p-paper-card), var(--p-paper));

    --text: light-dark(var(--p-paper-text), var(--p-bone));
    --text-muted: light-dark(var(--p-paper-muted), var(--p-linen));
    --text-subtle: light-dark(var(--p-paper-subtle), var(--p-ash));
    --text-on-action: light-dark(#ffffff, var(--p-ink));
    --text-on-invert: light-dark(var(--p-paper-ink), var(--p-paper-ink));
    --text-on-invert-muted: light-dark(#5c5040, var(--p-paper-ink-soft));

    --border: light-dark(var(--p-paper-line), var(--p-line));
    --border-strong: light-dark(var(--p-paper-line-strong), var(--p-line-strong));
    --border-interactive: light-dark(var(--p-paper-stone), var(--p-stone));

    --action: light-dark(var(--p-copper-deep), var(--p-copper));
    --action-hover: light-dark(var(--p-copper-deeper), var(--p-copper-soft));
    --accent: light-dark(var(--p-patina-deep), var(--p-patina));
    --focus: light-dark(var(--p-copper-deep), var(--p-copper-soft));

    --status-ok: light-dark(var(--p-patina-deeper), var(--p-patina-soft));
    --status-wait: light-dark(var(--p-copper-deep), var(--p-copper-soft));
    --status-error: light-dark(var(--p-rust-deep), var(--p-rust));

    --ground-line: light-dark(rgba(42, 36, 24, 0.07), rgba(235, 230, 216, 0.05));

    --glass-opaque: light-dark(var(--p-paper-raised), var(--p-bark));
    --glass-tint: light-dark(rgba(251, 247, 238, 0.72), rgba(24, 26, 22, 0.74));
    --glass-tint-bar: light-dark(rgba(244, 238, 226, 0.84), rgba(16, 18, 15, 0.86));
    --glass-tint-sheet: light-dark(rgba(251, 247, 238, 0.93), rgba(24, 26, 22, 0.93));
    --glass-fill: light-dark(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.06));
    --glass-rim: light-dark(rgba(90, 72, 40, 0.24), rgba(255, 255, 255, 0.16));
    --glass-hi: light-dark(rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22));
    --glass-gloss: light-dark(linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0) 16px), linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0) 16px));
    --glass-sheen: light-dark(rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.2));
    --glass-shadow: light-dark(0 10px 34px rgba(42, 36, 24, 0.16), 0 10px 34px rgba(0, 0, 0, 0.32));
    --glow-a: light-dark(rgba(201, 138, 74, 0.22), rgba(201, 138, 74, 0.18));
    --glow-b: light-dark(rgba(126, 163, 106, 0.18), rgba(126, 163, 106, 0.13));
    --scrim: light-dark(rgba(31, 27, 20, 0.4), rgba(8, 9, 7, 0.55));
    --elevation-1: light-dark(0 18px 50px rgba(42, 36, 24, 0.14), 0 18px 50px rgba(0, 0, 0, 0.35));
  }
}

/* --- contrast: standard | high ---------------------------------------------- */

/*
 * The high-contrast cut: 7:1 or better for every text role, thicker rules,
 * a white (or black) focus ring, no translucent texture anywhere. The block
 * is written twice because a media query cannot share a rule with an
 * attribute selector: once for the explicit choice, once for an OS
 * preference with no choice made. Keep the two identical.
 */
:root[data-contrast="high"] {
  --surface-page: light-dark(#ffffff, #000000);
  --surface-raised: light-dark(#ffffff, #0d0e0c);
  --surface-sunken: light-dark(#f1f1f1, #161815);
  --surface-invert: light-dark(#ffffff, #ffffff);
  --text: light-dark(#000000, #ffffff);
  --text-muted: light-dark(#111111, #f1ede4);
  --text-subtle: light-dark(#2a2a2a, #d9d4c7);
  --text-on-action: light-dark(#ffffff, #000000);
  --text-on-invert: #000000;
  --text-on-invert-muted: #2b2b2b;
  --border: light-dark(#8a8a8a, #6f746a);
  --border-strong: light-dark(#5a5a5a, #9ea396);
  --border-interactive: light-dark(#2a2a2a, #c9cdc3);
  --action: light-dark(#6b3400, #f3bd74);
  --action-hover: light-dark(#4a2300, #ffd59a);
  --accent: light-dark(#1f4d17, #b8e0a4);
  --focus: light-dark(#000000, #ffffff);
  --status-ok: light-dark(#1f4d17, #b8e0a4);
  --status-wait: light-dark(#6b3400, #f3bd74);
  --status-error: light-dark(#9c1c12, #ff9d93);
  --ground-line: transparent;
  --elevation-1: none;
  --rule: 2px;
  --rule-strong: 2px;
  --glass-opaque: var(--surface-raised);
  --glass-tint: var(--surface-raised);
  --glass-tint-bar: var(--surface-page);
  --glass-tint-sheet: var(--surface-raised);
  --glass-fill: transparent;
  --glass-rim: var(--border-strong);
  --glass-hi: transparent;
  --glass-gloss: none;
  --glass-sheen: transparent;
  --glass-shadow: none;
  --glass-blur: 0px;
  --glass-saturate: 1;
  --glow-a: transparent;
  --glow-b: transparent;
  --scrim: rgba(0, 0, 0, 0.72);
}

@media (prefers-contrast: more) {
  :root:not([data-contrast="standard"]) {
    --surface-page: light-dark(#ffffff, #000000);
    --surface-raised: light-dark(#ffffff, #0d0e0c);
    --surface-sunken: light-dark(#f1f1f1, #161815);
    --surface-invert: light-dark(#ffffff, #ffffff);
    --text: light-dark(#000000, #ffffff);
    --text-muted: light-dark(#111111, #f1ede4);
    --text-subtle: light-dark(#2a2a2a, #d9d4c7);
    --text-on-action: light-dark(#ffffff, #000000);
    --text-on-invert: #000000;
    --text-on-invert-muted: #2b2b2b;
    --border: light-dark(#8a8a8a, #6f746a);
    --border-strong: light-dark(#5a5a5a, #9ea396);
    --border-interactive: light-dark(#2a2a2a, #c9cdc3);
    --action: light-dark(#6b3400, #f3bd74);
    --action-hover: light-dark(#4a2300, #ffd59a);
    --accent: light-dark(#1f4d17, #b8e0a4);
    --focus: light-dark(#000000, #ffffff);
    --status-ok: light-dark(#1f4d17, #b8e0a4);
    --status-wait: light-dark(#6b3400, #f3bd74);
    --status-error: light-dark(#9c1c12, #ff9d93);
    --ground-line: transparent;
    --elevation-1: none;
    --rule: 2px;
    --rule-strong: 2px;
    --glass-opaque: var(--surface-raised);
    --glass-tint: var(--surface-raised);
    --glass-tint-bar: var(--surface-page);
    --glass-tint-sheet: var(--surface-raised);
    --glass-fill: transparent;
    --glass-rim: var(--border-strong);
    --glass-hi: transparent;
    --glass-gloss: none;
    --glass-sheen: transparent;
    --glass-shadow: none;
    --glass-blur: 0px;
    --glass-saturate: 1;
    --glow-a: transparent;
    --glow-b: transparent;
    --scrim: rgba(0, 0, 0, 0.72);
  }
}

/*
 * Glass resolves to its opaque surface unless the browser can blur behind it,
 * and goes opaque again when the OS asks for reduced transparency. A visitor
 * who chose the standard contrast cut explicitly still gets the OS transparency
 * preference honoured, since that is a different need (legibility over noise).
 */
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  :root {
    --glass-bg: var(--glass-tint);
    --glass-bg-bar: var(--glass-tint-bar);
    --glass-bg-sheet: var(--glass-tint-sheet);
  }
}

@media (prefers-reduced-transparency: reduce) {
  :root {
    --glass-tint: var(--glass-opaque);
    --glass-tint-bar: var(--surface-page);
    --glass-tint-sheet: var(--glass-opaque);
    --glass-fill: transparent;
    --glass-hi: transparent;
    --glass-gloss: none;
    --glass-sheen: transparent;
    --glass-blur: 0px;
    --glass-saturate: 1;
    --glow-a: transparent;
    --glow-b: transparent;
  }
}

/* --- text: default | large -------------------------------------------------- */

/* Everything below is in rem, so this one declaration scales the whole
   page — the same mechanism as the browser's own font-size setting, which
   it adds to rather than replaces. */
:root[data-text="large"] {
  font-size: 118%;
}

/*
 * ------------------------------------------------------------------ layers --
 *
 * The site has two presentation layers over ONE document: compact (single
 * column, touch) and wide (multi-column, pointer). Everything up to the WIDE
 * LAYER block at the foot of this file is compact; that block is the whole of
 * the difference between them.
 *
 * The boundary is a media query, so the layout follows the browser and needs
 * no JavaScript: it is right on the first paint, right with scripting off, and
 * right the instant a window is resized.
 *
 * The boundary is in `rem`, not pixels, so it also follows the reader's own
 * browser settings. Someone who has set a larger default font size gets the
 * single column held further up the width range, because their text needs the
 * room — which is the point of the layer, not the device it happens to be on.
 */

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

html {
  scroll-behavior: smooth;
  /* Reserve the sticky header's band so nothing scrolled into view — an anchor
     target or the next control under Tab — lands underneath it (WCAG 2.4.11). */
  scroll-padding-top: calc(var(--nav-h) + var(--space-3));
}

body {
  margin: 0;
  min-height: 100vh;
  background: var(--surface-page);
  color: var(--text);
  font-family: var(--font-body);
  font-size: 1.03rem;
  line-height: 1.65;
  -webkit-text-size-adjust: 100%;
  /* Tap feedback without the 300ms wait; pinch-zoom is untouched. */
  touch-action: manipulation;
}

/*
 * The ruled ground: a faint 4rem grid, the one texture on the site, and it
 * is confined to the page margins (the space outside --measure-page) so no
 * text is ever measured against it. It is what makes a wide window read as a
 * drafting sheet with the content set on it, rather than a dark void with a
 * column in the middle. Zero-width on a phone, where there is no margin, and
 * transparent in the high-contrast cut.
 */
.site-ground {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-image:
    linear-gradient(to right, var(--ground-line) 1px, transparent 1px),
    linear-gradient(to bottom, var(--ground-line) 1px, transparent 1px);
  background-size: 4rem 4rem;
  background-position: 50% 0;
  /* Cut the grid out from behind the content column. */
  -webkit-mask:
    linear-gradient(#000, #000) 50% 0 / min(100%, calc(var(--measure-page) + var(--content-padding) * 2)) 100% no-repeat,
    linear-gradient(#000, #000);
  -webkit-mask-composite: xor;
  mask:
    linear-gradient(#000, #000) 50% 0 / min(100%, calc(var(--measure-page) + var(--content-padding) * 2)) 100% no-repeat,
    linear-gradient(#000, #000);
  mask-composite: exclude;
}

/* height:auto is load-bearing: without it the width/height attributes (kept for
   CLS) pin every image to its natural pixel height and aspect-ratio is ignored. */
img {
  max-width: 100%;
  height: auto;
}

/* The copy is full of repo slugs and hostnames; let them break rather than
   push the page sideways (R24). */
p,
li,
dt,
dd,
figcaption {
  overflow-wrap: break-word;
}

a {
  color: var(--action-hover);
  text-decoration-thickness: var(--rule);
  text-underline-offset: 0.18em;
  transition: color var(--motion-fast) ease-out;
}

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

/* R25: one visible focus ring, same everywhere, never clipped by a parent. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/*
 * Icons (Font Awesome Free 6.7.2, inlined — see tools/README.md).
 *
 * Every one sits next to text that already says what it means, so none is
 * load-bearing: they are aria-hidden, sized in em so they track their label,
 * and filled with currentColor so they inherit whatever state the text is in.
 */
.icon {
  width: 1em;
  height: 1em;
  flex: none;
  fill: currentColor;
  vertical-align: -0.125em;
}

/* Beside a heading the glyph reads better a little smaller and set back. */
.icon-head {
  width: 0.78em;
  height: 0.78em;
  margin-right: 0.45em;
  color: var(--action);
}

/* Inline (non-flex) contexts need the gap the flex ones get from `gap`. */
li > .icon,
dt > .icon,
.contact-label > .icon {
  margin-right: 0.5em;
}

/*
 * Beside the small mono labels an em-sized glyph lands around 11px and reads as
 * a smudge, which defeats the point of it. These are sized in rem so they sit
 * independent of the label and anchor it instead of vanishing into it.
 */
/* A callout names a limitation; the glyph takes the rule's colour so the two
   read as one marker rather than two. */
.icon-note {
  margin-right: 0.5em;
  color: var(--action);
  vertical-align: -0.15em;
}

/* Inside a chip the glyph shares a small pill with its word. */
.chip > .icon {
  margin-right: 0.4em;
  opacity: 0.9;
}

/* Beside an entry heading it labels the kind of record without competing. */
.entry h3 > .icon,
.spec-list dt a > .icon {
  margin-right: 0.5em;
  color: var(--action);
}

.spec-list dt > .icon,
.contact-label > .icon {
  width: 1rem;
  height: 1rem;
  margin-right: 0.55rem;
  vertical-align: -0.22rem;
  color: var(--action);
}

.skip-link {
  position: absolute;
  left: var(--space-4);
  top: -4rem;
  z-index: 30;
  background: var(--action);
  color: var(--text-on-action);
  padding: var(--space-2) var(--space-3);
  font-weight: 600;
}

.skip-link:focus {
  top: var(--space-4);
}

/* ---------------------------------------------------------------- header -- */

.site-header {
  position: sticky;
  top: 0;
  z-index: 20;
  /* The header's markup is injected by site.ts. Reserving its band here keeps
     the first paint the same height as the hydrated one (CLS). */
  min-height: var(--nav-h);
  background: var(--surface-page);
  border-bottom: var(--rule) solid var(--border-strong);
}

.nav-wrap {
  max-width: var(--measure-page);
  margin: 0 auto;
  min-height: var(--nav-h);
  padding: var(--space-2) var(--content-padding);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: var(--tap-comfortable);
  color: inherit;
  text-decoration: none;
}

/* The BF monogram, drawn to match favicon.svg: same rounded square, same
   copper hairline, same serif initials, so the tab and the header read as one
   mark. */
.brand-mark {
  flex: none;
  width: 36px;
  height: 36px;
  display: grid;
  place-items: center;
  border: var(--rule) solid var(--action);
  border-radius: var(--radius-sm);
  color: var(--action-hover);
  font-family: var(--font-display);
  font-size: 0.95rem;
  font-weight: 600;
  letter-spacing: 0.02em;
}

.brand-text {
  display: flex;
  flex-direction: column;
  line-height: 1.15;
}

.brand-text strong {
  font-family: var(--font-display);
  font-size: 1.05rem;
}

.brand-text span {
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* The toggle, the links and the Display control, as one cluster. The link
   panel positions itself against the header, not this box, so the box is
   deliberately not a containing block in the compact layer. */
.nav-tools {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

/* Compact: hidden. The menu there is a bottom sheet (src/sheet.ts); the wide
   layer below shows this list inline. */
.nav-links {
  display: none;
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  flex-direction: column;
  align-items: stretch;
  gap: 0;
  list-style: none;
  margin: 0;
  padding: var(--space-2) var(--content-padding) var(--space-4);
  background: var(--surface-page);
  border-bottom: 1px solid var(--border);
}

.nav-links a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-comfortable);
  padding: 0 var(--space-2);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  text-decoration: none;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  transition: color var(--motion-fast) ease-out, background-color var(--motion-fast) ease-out;
}

.nav-links a:hover {
  color: var(--text);
  background: var(--surface-raised);
}

/* Current page is marked by an underline as well as colour (R25: not colour
   alone), so it survives a monochrome or high-contrast rendering. */
.nav-links a[aria-current="page"] {
  color: var(--text);
  box-shadow: inset 0 calc(var(--rule-strong) * -2) 0 0 var(--action);
}

.nav-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--tap-comfortable);
  background: transparent;
  color: var(--text);
  border: var(--rule) solid var(--border-interactive);
  border-radius: var(--radius-sm);
  padding: 0 var(--space-3);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  cursor: pointer;
  transition: border-color var(--motion-fast) ease-out, transform var(--motion-fast) ease-out;
}

.nav-toggle:hover {
  border-color: var(--action);
}

.nav-toggle:active {
  transform: scale(0.97);
}

/*
 * Hamburger / close, layered in one grid cell so the swap costs no reflow —
 * this is the near-universal open/close pair for a mobile nav (Jakob's Law),
 * and pairing it with the word "Menu" keeps the control unambiguous for a
 * visitor who has never seen this particular header. The cross-fade between
 * the two glyphs is animated rather than an instant swap, per the cookbook's
 * icon-swap recipe (§5): an instant swap on a control this small is easy to
 * miss entirely.
 */
.nav-toggle-icon {
  display: grid;
  width: 1em;
  height: 1em;
}

.nav-toggle-icon .icon {
  grid-area: 1 / 1;
  transition: opacity var(--motion-fast) ease-out;
}

.nav-toggle-icon .icon:last-child {
  opacity: 0;
}

.nav-toggle[aria-expanded="true"] .nav-toggle-icon .icon:first-child {
  opacity: 0;
}

.nav-toggle[aria-expanded="true"] .nav-toggle-icon .icon:last-child {
  opacity: 1;
}

/* ------------------------------------------------------------- structure -- */

main {
  position: relative;
}

.wrap {
  max-width: var(--measure-page);
  margin: 0 auto;
  padding: 0 var(--content-padding);
}

section {
  padding: var(--space-3) 0 var(--section-spacing);
}

h1,
h2,
h3,
h4 {
  font-family: var(--font-display);
  font-weight: 560;
  letter-spacing: -0.02em;
  line-height: 1.15;
  overflow-wrap: break-word;
  font-variation-settings: "SOFT" 20, "WONK" 0;
}

/*
 * Floors are set so the longest real heading — "Electronic medical record" —
 * fits a 320px viewport without horizontal overflow (R24).
 *
 * The page title is the one place Fraunces' "wonky" alternates are turned on:
 * the slanted terminals and the old-style letterforms are what make the name
 * read as a set piece of lettering rather than a default serif. Everywhere
 * else the axis stays off, so the quirk is a signature, not a texture.
 */
h1 {
  margin: 0 0 var(--space-4);
  font-size: clamp(2.5rem, 8vw, 6rem);
  font-weight: 600;
  line-height: 0.98;
  letter-spacing: -0.03em;
  font-variation-settings: "SOFT" 40, "WONK" 1;
}

/* Breadcrumb: where this page sits, as a real list of links (WAI-ARIA APG). */
.breadcrumb {
  margin: 0 0 var(--space-4);
}

.breadcrumb ol {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
  color: var(--text-muted);
  font-size: 0.9rem;
}

.breadcrumb li {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}

.breadcrumb li + li::before {
  content: "/";
  color: var(--text-subtle);
}

.breadcrumb a,
.breadcrumb [aria-current] {
  display: inline-flex;
  align-items: center;
  min-height: 1.5rem;
}

.breadcrumb [aria-current] {
  color: var(--text);
}

/* The line under a page title: role, dates, stack. Plain text, not a label. */
.page-meta {
  margin: 0 0 var(--space-4);
  color: var(--text-muted);
  font-size: 1rem;
  font-variant-numeric: tabular-nums;
}

h1:has(+ .page-meta) {
  margin-bottom: var(--space-2);
}

.lede {
  margin: 0;
  max-width: var(--measure-prose);
  color: var(--text-muted);
  font-size: 1.12rem;
}

/*
 * Section heads are numbered like the sections of a spec: a rule across the
 * top, a two-digit folio in mono, then the title. The blurb belongs to its
 * heading, so it sits under it rather than being pushed to the far edge of a
 * 1120px row (R08: related items sit closer).
 *
 * Only the home page's top-level sections are counted (`.wrap > section`);
 * the project pages' sections are nested inside a prose column and a
 * disclosure and would number in an order that means nothing. The numeral is
 * generated content with an empty alternative text, so assistive technology
 * reads the heading, not "zero one Featured work".
 */
main {
  counter-reset: section;
}

.wrap > section:not(.hero):not(.bento-section) {
  counter-increment: section;
}

.section-head {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-bottom: var(--space-5);
  padding-top: var(--space-4);
  border-top: var(--rule-strong) solid var(--border-strong);
}

.section-head h2 {
  margin: 0;
  font-size: clamp(1.7rem, 3vw, 2.4rem);
}

.wrap > section > .section-head h2::before {
  content: counter(section, decimal-leading-zero) / "";
  display: inline-block;
  margin-right: var(--space-4);
  color: var(--action-hover);
  font-family: var(--font-mono);
  font-size: 0.85rem;
  font-weight: 400;
  letter-spacing: 0.1em;
  vertical-align: 0.45em;
}

.section-head p,
.intro {
  margin: 0;
  color: var(--text-muted);
  max-width: var(--measure-prose);
}

/* The date on a project row. */
.ledger-date {
  margin: 0;
  color: var(--accent);
  font-size: 0.9rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.prose {
  max-width: 46rem;
}

.prose p,
.prose li {
  color: var(--text-muted);
}

.prose h2 {
  font-size: clamp(1.5rem, 2.4vw, 1.9rem);
}

/*
 * The project page's deep technical write-up, collapsed behind one toggle.
 * A visitor reads the short "why it exists" paragraph beside this by
 * default; the multi-section prose that used to run at that same visual
 * weight moves in here unchanged; nothing was rewritten to make room. A
 * native <details>/<summary> gives the toggle free keyboard support and,
 * for a screen reader, correct expanded/collapsed announcement without
 * anything here having to build that — see releases.ts, which reaches for
 * the same element for the same reason. The height transition below is new:
 * releases.ts didn't need one because its content is one line: this content
 * is several paragraphs, and "a transition framed around the project" was
 * asked for directly.
 */
.disclosure {
  margin-top: var(--space-6);
  border-top: 1px solid var(--border);
}

.disclosure > summary {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: var(--tap-comfortable);
  padding: var(--space-4) 0;
  cursor: pointer;
  list-style: none;
  color: var(--action-hover);
  font-family: var(--font-display);
  font-size: 1.15rem;
  transition: color var(--motion-fast) ease-out;
}

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

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

/* Same chevron as .release-toggle (releases.ts) — one open/close idiom
   across the site rather than a second one invented for this component. */
.disclosure-toggle {
  flex: none;
  width: 8px;
  height: 8px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transition: transform var(--motion-base) ease-out;
}

.disclosure[open] > summary .disclosure-toggle {
  transform: rotate(225deg);
}

/*
 * The "animate to auto height" grid trick: a track that is 0fr collapses to
 * zero height without needing a max-height guess, and 1fr expands to fit
 * whatever content is actually inside. The UA stylesheet sets
 * display:none on a closed <details>'s non-summary children, which cannot
 * be transitioned to or from — the :not([open]) rule below overrides that
 * back to display:grid so the height transition has something to animate.
 * 420ms rather than --motion-base: this reveals several paragraphs at once,
 * a much larger change than the 200ms token was chosen for.
 */
.disclosure-body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 420ms ease;
}

.disclosure:not([open]) > .disclosure-body {
  display: grid;
}

.disclosure[open] > .disclosure-body {
  grid-template-rows: 1fr;
}

.disclosure-body-inner {
  overflow: hidden;
  min-height: 0;
}

.disclosure-body-inner > :first-child {
  margin-top: var(--space-2);
}

code {
  font-family: var(--font-mono);
  font-size: 0.92em;
}

.note {
  margin: var(--space-5) 0 0;
  padding: var(--space-3) var(--space-4);
  border-left: 3px solid var(--action);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  background: var(--surface-sunken);
  color: var(--text-muted);
  font-size: 0.9rem;
}

/* ----------------------------------------------------------------- hero --- */

.hero {
  padding: var(--space-5) 0 var(--space-4);
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-4);
  align-items: center;
}

/* Compact leads with the face, then the name: the portrait is the one thing
   that identifies the page before any copy has been read. */
.hero-portrait {
  order: -1;
  width: min(10rem, 40vw);
}

/*
 * The spec sheet: the facts a datasheet would print in its header block —
 * where, what, current release, status — as label/value rows ruled off from
 * each other, in mono because they are records. It replaces a row of pill
 * badges, which said the same things in the shape every portfolio uses.
 */
.spec-sheet {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  margin: var(--space-5) 0 0;
  max-width: 34rem;
  border-top: var(--rule) solid var(--border-strong);
}

.spec-sheet > div {
  display: grid;
  grid-template-columns: minmax(7rem, 9rem) minmax(0, 1fr);
  gap: var(--space-3);
  align-items: baseline;
  padding: var(--space-2) 0;
  border-bottom: var(--rule) solid var(--border);
}

.spec-sheet dt {
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.spec-sheet dd {
  margin: 0;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.85rem;
}

.spec-sheet dd .icon {
  margin-right: 0.45em;
  color: var(--action);
}

.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-top: var(--space-5);
}

/* One portrait on the page, sized so it supports the name rather than
   replacing the evidence of work (R05, R14). */
.portrait-frame {
  position: relative;
  margin: 0;
  overflow: hidden;
  border-radius: var(--radius-md);
  border: var(--rule) solid var(--border-strong);
  background: var(--surface-raised);
  /* A second, offset rule behind the plate: the one drafting flourish. */
  box-shadow: var(--space-2) var(--space-2) 0 0 var(--border);
}

.portrait-frame img {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  object-position: center 10%;
}

.portrait-frame figcaption {
  padding: var(--space-2) var(--space-3);
  border-top: var(--rule) solid var(--border-strong);
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.page-hero {
  position: relative;
  overflow: hidden;
  padding: var(--space-8) 0 var(--space-5);
}

/* Everything but the motif sits above it; the motif itself is z-index: 0. */
.page-hero > :not([data-project-motif]) {
  position: relative;
  z-index: 1;
}

.page-hero h1 {
  font-size: clamp(2rem, 5vw, 3.4rem);
}

/*
 * The per-project hero background. One shared mechanism (a repeating SVG
 * tile with a single animated line) across every project — see
 * src/projectMotif.ts for why, and for what each project's tile draws.
 * Never louder than 12% so it stays a texture, not an illustration
 * competing with the hero's own text.
 */
[data-project-motif] {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.project-motif {
  display: block;
  width: 100%;
  height: 100%;
  color: var(--accent);
  opacity: 0.12;
}

.project-motif-line {
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.project-motif-flow {
  stroke-width: 1.5;
  stroke-dasharray: var(--motif-flow-length) var(--motif-flow-length);
  animation: project-motif-flow 7s linear infinite;
}

@keyframes project-motif-flow {
  to {
    stroke-dashoffset: calc(var(--motif-flow-length) * -2);
  }
}

/* --------------------------------------------------------------- buttons -- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--tap-comfortable);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-sm);
  border: var(--rule) solid transparent;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
  /* ease-out: the state is arriving, not settling mid-visibility (motion-cookbook §2). */
  transition:
    background-color var(--motion-fast) ease-out,
    border-color var(--motion-fast) ease-out,
    color var(--motion-fast) ease-out,
    transform var(--motion-fast) ease-out;
}

/* Immediate tactile confirmation that the press registered — the one
   pattern from Emil's cookbook (§10) applied to every real button-shaped
   control in this file, not decoration added per-component. */
.btn:active {
  transform: scale(0.97);
}

/* The glyph leans into the action on hover: motion that says "this goes
   somewhere", 2px, nothing more. */
.btn .icon {
  transition: transform var(--motion-fast) ease-out;
}

.btn:hover .icon {
  transform: translateX(2px);
}

.btn-primary {
  background: var(--action);
  border-color: var(--action);
  color: var(--text-on-action);
  font-weight: 600;
}

.btn-primary:hover {
  background: var(--action-hover);
  border-color: var(--action-hover);
  color: var(--text-on-action);
}

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

.btn-ghost:hover {
  border-color: var(--action);
  color: var(--action-hover);
}

/* -------------------------------------------------- cards and record sets - */

/* .card is reserved for a discrete destination the visitor can open (B3). */
.card-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
}

.card {
  display: flex;
  flex-direction: column;
  padding: var(--space-4);
  background: var(--surface-raised);
  border: var(--rule) solid var(--border-strong);
  border-radius: var(--radius-md);
  text-decoration: none;
  color: inherit;
  /* --motion-base, not -fast: a full card is a larger surface than a button
     or a chip, and match-duration-to-size (motion-cookbook §2) says a bigger
     repaint reads better a touch slower. */
  transition: border-color var(--motion-base) ease-out;
}

.card:hover {
  border-color: var(--action);
}

.card-top {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}

.card h2,
.card h3 {
  margin: 0 0 var(--space-2);
  font-size: 1.4rem;
}

.card h2 a,
.card h3 a {
  color: inherit;
  text-decoration: none;
  transition: color var(--motion-fast) ease-out;
}

.card h2 a:hover,
.card h3 a:hover {
  color: var(--action-hover);
}

.card p {
  margin: 0 0 var(--space-4);
  color: var(--text-muted);
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: auto;
}

.chip {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  padding: var(--space-1) var(--space-2);
  border: var(--rule) solid var(--border-strong);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.02em;
}

.card-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-top: var(--space-4);
}

/*
 * The ledger (B3): the project list as numbered, ruled rows. Each row is one
 * discrete destination — a project — so it carries what the old .card carried
 * (period, role, title, blurb, tags, actions), laid out the way an index or a
 * parts list lays it out: a folio numeral in the margin, the record beside it,
 * a rule beneath. The numeral is generated with empty alternative text; the
 * <ol> already gives the reader the count and the position.
 *
 * On a wide screen the rows form a two-column grid, so the six projects read
 * as three rows of two, in source order. See the wide layer below.
 */
.ledger {
  list-style: none;
  margin: 0;
  padding: 0;
  counter-reset: ledger;
  border-top: var(--rule-strong) solid var(--border-strong);
}

.ledger-row {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
  padding: var(--space-5) 0 var(--space-5) 2.75rem;
  border-bottom: var(--rule) solid var(--border-strong);
  counter-increment: ledger;
  transition: background-color var(--motion-base) ease-out;
}

.ledger-row::before {
  content: counter(ledger, decimal-leading-zero) / "";
  position: absolute;
  left: 0;
  top: calc(var(--space-5) + 0.35em);
  color: var(--action-hover);
  font-family: var(--font-mono);
  font-size: 0.85rem;
  letter-spacing: 0.1em;
  transition: color var(--motion-fast) ease-out;
}

/* A whole-row link, on the index page. The <a> fills the <li>; the row's own
   padding is moved onto it so the whole ruled band is the target. */
.ledger-row:has(> .ledger-link) {
  padding: 0;
}

.ledger-link {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
  padding: var(--space-5) 0 var(--space-5) 2.75rem;
  color: inherit;
  text-decoration: none;
  transition: background-color var(--motion-base) ease-out;
}

.ledger-row:hover,
.ledger-link:hover {
  background: var(--surface-raised);
}

.ledger-row:hover::before {
  color: var(--action);
}

.ledger-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
}

.ledger-row h2,
.ledger-row h3 {
  margin: 0;
  font-size: clamp(1.5rem, 2.6vw, 2rem);
}

.ledger-row h2 .icon,
.ledger-row h3 .icon {
  margin-right: 0.4em;
  color: var(--action);
}

.ledger-link:hover h2 {
  color: var(--action-hover);
}

.ledger-row p {
  margin: 0;
  max-width: var(--measure-prose);
  color: var(--text-muted);
}

.ledger-row .chips {
  margin-top: var(--space-1);
}

.ledger-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-top: var(--space-2);
}

/*
 * A project's own texture behind its row — the same tile its page's hero
 * draws (src/projectMotif.ts), so hovering a row in the log previews the
 * branch you would be switching to. Hidden until hover or keyboard focus
 * lands in the row, clipped to the row, never above its text, and its flow
 * line only moves while the row is active. Pointer-only by nature; the row
 * carries nothing in it that is not also in its text.
 */
.ledger-motif {
  overflow: hidden;
  position: absolute;
  inset: 0;
  left: auto;
  width: min(46%, 28rem);
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 45%);
  mask-image: linear-gradient(to right, transparent, #000 45%);
  transition: opacity var(--motion-base) ease-out;
}

.ledger-motif .project-motif {
  opacity: 0.16;
  animation-play-state: paused;
}

.ledger-motif .project-motif-flow {
  animation-play-state: paused;
}

.ledger-row:hover > .ledger-motif,
.ledger-row:focus-within > .ledger-motif {
  opacity: 1;
}

.ledger-row:hover .project-motif-flow,
.ledger-row:focus-within .project-motif-flow {
  animation-play-state: running;
}

.ledger-row > :not(.ledger-motif),
.ledger-link > * {
  position: relative;
  z-index: 1;
}

/* .entry is a repeated record with the same fields (B3). */
.experience {
  display: grid;
  gap: var(--space-3);
}

/* The form and the direct routes are two ways to do one thing, so the wide
   layer sets them side by side rather than one after the other. */
.contact-pair {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-4);
  align-items: start;
}

.entry {
  padding: var(--space-4) 0 var(--space-2);
  border-top: var(--rule) solid var(--border-strong);
}

.entry-head {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--space-2) var(--space-4);
  align-items: baseline;
}

.entry h3 {
  margin: 0 0 var(--space-1);
  font-size: 1.3rem;
}

.entry .org {
  margin: 0;
  color: var(--action-hover);
}

/* .org is the whole paragraph when it is a link, so WCAG 2.5.8's inline
   exception does not cover it — give it a real target box. */
.entry .org a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
}

/* Same shape of problem as .entry .org a above: a paragraph that is only a
   link (a project page's "read the source paper" line) is a standalone
   target, not text a link sits inside of — give it a real target box too. */
.standalone-link a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
}

.entry .when {
  margin: 0;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.78rem;
}

.entry p:not(.org):not(.when) {
  color: var(--text-muted);
}

.entry ul {
  margin: var(--space-3) 0 0;
  padding-left: var(--space-5);
  color: var(--text-muted);
}

.entry li + li {
  margin-top: var(--space-2);
}

/*
 * Named releases (B3, R07): a collapsed-by-default timeline, not the .entry
 * card grid above. Each row is a native <details>/<summary> pair, so
 * expand/collapse, focus and Enter/Space handling all come from the browser
 * rather than a script keeping that state itself.
 */
.release-list {
  position: relative;
  display: grid;
  /* A grid item's min-width is auto, so a release title with no break
     opportunity widens the track past the page rather than wrapping (R24). */
  grid-template-columns: minmax(0, 1fr);
  list-style: none;
  margin: 0;
  padding: 0;
}

/* The spine the tag markers sit on, matching .tl-graph's git-branch line. */
.release-list::before {
  content: "";
  position: absolute;
  left: 9px;
  top: var(--space-4);
  bottom: var(--space-4);
  width: 2px;
  background: var(--border-strong);
  border-radius: 2px;
}

.release-row + .release-row {
  border-top: 1px solid var(--border);
}

.release-summary {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  width: 100%;
  /* Same reason as the track above: a flex item refuses to shrink below its
     content without this, and the row is what overflows. */
  min-width: 0;
  padding: var(--space-4) 0;
  cursor: pointer;
  list-style: none;
  transition: background-color var(--motion-fast) ease-out;
}

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

.release-marker {
  position: relative;
  z-index: 1;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  margin-top: 0.15rem;
  color: var(--action);
  background: var(--surface-page);
}

.release-marker .icon {
  width: 0.85rem;
  height: 0.85rem;
}

.release-main {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: var(--space-3);
  row-gap: var(--space-1);
}

.release-version-date {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-1) var(--space-2);
}

.release-version {
  font-family: var(--font-mono);
  font-weight: 600;
  white-space: nowrap;
}

.release-date {
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.78rem;
  white-space: nowrap;
}

.release-summary-text {
  flex: 1 1 12rem;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--text-muted);
  font-size: 0.88rem;
}

.release-toggle {
  flex: none;
  align-self: center;
  width: 8px;
  height: 8px;
  margin-top: 2px;
  border-right: 2px solid var(--text-subtle);
  border-bottom: 2px solid var(--text-subtle);
  transform: rotate(45deg);
}

.release-disclosure[open] .release-toggle {
  margin-top: 6px;
  transform: rotate(225deg);
}

.release-summary:hover,
.release-summary:focus-visible {
  background: var(--surface-raised);
}

/*
 * Bounded and scrollable, mirroring .tl-card-scroll/.tl-card-body: an
 * automated GM=1 release carries its full internal DESCRIPTION forward as
 * this text (see Flinstone's docs/versioning.md), which can run to several
 * paragraphs — nothing like the sentence or two a curator writes by hand for
 * the others. Scrolling, not truncating, keeps "expand" mean all of it.
 */
.release-body-scroll {
  position: relative;
  min-width: 0;
  margin: 0 0 var(--space-3) calc(20px + var(--space-3));
}

.release-body {
  min-width: 0;
  max-height: 16rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-top: var(--space-1);
  padding-right: var(--space-3);
  scrollbar-width: thin;
  color: var(--text-muted);
}

.release-body:focus-visible {
  outline: 2px solid var(--border-interactive);
  outline-offset: 4px;
}

.release-body p {
  margin: 0 0 var(--space-3);
}

/* A fade at the foot, only while there is more below. */
.release-body-scroll[data-more="true"]::after {
  content: "";
  position: absolute;
  left: 0;
  right: var(--space-3);
  bottom: 0;
  height: 2.5rem;
  pointer-events: none;
  background: linear-gradient(to top, var(--surface-page), transparent);
}

.release-loading {
  color: var(--text-subtle);
}

.release-degraded-note {
  color: var(--status-warn, #c98a4a);
  font-size: 0.82rem;
}

.release-link {
  display: inline-block;
  margin: 0 0 var(--space-4) calc(20px + var(--space-3));
  color: var(--action-hover);
  text-decoration: none;
}

.release-link .icon {
  margin-left: 0.35em;
}

/* .spec-list is label/value data. There is nowhere to go, so it is not a card
   grid — it is a description list with a rule between rows (B3, R07). */
.spec-list {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0;
  margin: 0;
  border-top: var(--rule) solid var(--border-strong);
}

.spec-list dt a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  font-family: var(--font-body);
  font-size: 1rem;
  letter-spacing: normal;
  text-transform: none;
}

/* Stacked, the label and its value are one record, so only the pair is ruled
   off — a rule between them would read as two separate rows. */
.spec-list dt {
  padding: var(--space-3) 0 0;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-subtle);
}

.spec-list dd {
  margin: 0;
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--border);
  color: var(--text-muted);
}

/* .stat-list is résumé figures with their source stated inline — not four
   dashboard tiles added to fill space (R07, R15). */
.stat-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
  gap: var(--space-4) var(--space-6);
  margin: 0;
  padding: var(--space-5) 0;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}

.stat-list > div {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding-left: var(--space-4);
  border-left: var(--rule) solid var(--border-strong);
}

.stat-list dt {
  font-family: var(--font-display);
  font-size: 2.2rem;
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--action-hover);
  font-variation-settings: "SOFT" 40, "WONK" 0;
}

.stat-list dd {
  margin: 0;
  color: var(--text-muted);
  font-size: 0.9rem;
}

/*
 * The project-page "strength" statement: one label plus one evidence
 * sentence, sitting right after the impact strip. Reuses .note's
 * accent-rule idiom (an established callout shape on this site) at higher
 * prominence — this is confidence, not a caveat, so it keeps full-strength
 * text colour and a display-font label rather than .note's muted small print.
 */
.strength {
  margin: var(--space-5) 0;
  padding-left: var(--space-4);
  border-left: 3px solid var(--action);
}

.strength-label {
  margin: 0 0 var(--space-1);
  font-family: var(--font-display);
  font-size: 1.3rem;
  color: var(--text);
}

.strength-evidence {
  margin: 0;
  max-width: var(--measure-prose);
  color: var(--text-muted);
}

.source-note {
  margin: var(--space-3) 0 0;
  color: var(--text-subtle);
  font-size: 0.85rem;
}

/*
 * A legal or safety caveat that must be read, not skimmed past: the same
 * accent-rule shape as .strength, but in the error role rather than action,
 * so a warning reads as a warning rather than as a second confidence
 * statement.
 */
.legal-note {
  margin: var(--space-5) 0;
  padding-left: var(--space-4);
  border-left: 3px solid var(--status-error);
}

.legal-note-label {
  margin: 0 0 var(--space-1);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--status-error);
}

.legal-note p {
  margin: 0;
  max-width: var(--measure-prose);
  color: var(--text);
}

.about-col .btn {
  margin-top: var(--space-4);
}

/* ---------------------------------------------------------------- about --- */

/* Sub-headings inside a section that is already introduced by .section-head.
   Spacing between blocks comes from the grid, not from the heading. */
.sub-head {
  margin: 0 0 var(--space-3);
  font-size: clamp(1.25rem, 2vw, 1.5rem);
}

.about-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  align-items: start;
  gap: var(--space-5);
  margin-top: var(--space-4);
}

.about-outro {
  margin-top: var(--space-8);
}

.now-list {
  margin: 0;
  padding-left: var(--space-5);
  max-width: var(--measure-prose);
  color: var(--text-muted);
}

.now-list li + li {
  margin-top: var(--space-2);
}

/* ------------------------------------------------------------------ deck -- */

/*
 * A browsable collection of discrete items (dishes, games). Scrolling is the
 * native overflow scroller, so touch swipe, trackpad, shift+wheel and the
 * keyboard all work without being reimplemented; the buttons and the pointer
 * drag only drive that same scroller. DESIGN.md B4 records the arrow-only
 * buttons and the card stock.
 */
.deck {
  margin: 0;
}

.deck-head {
  margin-bottom: var(--space-4);
}

.deck-head h3 {
  margin: 0 0 var(--space-2);
  font-size: clamp(1.25rem, 2vw, 1.5rem);
}

.deck-head p {
  margin: 0;
  max-width: var(--measure-prose);
  color: var(--text-muted);
}

.deck-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  max-width: 34rem;
  margin-top: var(--space-4);
}

.deck-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap-comfortable);
  height: var(--tap-comfortable);
  border: 1px solid var(--border-interactive);
  border-radius: 50%;
  background: transparent;
  color: var(--text);
  font-family: inherit;
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  transition: border-color var(--motion-fast) ease-out, color var(--motion-fast) ease-out, transform var(--motion-fast) ease-out;
}

.deck-btn:hover:not(:disabled) {
  border-color: var(--action);
  color: var(--action-hover);
}

.deck-btn:active:not(:disabled) {
  transform: scale(0.97);
}

/* A real disabled state driven by scroll position, not decoration (R21). */
.deck-btn:disabled {
  opacity: 0.4;
  cursor: default;
}

/*
 * Without JS the stack must not exist: five cards laid on top of each other
 * with no way to cycle them is a pile of unreadable text. The default is a
 * plain vertical list, .is-stacked is added by the script, and the nav is
 * `hidden` in the markup until the script that drives it has run (R21).
 */
.deck-frame {
  display: grid;
  gap: var(--space-4);
  max-width: 34rem;
}

.about-grid .deck-frame,
.about-grid .deck-nav,
.about-grid .deck-head p {
  max-width: none;
}

.deck-stack {
  display: grid;
  gap: var(--space-4);
  list-style: none;
  margin: 0;
  padding: 0;
}

.deck-count {
  margin: 0;
  min-width: 4.5rem;
  text-align: center;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.78rem;
}

/* --- the stack ------------------------------------------------------------ */

.deck-stack.is-stacked {
  /* Cards behind sit higher, the way they do standing in a recipe box, so the
     peek shows the next card's label and title rather than the tail of its
     note. Transforms do not take part in layout, so the room they need has to
     be reserved here. */
  padding-top: 7.15rem;
  gap: 0;
  touch-action: pan-y;
}

/*
 * Resting poses and the tween between them live in src/deck.ts, because the
 * transition has to sample arbitrary points between two depths and CSS can
 * only name the ends. What stays here is everything that is not geometry.
 */
.deck-stack.is-stacked .note-card {
  grid-area: 1 / 1;
  will-change: transform;
}

/* Deeper than the visible stack. Still in the accessibility tree and still
   read in source order — only the eye loses it. */
.deck-stack.is-stacked .note-card[data-depth="4"] {
  pointer-events: none;
}

/* Clicking a card behind deals down to it. The nav buttons and the arrow keys
   do the same job for the keyboard, so this is a convenience, not the only
   route (DESIGN.md B4). */
.deck-stack.is-stacked .note-card:not([data-depth="0"]) {
  cursor: pointer;
}

.deck-stack.is-stacked .note-card[data-depth="0"] {
  cursor: grab;
}

.deck-stack.is-stacked .note-card[data-depth="0"] {
  cursor: grab;
}

.deck-stack.is-dragging .note-card[data-depth="0"] {
  cursor: grabbing;
}

/* --- the card ------------------------------------------------------------- */

/*
 * Card stock: the one light surface on the site, and the only place the
 * inverted tokens are used. Ruled lines sit on a fixed line-height so they stay
 * aligned even when the webfont has not loaded.
 */
.note-card {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-4) var(--space-4) var(--space-5);
  background: var(--surface-invert);
  color: var(--text-on-invert);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}

.note-card-label {
  margin: 0;
  color: var(--text-on-invert-muted);
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.note-card h4 {
  margin: 0;
  padding-bottom: var(--space-2);
  border-bottom: 2px solid rgba(196, 69, 60, 0.45);
  font-size: 1.3rem;
  color: var(--text-on-invert);
}

.note-card-note {
  margin: 0;
  font-size: 0.95rem;
  /* Fixed, so the ruling below repeats on the same rhythm in any font. */
  line-height: 1.6rem;
  background-image: repeating-linear-gradient(
    to bottom,
    transparent 0,
    transparent calc(1.6rem - 1px),
    rgba(42, 36, 24, 0.13) calc(1.6rem - 1px),
    rgba(42, 36, 24, 0.13) 1.6rem
  );
}

.about-copy p {
  margin: 0 0 var(--space-4);
  color: var(--text-muted);
  max-width: var(--measure-prose);
}

.about-copy p:last-child {
  margin-bottom: 0;
}

/* -------------------------------------------------------------- timeline -- */

/*
 * The project page is prose on the left and the timeline on the right rail.
 * The rail scrolls with the page rather than being its own scroll area — a
 * nested scroller inside a page scroller is the thing R10 warns about.
 */
.project-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  align-items: start;
  gap: var(--space-5);
}

/* A grid item defaults to min-width:auto, so the transcript's long lines push
   this column wider than its own track and the page scrolls sideways. */
.project-main {
  min-width: 0;
}

.project-rail {
  position: relative;
  min-width: 0;
}

.project-rail .section-head {
  margin-bottom: var(--space-4);
}

/* --- the graph ------------------------------------------------------------ */

.tl-graph {
  position: relative;
}

/* The spine the commits sit on: still a vertical git branch. */
.tl-graph::before {
  content: "";
  position: absolute;
  left: 7px;
  top: 1rem;
  bottom: 1rem;
  width: 2px;
  background: var(--border-strong);
  border-radius: 2px;
}

/*
 * The branch continues past the window. Drawn only when there are older
 * entries, because a trailing ellipsis under the last commit in the history
 * would claim a history that is not there.
 */
.tl-graph[data-has-older="true"]::after {
  content: "";
  position: absolute;
  left: 4px;
  bottom: -0.35rem;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--border-strong);
  box-shadow: 0 6px 0 var(--border-strong), 0 12px 0 var(--border-strong);
  opacity: 0.55;
}

/* --- the branch ----------------------------------------------------------- */

.tl-list {
  display: grid;
  gap: var(--space-1);
  list-style: none;
  margin: 0;
  padding: 0 0 1.2rem;
}

.tl-item {
  position: relative;
  /* Grid items default to min-width:auto, which refuses to shrink below their
     content. A long unbreakable identifier in a commit body would otherwise
     widen the row, the rail, and the page. */
  min-width: 0;
}

/*
 * Rows are buttons now: selecting one is the interaction, and the commit link
 * lives in the card beside it rather than wrapping the whole row. A link cannot
 * contain a button, and the expand control has to be reachable on its own.
 */
.tl-node {
  display: grid;
  grid-template-columns: 16px minmax(0, 1fr);
  gap: 0 var(--space-3);
  width: 100%;
  padding: var(--space-2) var(--space-3) var(--space-2) 0;
  border: 0;
  border-radius: var(--radius-md);
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background-color var(--motion-fast) ease-out;
}

.tl-node:hover,
.tl-node:focus-visible {
  background: var(--surface-raised);
}

.tl-item[data-current="true"] .tl-node {
  background: var(--surface-raised);
  padding-right: var(--space-4);
}

/* The commit dot, sitting on the trunk. */
.tl-mark {
  grid-row: 1 / span 2;
  position: relative;
  width: 16px;
  margin-top: 0.45rem;
}

.tl-mark::before {
  content: "";
  position: absolute;
  left: 1px;
  top: 0;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--surface-page);
  border: 2px solid var(--accent);
}

/* A release is a filled commit on the trunk; a feature is an open node. */
.tl-item[data-kind="release"] .tl-mark::before {
  background: var(--action);
  border-color: var(--action);
}

/* Date and counter share the first line, the title takes the second. Letting
   these wrap freely put the counter in a different place on every row. */
.tl-head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: baseline;
  column-gap: var(--space-3);
}

.tl-date {
  grid-area: 1 / 1;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.72rem;
}

.tl-title {
  grid-area: 2 / 1 / 3 / 3;
  font-family: var(--font-display);
  font-size: 1rem;
  line-height: 1.35;
}

.tl-step {
  grid-area: 1 / 2;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.68rem;
}

.tl-item[data-current="true"] .tl-title {
  color: var(--action-hover);
}

/*
 * Open/closed is now the `hidden` attribute rather than a per-frame height, so
 * these need explicit display:none — a display rule here would otherwise win
 * over the attribute's default style.
 */
.tl-item[hidden],
.tl-detail[hidden],
.tl-card[hidden],
.tl-actions[hidden],
.tl-expand[hidden],
.tl-open[hidden] {
  display: none;
}

.tl-detail {
  display: block;
  grid-column: 2;
}

.tl-summary {
  display: block;
  white-space: pre-line;
  padding-top: var(--space-2);
  color: var(--text-muted);
  font-size: 0.88rem;
}

/* --- the selected entry's controls and card ------------------------------- */

.tl-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4) 0 calc(16px + var(--space-3));
}

.tl-expand {
  border-color: var(--border-interactive);
  background: none;
  color: var(--action-hover);
  font: inherit;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  cursor: pointer;
}

.tl-open {
  border-color: var(--border-interactive);
  color: var(--action-hover);
  text-decoration: none;
}

.tl-card {
  margin: var(--space-3) var(--space-4) var(--space-2) calc(16px + var(--space-3));
  padding: var(--space-4);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
}

.tl-facts {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  gap: var(--space-1) var(--space-4);
  margin: var(--space-4) 0 0;
  padding-top: var(--space-3);
  border-top: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: 0.72rem;
}

.tl-facts dt {
  color: var(--text-subtle);
}

.tl-facts dd {
  margin: 0;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

.tl-card-chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding-top: var(--space-3);
}

/* --- pager ---------------------------------------------------------------- */

.tl-pager {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--border);
}

.tl-page {
  padding: var(--space-1) var(--space-3);
  font-size: 0.78rem;
}

.tl-page[disabled] {
  opacity: 0.4;
  cursor: not-allowed;
}

.tl-range {
  margin: 0;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.72rem;
}

/* --- rendered commit text ------------------------------------------------- */

/*
 * Markdown from commit bodies. Scoped to .rt so these never leak into the
 * hand-written prose elsewhere on the page, which has its own rhythm.
 */
.rt > :first-child {
  margin-top: 0;
}

.rt > :last-child {
  margin-bottom: 0;
}

.rt p,
.rt ul,
.rt ol,
.rt blockquote,
.rt pre {
  margin: 0 0 var(--space-3);
  overflow-wrap: break-word;
  color: var(--text-muted);
  font-size: 0.88rem;
  line-height: 1.55;
}

.rt h4,
.rt h5,
.rt h6 {
  margin: var(--space-4) 0 var(--space-2);
  overflow-wrap: break-word;
  font-family: var(--font-display);
  font-size: 0.95rem;
}

.rt ul,
.rt ol {
  padding-left: 1.25rem;
}

.rt li {
  margin-bottom: var(--space-1);
}

/* Commit bodies are full of long paths and identifiers. Without this one of
   them sets the card's width and pushes the page sideways on a phone. */
.rt code {
  overflow-wrap: anywhere;
  padding: 0.1em 0.35em;
  background: var(--surface-raised);
  border-radius: var(--radius-sm, 4px);
  font-family: var(--font-mono);
  font-size: 0.85em;
}

.rt pre {
  padding: var(--space-3);
  overflow-x: auto;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
}

.rt pre code {
  padding: 0;
  background: none;
}

.rt blockquote {
  padding-left: var(--space-3);
  border-left: 2px solid var(--border-strong);
  color: var(--text-subtle);
}

.rt hr {
  margin: var(--space-4) 0;
  border: 0;
  border-top: 1px solid var(--border);
}

.rt a {
  color: var(--action-hover);
  overflow-wrap: anywhere;
}

.rt math {
  font-size: 1.05em;
}

/* --- the scrollable commit body ------------------------------------------ */

.tl-card-scroll {
  position: relative;
  min-width: 0;
}

/*
 * Bounded so an expanded card cannot push the other four commits off the
 * screen — the whole point of a five-entry window. Bodies run to thousands of
 * words, so this scrolls rather than truncating: "show details" has to mean all
 * of them.
 */
.tl-card-body {
  min-width: 0;
  max-height: 22rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-right: var(--space-3);
  scrollbar-width: thin;
}

.tl-card-body:focus-visible {
  outline: 2px solid var(--border-interactive);
  outline-offset: 4px;
}

/* A fade at the foot, only while there is more below. */
.tl-card-scroll[data-more="true"]::after {
  content: "";
  position: absolute;
  left: 0;
  right: var(--space-3);
  bottom: 0;
  height: 2.5rem;
  pointer-events: none;
  background: linear-gradient(to top, var(--surface-sunken), transparent);
}

.tl-card-loading {
  color: var(--text-subtle);
  white-space: pre-line;
}

.tl-card-degraded-note {
  color: var(--status-warn, #c98a4a);
  font-size: 0.82rem;
}

/* --- markdown extras ------------------------------------------------------ */

/* A wide table scrolls inside the card instead of widening the page. */
.rt-table-scroll {
  overflow-x: auto;
  margin: 0 0 var(--space-3);
}

.rt table {
  border-collapse: collapse;
  font-size: 0.82rem;
}

.rt th,
.rt td {
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--border);
  text-align: left;
  vertical-align: top;
}

.rt th {
  color: var(--text-muted);
  font-weight: 600;
}

/* Images in commit bodies are screenshots on GitHub's CDN; the alt text is
   kept and the request is not made. */
.rt-img-omitted {
  display: inline-block;
  padding: 0.1em 0.5em;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-sm, 4px);
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.78rem;
}

.rt input[type="checkbox"] {
  margin-right: var(--space-2);
}

.rt ul:has(> li > input[type="checkbox"]) {
  list-style: none;
  padding-left: 0;
}

/* KaTeX: a long display formula scrolls rather than widening the card. */
.rt .katex-display {
  overflow-x: auto;
  overflow-y: hidden;
  padding: var(--space-1) 0;
}

.rt .katex {
  font-size: 1.05em;
}


.timeline-empty {
  margin: 0;
  padding: var(--space-5);
  background: var(--surface-sunken);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-md);
  color: var(--text-muted);
}

/* ------------------------------------------------------------- evidence --- */

/*
 * A screenshot of the project actually running. R15 asks for real evidence, so
 * the caption carries what produced it and when — a picture with no provenance
 * is decoration.
 */
.shot {
  margin: var(--space-5) 0 0;
}

.shot img {
  display: block;
  width: 100%;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--surface-page);
}

.shot figcaption {
  margin-top: var(--space-3);
  color: var(--text-subtle);
  font-size: 0.85rem;
}

/*
 * A project diagram, redrawn as inline SVG from the project's own report
 * figures (R15: a meaningful diagram, not decoration) rather than embedded as
 * a raster image: it stays crisp at any zoom, its labels are real text that
 * follows the Display control's theme and contrast, and it costs nothing to
 * keep in sync with the source figure. Legitimate traffic and a forged or
 * blocked path never differ by colour alone — a dashed stroke and a label
 * carry the distinction too (R-use-of-colour).
 */
.diagram {
  margin: var(--space-5) 0 0;
  min-width: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  overflow: hidden;
  background: var(--surface-page);
}

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

.diagram-node {
  fill: var(--surface-raised);
  stroke: var(--border-strong);
  stroke-width: 1.5;
}

.diagram-node-title {
  fill: var(--text);
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 600;
}

.diagram-node-meta {
  fill: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 11px;
}

.diagram-edge-label {
  fill: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 11px;
}

.diagram-line-ok {
  stroke: var(--action);
  stroke-width: 2;
  fill: none;
}

.diagram-line-attack {
  stroke: var(--status-error);
  stroke-width: 2;
  stroke-dasharray: 6 4;
  fill: none;
}

/*
 * A second pair, for a diagram with four arrows rather than two: each arrow
 * gets its own colour (not just its own dash pattern), so two solid or two
 * dashed arrows in the same diagram are never told apart by direction alone.
 * Style (solid/dashed) still carries the real-vs-forged distinction on its
 * own, same as the pair above.
 */
.diagram-line-ok-alt {
  stroke: var(--accent);
  stroke-width: 2;
  fill: none;
}

.diagram-line-attack-alt {
  stroke: var(--status-wait);
  stroke-width: 2;
  stroke-dasharray: 6 4;
  fill: none;
}

.diagram-boundary {
  stroke: var(--text-subtle);
  stroke-width: 1.5;
  stroke-dasharray: 3 4;
}

.diagram-marker-ok {
  fill: var(--action);
}

.diagram-marker-attack {
  fill: var(--status-error);
}

.diagram-marker-ok-alt {
  fill: var(--accent);
}

.diagram-marker-attack-alt {
  fill: var(--status-wait);
}

/* The key under the diagram, same shape as the report's own "Legend:" block:
   a line sample (not a colour alone) beside what it means. */
.diagram-legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-5);
  margin: 0;
  padding: 0 var(--space-4) var(--space-3);
  list-style: none;
  color: var(--text-muted);
  font-size: 0.85rem;
}

.diagram-legend li {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}

/* Specificity over `.diagram svg` (width: 100%), which would otherwise
   stretch this swatch to the legend's full column width. */
.diagram-legend .diagram-legend-swatch {
  flex: 0 0 auto;
  width: 28px;
  height: 10px;
  overflow: visible;
}

.diagram figcaption {
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--border-strong);
  color: var(--text-subtle);
  font-size: 0.85rem;
}

/*
 * A terminal transcript, kept as text rather than a picture of text: it stays
 * selectable, searchable, legible at any zoom, and reflows on a phone, none of
 * which a screenshot of a shell does.
 */
.transcript {
  margin: var(--space-5) 0 0;
  min-width: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  overflow: hidden;
  background: var(--surface-page);
}

.transcript-bar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  background: var(--surface-sunken);
  border-bottom: 1px solid var(--border);
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.transcript pre {
  margin: 0;
  padding: var(--space-4);
  overflow-x: auto;
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  line-height: 1.55;
  tab-size: 2;
}

/* The command the operator typed, so the eye can find it in the output. */
.transcript b {
  color: var(--action-hover);
  font-weight: 400;
}

.transcript + figcaption,
.evidence-note {
  margin: var(--space-3) 0 0;
  color: var(--text-subtle);
  font-size: 0.85rem;
}

/* A recorded demo of the live lab. Controls only: it never autoplays, so it
 * holds still under reduced motion without any script. */
.demo-video {
  margin: var(--space-5) 0 0;
  min-width: 0;
}

.demo-video video {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: #000;
}

/* One command per block: the command alone in its code line, its meaning
 * in prose just below, so each reads on its own at any width. */
.cmd-list {
  margin-top: var(--space-4);
}

.cmd-line {
  margin: var(--space-4) 0 0;
}

.cmd-line pre {
  padding: var(--space-3) var(--space-4);
}

.cmd-desc {
  margin: var(--space-2) 0 0;
  color: var(--text-muted);
  font-size: 0.9rem;
}

/* ----------------------------------------------------------- lang chart --- */

/*
 * The language donut is the whole visualisation now — no bar. This site's
 * own dataviz reference deprioritises a donut against a stacked bar for
 * part-to-whole, but sanctions it for exactly this case: a quick read at a
 * handful of segments. A repository Linguist reports a dozen languages for
 * does not stay "a quick read" at a glance, which is why the renderer
 * (src/languageChart.ts) folds anything past five named languages into one
 * "Other" segment rather than drawing every one Linguist ever saw.
 */
.lang-chart-figure {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-5);
  margin: var(--space-5) 0;
}

.lang-chart {
  flex: none;
  width: 7rem;
  height: 7rem;
}

.lang-legend {
  display: flex;
  flex-direction: column;
  flex-wrap: wrap;
  gap: var(--space-2);
  max-height: 7rem;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.74rem;
}

/*
 * The legend is generated, so its length and its longest word are both unknown
 * at authoring time: "Linker Script" and "Jupyter Notebook" are real Linguist
 * names (the "Other" fold keeps the list itself short — see languageChart.ts —
 * but not any one name in it). Each item stays on one line, wrapping into a
 * second column once the column above hits max-height; max-width stops a
 * single long name forcing a horizontal scrollbar at 320px.
 */
.lang-legend span {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  white-space: nowrap;
  max-width: 100%;
  min-width: 0;
}

.lang-legend span > .lang-swatch {
  flex: 0 0 auto;
}

.lang-swatch {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  border: 1px solid var(--border-strong);
}

/* ------------------------------------------------------------- live lab --- */

.guest-window {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--surface-raised);
  border: var(--rule) solid var(--border-strong);
  border-radius: var(--radius-md);
  box-shadow: var(--elevation-1);
}

.guest-titlebar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  background: var(--surface-sunken);
  border-bottom: 1px solid var(--border);
}

.guest-heading {
  min-width: 0;
  line-height: 1.15;
}

.guest-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.02rem;
}

.guest-subtitle {
  margin: 0.12rem 0 0;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.guest-title-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-left: 0;
}

.guest-btn {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-comfortable);
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--border-interactive);
  border-radius: 999px;
  background: transparent;
  color: var(--text);
  font-family: inherit;
  font-size: 0.82rem;
  cursor: pointer;
  transition: border-color var(--motion-fast) ease-out, color var(--motion-fast) ease-out, transform var(--motion-fast) ease-out;
}

.guest-btn:hover {
  border-color: var(--action);
  color: var(--action-hover);
}

.guest-btn:active {
  transform: scale(0.97);
}

/* Status is text first. The tone tokens only reinforce what the words say. */
.guest-status {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.74rem;
  letter-spacing: 0.04em;
}

.guest-status[data-tone="live"] {
  color: var(--status-ok);
}

.guest-status[data-tone="wait"] {
  color: var(--status-wait);
}

.guest-status[data-tone="error"] {
  color: var(--status-error);
}

.guest-dock {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  background: var(--surface-page);
  border-bottom: 1px solid var(--border);
}

.guest-dock-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--tap-comfortable);
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--border-interactive);
  border-radius: 999px;
  background: transparent;
  color: var(--text-muted);
  font-family: inherit;
  font-size: 0.82rem;
  cursor: pointer;
  transition:
    border-color var(--motion-fast) ease-out,
    background-color var(--motion-fast) ease-out,
    color var(--motion-fast) ease-out,
    transform var(--motion-fast) ease-out;
}

.guest-dock-btn:hover {
  border-color: var(--action);
  color: var(--text);
}

.guest-dock-btn:active {
  transform: scale(0.97);
}

.guest-dock-btn[aria-pressed="true"] {
  background: var(--action);
  border-color: var(--action);
  color: var(--text-on-action);
  font-weight: 600;
}

.guest-live-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--status-ok);
}

.guest-dock-btn[aria-pressed="true"] .guest-live-dot {
  background: var(--text-on-action);
}

.guest-stage {
  position: relative;
  display: grid;
  min-height: 22rem;
  background: var(--surface-page);
}

.guest-frame,
.guest-panel {
  grid-area: 1 / 1;
  min-height: 22rem;
}

.guest-frame {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  background: var(--surface-page);
}

.guest-panel {
  display: grid;
  place-items: center;
  align-content: center;
  gap: var(--space-2);
  padding: var(--space-6) var(--space-5);
  text-align: center;
}

.guest-error .guest-panel-label {
  color: var(--status-error);
}

.guest-panel-label {
  margin: 0;
  color: var(--accent);
  font-size: 0.9rem;
  font-weight: 600;
}

.guest-panel h3 {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.3rem;
}

.guest-panel p {
  margin: 0;
  max-width: 34rem;
  color: var(--text-muted);
}

.guest-panel .guest-panel-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-3);
  margin-top: var(--space-3);
}

/* The boot panel covers the stage while attaching, so it needs its own ground. */
.guest-boot {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: var(--surface-page);
}

.guest-window.is-minimized .guest-stage {
  min-height: 0;
}

.guest-restore-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--border);
  color: var(--text-muted);
  font-size: 0.9rem;
}

.guest-window:fullscreen,
.guest-window.is-overlay {
  width: 100%;
  height: 100%;
  border: 0;
  border-radius: 0;
  background: var(--surface-page);
}

.guest-window.is-overlay {
  position: fixed;
  inset: 0;
  z-index: 80;
}

.guest-window:fullscreen .guest-stage,
.guest-window.is-overlay .guest-stage {
  flex: 1 1 auto;
  min-height: 0;
}

.guest-window:fullscreen .guest-frame,
.guest-window:fullscreen .guest-panel,
.guest-window.is-overlay .guest-frame,
.guest-window.is-overlay .guest-panel {
  min-height: 0;
  height: 100%;
}

body.guest-overlay-open {
  overflow: hidden;
}

/* ---------------------------------------------------------------- footer -- */

.site-footer {
  margin-top: var(--space-6);
  padding: var(--space-6) 0 var(--space-7);
  border-top: var(--rule-strong) solid var(--border-strong);
  color: var(--text-subtle);
  font-size: 0.9rem;
}

.footer-grid {
  display: flex;
  justify-content: space-between;
  gap: var(--space-4);
  flex-wrap: wrap;
}

.footer-grid p {
  margin: 0;
  max-width: 34rem;
}

.footer-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-4);
  list-style: none;
  margin: 0;
  padding: 0;
}

.footer-links a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.04em;
}

.contact-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
  gap: var(--space-2) var(--space-5);
}

/* Label above value, so four entries sit on one row without some of them
   wrapping to two lines and others not. The label is inside the link, so the
   accessible name reads "LinkedIn bailey-preston-forbes". */
.contact-list a {
  display: grid;
  gap: 2px;
  padding: var(--space-2) 0;
  min-height: var(--tap-comfortable);
  text-decoration: none;
}

.contact-label {
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.contact-value {
  color: var(--action-hover);
  text-decoration: underline;
  text-underline-offset: 0.18em;
  overflow-wrap: anywhere;
  transition: color var(--motion-fast) ease-out;
}

.contact-list a:hover .contact-value {
  color: var(--text);
}

/*
 * display:grid on the stage/overlays would otherwise win over the [hidden]
 * attribute's implicit display:none.
 */
[hidden] {
  display: none !important;
}

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

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

/*
 * Reduced motion, from either source: the OS preference (unless the visitor
 * has explicitly chosen full motion in the Display control) or the control
 * itself. Both blocks must stay identical; a media query cannot share a rule
 * with an attribute selector. Scripts ask src/display.ts, which reads both.
 */
@media (prefers-reduced-motion: reduce) {
  :root:not([data-motion="full"]) {
    scroll-behavior: auto;
  }

  :root:not([data-motion="full"]) *,
  :root:not([data-motion="full"]) *::before,
  :root:not([data-motion="full"]) *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

:root[data-motion="reduced"] {
  scroll-behavior: auto;
}

:root[data-motion="reduced"] *,
:root[data-motion="reduced"] *::before,
:root[data-motion="reduced"] *::after {
  animation-duration: 0.01ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.01ms !important;
}

/*
 * High contrast, beyond the token swap: links always underlined, the
 * project-page motif texture gone (it was a 12% tint, which is exactly the
 * kind of thing the cut exists to remove), and nothing translucent.
 */
:root[data-contrast="high"] a,
:root[data-contrast="high"] .contact-value {
  text-decoration: underline;
}

:root[data-contrast="high"] [data-project-motif],
:root[data-contrast="high"] .site-ground {
  display: none;
}

:root[data-contrast="high"] .chip > .icon {
  opacity: 1;
}

@media (prefers-contrast: more) {
  :root:not([data-contrast="standard"]) a,
  :root:not([data-contrast="standard"]) .contact-value {
    text-decoration: underline;
  }

  :root:not([data-contrast="standard"]) [data-project-motif],
  :root:not([data-contrast="standard"]) .site-ground {
    display: none;
  }

  :root:not([data-contrast="standard"]) .chip > .icon {
    opacity: 1;
  }
}

/* -------------------------------------------------------- display control -- */

/*
 * The Display control: a button in the header that opens a small panel of
 * radio groups — theme, contrast, motion, text size. Native radios inside
 * <fieldset>/<legend>, so the group semantics, arrow-key movement and the
 * checked state all come from the browser; the stylesheet only draws each
 * group as a segmented row. The panel is a sheet (see "sheets" below): a bottom
 * sheet on a phone and a side sheet on a wide screen, opened by the button.
 */
.display {
  position: static;
  display: flex;
  align-items: center;
}

/* On a phone the word is dropped and the button is the glyph alone; the
   accessible name is on the button, not the glyph (B2, R17). */
.display-toggle-label {
  display: none;
}

.display-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--tap-comfortable);
  padding: 0 var(--space-3);
  background: transparent;
  color: var(--text);
  border: var(--rule) solid var(--border-interactive);
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  cursor: pointer;
  transition: border-color var(--motion-fast) ease-out, transform var(--motion-fast) ease-out;
}

.display-toggle:hover {
  border-color: var(--action);
}

.display-toggle:active {
  transform: scale(0.97);
}

.display-toggle[aria-expanded="true"] {
  border-color: var(--action);
  color: var(--action-hover);
}

.display-hint {
  margin: 0 0 var(--space-4);
  color: var(--text-muted);
  font-size: 0.88rem;
}

[data-display-form] {
  display: grid;
  gap: var(--space-4);
}

.display-group {
  margin: 0;
  padding: 0;
  border: 0;
  min-width: 0;
}

.display-group legend {
  padding: 0;
  margin-bottom: var(--space-2);
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

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

/* Each option is a label wrapping its radio. The radio is visually hidden
   but stays in the tree and focusable; the label draws the state. */
.display-option {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-comfortable);
  padding: 0 var(--space-3);
  border: var(--rule) solid var(--border-interactive);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  font-size: 0.9rem;
  cursor: pointer;
  transition:
    border-color var(--motion-fast) ease-out,
    background-color var(--motion-fast) ease-out,
    color var(--motion-fast) ease-out;
}

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

.display-option:hover {
  border-color: var(--action);
  color: var(--text);
}

/* Checked is drawn as a filled option AND a leading mark, so the state is
   not carried by colour alone (R25). */
.display-option:has(input:checked) {
  background: var(--action);
  border-color: var(--action);
  color: var(--text-on-action);
  font-weight: 600;
}

.display-option:has(input:checked)::before {
  content: "";
  display: inline-block;
  width: 0.45em;
  height: 0.75em;
  margin-right: var(--space-2);
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-0.1em) rotate(45deg);
}

.display-option:has(input:focus-visible) {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
}

.display-reset {
  justify-self: start;
}

/* Contact form: existing semantic tokens, plain single-column layout. */
.contact-form { display: grid; gap: var(--space-4); max-width: 40rem; margin-block: var(--space-6); }
.contact-form label { display: grid; gap: var(--space-2); }
.contact-form input, .contact-form textarea { box-sizing: border-box; width: 100%; padding: var(--space-3); border: 1px solid var(--border-interactive); border-radius: var(--radius-sm); background: var(--surface-raised); color: var(--text); font: inherit; }
.contact-form textarea { resize: vertical; min-height: 10rem; }
.contact-form button { justify-self: start; }
.contact-form button:disabled { opacity: .6; cursor: not-allowed; }

/*
 * Peak-End Rule: this line is the last thing a visitor sees for the one task
 * the page most wants them to complete, so success and failure need to look
 * different from "still working" and from each other — not just read
 * different. Tone is carried by the same icon+colour pattern as
 * .guest-status, so the site says "done" the same way everywhere. The word
 * is still what carries the meaning (R25); colour and glyph only reinforce it.
 */
.contact-status {
  display: inline-flex;
  /* The longest message here wraps to three lines on a narrow phone; aligned
     to center that reads as pinned to the block's middle rather than the
     message. flex-start plus a nudge keeps it level with the first line. */
  align-items: flex-start;
  gap: var(--space-2);
  min-height: 1.5em;
  color: var(--text-subtle);
}

.contact-status .icon {
  margin-top: 0.15em;
}

.contact-status[data-tone="wait"] {
  color: var(--status-wait);
}

.contact-status[data-tone="ok"] {
  color: var(--status-ok);
  font-weight: 600;
}

.contact-status[data-tone="error"] {
  color: var(--status-error);
}

/* ------------------------------------------------------------- the story -- */

/*
 * THE THEME: a commit log. The project pages each draw one branch of git
 * history; the home page is the trunk those branches come off. What follows
 * is the motion that makes that read as a story rather than a stack of
 * sections — and every piece of it is keyed to the reader's own progress
 * (load, scroll, hover), replays on every entry in either scroll direction,
 * and collapses to its final state under reduced motion. Nothing here loops
 * and nothing moves while you read.
 */

/* --- elements arrive each time they are reached ------------------------------ */

/*
 * `.reveal` is added by src/story.ts, never authored in markup: with scripting
 * off there is no class and nothing is hidden. `.is-seen` is added each time
 * an element crosses into view and removed once it is well off screen, so the
 * entrance replays whichever way the page is scrolled. The offset is 12px —
 * enough to read as arriving, small enough to read as a fade (the skill's
 * 8–16px band) — and the stagger is set per element by the script and dropped
 * once the fade ends.
 */
.reveal {
  opacity: 0;
  transform: translateY(12px);
  transition:
    opacity 360ms ease-out var(--reveal-delay, 0ms),
    transform 360ms ease-out var(--reveal-delay, 0ms);
}

.reveal.is-seen {
  opacity: 1;
  transform: none;
}

/* Tiles also animate their border and shadow on hover; list all four so the
   entrance does not replace the hover transition. */
.tile.reveal {
  transition:
    opacity 360ms ease-out var(--reveal-delay, 0ms),
    transform 360ms ease-out var(--reveal-delay, 0ms),
    border-color var(--motion-base) ease-out,
    box-shadow var(--motion-base) ease-out;
}

/*
 * First paint. The reveal state above only exists once the script has added
 * `.reveal`, which is after the first paint, so the tiles and the page heroes
 * would show for a frame and then vanish. The head script sets `html.js` before
 * anything is drawn, and these rules hold exactly those elements back until
 * src/story.ts adds `.reveal-ready`, when the rules above take over.
 *
 * Two guards keep a failed script from leaving them hidden: the rules apply
 * only where the reveal will actually run (not under reduced motion, whichever
 * source set it), and each one carries a zero-length animation that fades the
 * element in after 2.5 seconds if `.reveal-ready` never arrives.
 */
@keyframes reveal-fail-open {
  to {
    opacity: 1;
    transform: none;
  }
}

@media (prefers-reduced-motion: no-preference) {
  html.js:not(.reveal-ready):not([data-motion="reduced"]) :is(.bento > .tile, .page-hero > :not([data-project-motif])) {
    opacity: 0;
    transform: translateY(12px);
    animation: reveal-fail-open 0s 2.5s forwards;
  }
}

html.js:not(.reveal-ready)[data-motion="full"] :is(.bento > .tile, .page-hero > :not([data-project-motif])) {
  opacity: 0;
  transform: translateY(12px);
  animation: reveal-fail-open 0s 2.5s forwards;
}

/* --- the story strip ---------------------------------------------------------- */

/*
 * Six beats on one line, the line drawing from each beat to the next as it
 * is reached. Each beat is a list item; the line is the item's ::before and
 * the node its ::after, so the whole thing is one <ol> with no extra markup.
 * Compact stacks the beats; the wide layer lays them across the page.
 */
.story-line {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-5);
  list-style: none;
  margin: 0;
  padding: 0 0 0 2.75rem;
}

.story-beat {
  position: relative;
  transition:
    opacity 360ms ease-out var(--beat-delay, 0ms),
    transform 360ms ease-out var(--beat-delay, 0ms);
}

/* The unreached state exists only where script will reach the beat (`html.js`
   is set by the head script); without script every beat is shown, filled and
   joined, so the story is whole. */
html.js .story-beat:not(.is-seen) {
  opacity: 0;
  transform: translateY(8px);
}

/* The node: an open commit, filled once reached. */
.story-beat::after {
  content: "";
  position: absolute;
  left: -2.75rem;
  top: 0.35rem;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 2px solid var(--action);
  background: var(--action);
  transition: background-color var(--motion-base) ease-out var(--beat-delay, 0ms);
}

html.js .story-beat:not(.is-seen)::after {
  background: var(--surface-page);
}

/* "Now" is the HEAD: a ring, not a filled commit — it is still being written. */
.story-beat-now::after,
html.js .story-beat-now.is-seen::after {
  border-color: var(--accent);
  background: var(--surface-page);
  box-shadow: 0 0 0 3px var(--surface-page), 0 0 0 4px var(--accent);
}

/* The line from this beat to the next, drawn top-down when the next arrives. */
.story-beat::before {
  content: "";
  position: absolute;
  left: calc(-2.75rem + 5px);
  top: 1.1rem;
  bottom: calc(-1 * var(--space-5) - 0.35rem);
  width: 2px;
  background: var(--action);
  transform-origin: top center;
  transition: transform 420ms ease-out var(--beat-delay, 0ms);
}

html.js .story-beat::before {
  transform: scaleY(0);
}

html.js .story-beat.is-seen + .story-beat.is-seen::before,
html.js .story-beat.is-seen:has(+ .story-beat.is-seen)::before {
  transform: scaleY(1);
}

.story-beat:last-child::before {
  display: none;
}

.story-when {
  margin: 0 0 var(--space-1);
  color: var(--action-hover);
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.story-what {
  margin: 0;
  color: var(--text-muted);
  font-size: 0.95rem;
  max-width: 34rem;
}

/* --- the trunk ---------------------------------------------------------------- */

/*
 * A page-local nav in the left margin: one node per numbered chapter, the
 * line between them filling with the reader's progress, the current chapter
 * marked. Built by src/story.ts on the home page only, and only shown where
 * the margin outside the content column is wide enough to hold it. The
 * numeral is the visible label; the chapter title is the accessible name and
 * appears beside the node on hover or focus.
 */
.trunk {
  display: none;
}

@media (min-width: 80rem) {
  .trunk {
    position: fixed;
    left: max(var(--space-5), calc((100vw - var(--measure-page)) / 2 - 4.5rem));
    top: 50%;
    z-index: 10;
    display: block;
    transform: translateY(-50%);
  }

  .trunk-track {
    position: absolute;
    left: calc(var(--tap-min) / 2 - 1px);
    top: var(--tap-min);
    bottom: var(--tap-min);
    width: 2px;
    background: var(--border-strong);
    border-radius: 2px;
  }

  .trunk-fill {
    position: absolute;
    inset: 0;
    background: var(--action);
    transform: scaleY(var(--trunk-progress, 0));
    transform-origin: top center;
    transition: transform var(--motion-base) linear;
  }

  .trunk-list {
    position: relative;
    display: grid;
    gap: var(--space-6);
    list-style: none;
    margin: 0;
    padding: 0;
  }

  .trunk-node {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--space-3);
    min-width: var(--tap-comfortable);
    min-height: var(--tap-comfortable);
    color: var(--text-subtle);
    text-decoration: none;
  }

  /* The commit dot: open until the reader has passed it. */
  .trunk-node::before {
    content: "";
    position: absolute;
    left: calc(var(--tap-min) / 2 - 6px);
    width: 12px;
    height: 12px;
    border-radius: 50%;
    border: 2px solid var(--border-interactive);
    background: var(--surface-page);
    transition: border-color var(--motion-base) ease-out, background-color var(--motion-base) ease-out;
  }

  .trunk-node.is-committed::before {
    border-color: var(--action);
    background: var(--action);
  }

  .trunk-node[aria-current]::before {
    box-shadow: 0 0 0 3px var(--surface-page), 0 0 0 4px var(--action);
  }

  .trunk-numeral {
    margin-left: var(--tap-min);
    font-family: var(--font-mono);
    font-size: 0.72rem;
    letter-spacing: 0.1em;
  }

  .trunk-node[aria-current] .trunk-numeral,
  .trunk-node:hover .trunk-numeral {
    color: var(--action-hover);
  }

  /* The title, shown beside the numeral on hover or focus; never only on hover
     for anything the page needs, since the numeral links regardless. */
  .trunk-label {
    opacity: 0;
    transform: translateX(-4px);
    white-space: nowrap;
    font-family: var(--font-display);
    font-size: 0.95rem;
    color: var(--text);
    transition: opacity var(--motion-fast) ease-out, transform var(--motion-fast) ease-out;
  }

  .trunk-node:hover .trunk-label,
  .trunk-node:focus-visible .trunk-label {
    opacity: 1;
    transform: none;
  }
}

/* ----------------------------------------------------------------- bento --- */

/*
 * The overview: the top of the home page as a bento grid. Tiles of varied
 * size, each a summary or a destination — who this is, the story line, the
 * three labs, the two other projects, skills, what is happening now, and the
 * ways to reach him. Below it the page continues as the record (experience,
 * education, off the clock, contact), which the trunk in the margin walks.
 *
 * Why a grid of tiles here and ruled rows everywhere else (B3): the overview
 * is a set of *different kinds* of things that should be taken in at once;
 * a list is for repeated records of one kind. The compact layer stacks the
 * tiles in source order; the mid and wide layers at the foot of this file
 * give each its span.
 */
.bento {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
  padding-top: var(--space-4);
}

.tile {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  min-width: 0;
  margin: 0;
  padding: var(--space-5);
  background: var(--surface-raised);
  border: var(--rule) solid var(--border-strong);
  border-radius: var(--radius-tile);
  overflow: hidden;
  transition: border-color var(--motion-base) ease-out, transform var(--motion-base) ease-out;
}

/* A tile with a destination lifts on hover: 2px, no scale, text stays crisp. */
.tile:not(.tile-filter):has(.btn):hover,
.tile:not(.tile-filter):has(.btn):focus-within {
  border-color: var(--action);
  transform: translateY(-2px);
}

/* Everything in a tile sits above its motif. */
.tile > :not(.tile-motif) {
  position: relative;
  z-index: 1;
}

.tile-label {
  margin: 0;
  color: var(--text-muted);
  font-size: 0.9rem;
  font-weight: 600;
}

.tile h2 {
  margin: 0;
  font-size: clamp(1.4rem, 2.2vw, 1.75rem);
}

.tile h2 .icon {
  margin-right: 0.4em;
  color: var(--action);
}

.tile > p:not(.tile-label):not(.page-meta):not(.lede):not(.source-note) {
  margin: 0;
  color: var(--text-muted);
  font-size: 0.95rem;
}

.tile .chips {
  margin-top: 0;
}

.tile-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: auto;
  padding-top: var(--space-2);
}

/* --- intro ------------------------------------------------------------------- */

.tile-intro {
  padding: var(--space-6) var(--space-5);
}

.tile-intro h1 {
  margin: 0;
}

.tile-intro .lede {
  font-size: 1.05rem;
}

.tile-intro .hero-actions {
  margin-top: var(--space-2);
}

/* --- portrait: the photograph fills the tile, caption on a plate ---------- */

.tile-portrait {
  padding: 0;
  min-height: 20rem;
  background: var(--surface-sunken);
}

/* Doubled class: the generic `.tile > *` rule above sets position: relative
   on every tile child, and the picture and caption must out-rank it. */
.tile.tile-portrait > picture {
  display: block;
  position: absolute;
  inset: 0;
  z-index: 0;
}

.tile-portrait img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 12%;
}

.tile.tile-portrait > figcaption {
  position: absolute;
  left: var(--space-3);
  bottom: var(--space-3);
  padding: var(--space-1) var(--space-3);
  background: var(--surface-raised);
  border: var(--rule) solid var(--border-strong);
  border-radius: var(--radius-sm);
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* --- story: the strip lives in a tile now ---------------------------------- */

.tile-story .story-line {
  padding-top: 0;
}

/* --- spec sheet in a tile: no outer rule, the tile is the frame ------------ */

.tile-spec .spec-sheet {
  margin: 0;
  max-width: none;
  border-top: 0;
}

.tile-spec .spec-sheet > div:last-child {
  border-bottom: 0;
}

/* --- labs and projects: their own branch texture behind the text ---------- */

/*
 * The motif is the one its project page draws (src/projectMotif.ts). In a
 * tile it is faint and still; hovering or focusing into the tile brightens
 * it and sets its flow line moving — the same preview the ledger rows give,
 * for the same reason.
 */
.tile-motif {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 85%);
  mask-image: linear-gradient(to bottom, #000 30%, transparent 85%);
  transition: opacity var(--motion-base) ease-out;
}

.tile-motif .project-motif {
  opacity: 0.09;
  transition: opacity var(--motion-base) ease-out;
}

.tile-motif .project-motif-flow {
  animation-play-state: paused;
}

.tile:hover .tile-motif .project-motif,
.tile:focus-within .tile-motif .project-motif {
  opacity: 0.18;
}

.tile:hover .project-motif-flow,
.tile:focus-within .project-motif-flow {
  animation-play-state: running;
}

/* --- skills ------------------------------------------------------------------ */

.skill-rows {
  display: grid;
  gap: var(--space-3);
  margin: 0;
}

.skill-rows > div {
  display: grid;
  gap: var(--space-1);
}

.skill-rows dt {
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.skill-rows dt .icon {
  width: 1rem;
  height: 1rem;
  margin-right: 0.5rem;
  vertical-align: -0.22rem;
  color: var(--action);
}

.skill-rows dd {
  margin: 0;
  color: var(--text-muted);
  font-size: 0.92rem;
}

/* --- now ------------------------------------------------------------------------ */

.tile-now .now-list {
  font-size: 0.95rem;
}

.tile-now .source-note {
  margin-top: auto;
  font-size: 0.78rem;
}

/* --- reach: the direct routes, with a copy control for the address ----------- */

.reach-list {
  display: grid;
  gap: var(--space-1);
  list-style: none;
  margin: 0;
  padding: 0;
}

.reach-list li {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}

.reach-list a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-comfortable);
  color: var(--action-hover);
  font-family: var(--font-mono);
  font-size: 0.85rem;
  overflow-wrap: anywhere;
}

.reach-list a .icon {
  width: 1rem;
  height: 1rem;
  margin-right: 0.55rem;
  color: var(--action);
}

/* "Copy", drawn by src/story.ts only where the clipboard is available. */
.copy-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  min-height: var(--tap-min);
  padding: 0 var(--space-2);
  background: transparent;
  border: var(--rule) solid var(--border-interactive);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  cursor: pointer;
  transition: border-color var(--motion-fast) ease-out, color var(--motion-fast) ease-out;
}

.copy-btn:hover {
  border-color: var(--action);
  color: var(--text);
}

.copy-btn[data-copied="true"] {
  border-color: var(--status-ok);
  color: var(--status-ok);
}

/* ---------------------------------------------------------- topic filter -- */

/*
 * Filter by topic (src/topics.ts). The control is revealed by script, so with
 * scripting off it does not exist and everything is shown. Each topic is a
 * toggle button with aria-pressed, grouped as Languages / Platforms / Practice,
 * with the number of items that carry it. Pressed is drawn as a filled button
 * AND a leading check, so the state is not carried by colour alone (R25), the
 * same idiom as the Display control's options.
 */
.topic-filter {
  margin: 0 0 var(--space-5);
  padding: var(--space-4) 0;
  border-top: var(--rule) solid var(--border-strong);
  border-bottom: var(--rule) solid var(--border-strong);
}

.filter-title {
  margin: 0;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 400;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.tile .filter-title {
  font-size: 0.72rem;
}

.filter-hint {
  margin: var(--space-1) 0 0;
  color: var(--text-muted);
  font-size: 0.9rem;
}

.topic-disclosure {
  margin-top: var(--space-2);
}

.topic-summary {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: var(--tap-comfortable);
  cursor: pointer;
  list-style: none;
  color: var(--action-hover);
  font-family: var(--font-mono);
  font-size: 0.82rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

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

.topic-disclosure[open] > .topic-summary .disclosure-toggle {
  transform: rotate(225deg);
}

.topic-selected {
  color: var(--text-muted);
  text-transform: none;
  letter-spacing: 0;
}

.topic-filter-body {
  display: grid;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}

.topic-group {
  display: grid;
  gap: var(--space-2);
}

.topic-group-label {
  margin: 0;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.topic-options {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.topic-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--tap-comfortable);
  padding: 0 var(--space-3);
  background: transparent;
  border: var(--rule) solid var(--border-interactive);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  cursor: pointer;
  transition:
    border-color var(--motion-fast) ease-out,
    background-color var(--motion-fast) ease-out,
    color var(--motion-fast) ease-out,
    transform var(--motion-fast) ease-out;
}

.topic-btn:hover {
  border-color: var(--action);
  color: var(--text);
}

.topic-btn:active {
  transform: scale(0.97);
}

.topic-count {
  min-width: 1.4em;
  padding: 0 0.35em;
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  color: var(--text-subtle);
  font-size: 0.72rem;
  text-align: center;
}

.topic-btn[aria-pressed="true"] {
  background: var(--action);
  border-color: var(--action);
  color: var(--text-on-action);
  font-weight: 600;
}

.topic-btn[aria-pressed="true"]::before {
  content: "";
  display: inline-block;
  width: 0.4em;
  height: 0.7em;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-0.1em) rotate(45deg);
}

.topic-btn[aria-pressed="true"] .topic-count {
  background: transparent;
  color: var(--text-on-action);
}

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

.topic-clear:disabled {
  opacity: 0.55;
  cursor: default;
}

.topic-status {
  margin: 0;
  color: var(--text-muted);
  font-size: 0.88rem;
}

/* A chip the current selection matched: why this item is still showing.
   Border weight and the mark carry it, not only the colour. */
.chip[data-match] {
  border-color: var(--action);
  color: var(--text);
  font-weight: 600;
  box-shadow: inset 0 calc(var(--rule-strong) * -2) 0 0 var(--action);
}

.entry-chips {
  margin-top: var(--space-3);
}

.filter-empty {
  margin: var(--space-3) 0 0;
  padding: var(--space-3) var(--space-4);
  border: var(--rule) dashed var(--border-strong);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
}

/* ------------------------------------------------------------------ sheets -- */

/*
 * A sheet is the one way this site opens a menu over the page (src/sheet.ts):
 * a native modal <dialog>, so focus is trapped, the page behind is inert and
 * Escape closes it, all supplied by the browser. Below 56rem it rises from the
 * bottom edge as a bottom sheet with a grab handle; from 56rem it slides in
 * from the right as a side sheet. It is one element and the stylesheet picks
 * the shape, so resizing a window with a sheet open reshapes it.
 *
 * It opens and closes with a transition on transform and opacity. `display`
 * and `overlay` are transitioned as discrete properties so the closing motion
 * is seen before the dialog leaves the top layer; a browser without that
 * simply shows and hides it.
 */
.sheet {
  position: fixed;
  inset: auto 0 0 0;
  width: 100%;
  max-width: none;
  max-height: min(88dvh, 44rem);
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: var(--radius-sheet) var(--radius-sheet) 0 0;
  overflow: auto;
  overscroll-behavior: contain;
  color: var(--text);
  transform: translateY(100%);
  opacity: 0;
  transition:
    transform 360ms cubic-bezier(0.2, 0.8, 0.2, 1),
    opacity 220ms ease-out,
    overlay 360ms allow-discrete,
    display 360ms allow-discrete;
}

.sheet[open] {
  transform: none;
  opacity: 1;
}

@starting-style {
  .sheet[open] {
    transform: translateY(100%);
    opacity: 0;
  }
}

.sheet::backdrop {
  background: var(--scrim);
  -webkit-backdrop-filter: blur(calc(var(--glass-blur) * 0.4));
  backdrop-filter: blur(calc(var(--glass-blur) * 0.4));
  opacity: 0;
  transition:
    opacity 360ms ease-out,
    overlay 360ms allow-discrete,
    display 360ms allow-discrete;
}

.sheet[open]::backdrop {
  opacity: 1;
}

@starting-style {
  .sheet[open]::backdrop {
    opacity: 0;
  }
}

/* The page behind a bottom sheet does not scroll. On a wide screen the side
   sheet leaves it alone, so the scrollbar does not appear and vanish. */
@media (max-width: 55.99rem) {
  body.sheet-open {
    overflow: hidden;
  }
}

.sheet-surface {
  display: grid;
  align-content: start;
  gap: var(--space-3);
  min-height: 100%;
  padding: 0 var(--space-5) calc(var(--space-6) + env(safe-area-inset-bottom, 0px));
}

/* The grab handle: bottom sheet only, and only a pointer shortcut. The close
   button and Escape are the real ways out (WCAG 2.5.7). */
.sheet-grab {
  display: flex;
  justify-content: center;
  padding: var(--space-3) 0 var(--space-1);
  cursor: grab;
  touch-action: none;
}

.sheet-grab span {
  width: 2.75rem;
  height: 5px;
  border-radius: 999px;
  background: var(--border-interactive);
  opacity: 0.7;
}

.sheet-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
}

.sheet-head h2 {
  margin: 0;
  font-size: 1.35rem;
}

.sheet-close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap-comfortable);
  height: var(--tap-comfortable);
  padding: 0;
  background: var(--glass-fill);
  color: var(--text);
  border: var(--rule) solid var(--border-interactive);
  border-radius: 50%;
  cursor: pointer;
  transition: border-color var(--motion-fast) ease-out, transform var(--motion-fast) ease-out;
}

.sheet-close:hover {
  border-color: var(--action);
}

.sheet-close:active {
  transform: scale(0.94);
}

.sheet-nav {
  display: grid;
  gap: var(--space-1);
  list-style: none;
  margin: 0;
  padding: 0;
}

.sheet-nav a {
  display: flex;
  align-items: center;
  min-height: 3.25rem;
  padding: 0 var(--space-4);
  border: var(--rule) solid transparent;
  border-radius: var(--radius-md);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.9rem;
  letter-spacing: 0.08em;
  text-decoration: none;
  text-transform: uppercase;
  transition: background-color var(--motion-fast) ease-out, border-color var(--motion-fast) ease-out;
}

.sheet-nav a:hover {
  background: var(--glass-fill);
  border-color: var(--glass-rim);
}

/* The current page: a bar and a mark as well as the fill, so it is not
   colour alone (R25). */
.sheet-nav a[aria-current="page"] {
  background: var(--glass-fill);
  border-color: var(--glass-rim);
  box-shadow: inset 4px 0 0 0 var(--action);
}

@media (min-width: 56rem) {
  .sheet {
    inset: 0 0 0 auto;
    width: min(26rem, 100%);
    height: 100dvh;
    max-height: none;
    border-radius: var(--radius-sheet) 0 0 var(--radius-sheet);
    transform: translateX(100%);
  }

  .sheet[open] {
    transform: none;
  }

  @starting-style {
    .sheet[open] {
      transform: translateX(100%);
      opacity: 0;
    }
  }

  .sheet-surface {
    padding-top: var(--space-5);
  }

  .sheet-grab {
    display: none;
  }
}

/* ------------------------------------------------------------------- glass -- */

/*
 * The glass treatment, applied once to everything that is a surface: the
 * header bar, tiles, role entries, sheets, and the live lab's chrome.
 * A translucent tint, a blur and saturation boost of whatever is behind it, a
 * lit top edge, and a faint gloss gradient. Behind it sits a soft ambient glow
 * (body::before) so there is something coloured to refract. The tokens are in
 * the token layer; this block only applies them.
 *
 * Text never sits on bare glass. The tints are dense enough for the worst
 * backdrop to still clear 4.5:1, which tools/contrast.py checks, and the two
 * secondary text roles in the header use the muted colour for that reason.
 * Controls keep their own interactive border (>=3:1), not the glass rim.
 */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  background:
    radial-gradient(52rem 36rem at 10% -8%, var(--glow-a), transparent 62%),
    radial-gradient(46rem 36rem at 98% 26%, var(--glow-b), transparent 62%),
    radial-gradient(44rem 32rem at 24% 106%, var(--glow-a), transparent 60%);
}

.site-header {
  background-color: var(--glass-bg-bar);
  background-image: var(--glass-gloss);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  border-bottom-color: var(--glass-rim);
  box-shadow: inset 0 -1px 0 var(--glass-hi);
}

.tile,
.entry,
.sheet,
.guest-titlebar,
.guest-dock {
  background-color: var(--glass-bg);
  background-image: var(--glass-gloss);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
}

.tile,
.entry {
  border-color: var(--glass-rim);
  border-radius: var(--radius-tile);
  box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-hi);
}

.entry {
  padding: var(--space-4) var(--space-5) var(--space-3);
  border-style: solid;
  border-width: var(--rule);
}

.sheet {
  background-color: var(--glass-bg-sheet);
  border: var(--rule) solid var(--glass-rim);
  box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-hi);
}

.guest-titlebar,
.guest-dock {
  border-bottom-color: var(--glass-rim);
}

/* A tile that holds a destination lifts on hover; the lift keeps the glass
   shadow and brightens its rim rather than swapping to a flat border. */
.tile:not(.tile-filter):has(.btn):hover,
.tile:not(.tile-filter):has(.btn):focus-within {
  border-color: var(--action);
  box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-hi), 0 0 0 1px var(--action);
}

/* Controls: a faint glass fill inside their own interactive border. */
.btn-ghost,
.chip,
.nav-toggle,
.display-toggle,
.topic-btn,
.display-option,
.copy-btn,
.guest-btn,
.guest-dock-btn,
.deck-btn,
.tl-expand,
.tl-open {
  background-color: var(--glass-fill);
}

.btn-ghost,
.nav-toggle,
.display-toggle,
.topic-btn,
.display-option {
  -webkit-backdrop-filter: blur(calc(var(--glass-blur) * 0.5));
  backdrop-filter: blur(calc(var(--glass-blur) * 0.5));
}

/* The filled action keeps its colour and gains a lit edge and a soft sheen at
   the top, fading out before the label so the label's contrast is unchanged. */
.btn-primary,
.btn-primary:hover,
.topic-btn[aria-pressed="true"],
.display-option:has(input:checked),
.guest-dock-btn[aria-pressed="true"] {
  background-image: linear-gradient(180deg, var(--glass-sheen), transparent 55%);
  box-shadow: inset 0 1px 0 var(--glass-hi);
}

/*
 * ================================================================ WIDE LAYER
 *
 * Everything above is the compact layer. This block is the whole of the
 * difference between the two, which is the point: there is one place to read
 * to know what a desktop visitor gets that a phone visitor does not.
 *
 * 56rem, not a device width: it is where this page's own content stops
 * working in one column, and in rem so it moves with the reader's font size.
 *
 * Width is spent on putting more of the page in view at once, not on the same
 * column made larger — a laptop visitor should scroll less than a phone
 * visitor, not the same amount at a bigger size.
 */

@media (min-width: 56rem) {

  :root {
    --content-padding: var(--space-5);
    --section-spacing: var(--space-8);
    --nav-h: 64px;
  }

  section {
    padding: var(--space-4) 0 var(--section-spacing);
  }

  /*
   * --- bento: the mid layer, six columns -------------------------------------
   *
   * Between 56rem and 76rem the page is wide enough for two or three tiles
   * abreast but not for twelve columns. The portrait takes the right half
   * for two rows; the intro and the spec sheet stack beside it; everything
   * else pairs up. `dense` lets a later small tile fill a gap an earlier
   * tall one left.
   */
  .bento {
    grid-template-columns: repeat(6, minmax(0, 1fr));
    grid-auto-rows: minmax(6rem, auto);
    grid-auto-flow: dense;
    gap: var(--space-4);
  }

  .tile-intro { grid-column: span 3; padding: var(--space-6) var(--space-5); }
  .tile-portrait { grid-column: span 3; grid-row: span 2; min-height: 0; }
  .tile-story { grid-column: span 6; }
  .tile-filter { grid-column: span 6; }
  .tile-spec { grid-column: span 3; }
  .tile-lab { grid-column: span 3; }
  .tile-hc { grid-column: span 3; }
  .tile-emr { grid-column: span 3; }
  .tile-mitm { grid-column: span 6; }
  .tile-skills { grid-column: span 3; }
  .tile-now { grid-column: span 3; }
  .tile-reach { grid-column: span 3; }

  /* --- hero: copy and portrait side by side --------------------------------- */

  .hero {
    grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.65fr);
    gap: var(--space-7);
    padding: var(--space-8) 0 var(--space-7);
  }

  .hero-portrait {
    order: 0;
    width: auto;
  }

  .portrait-frame img {
    aspect-ratio: 4 / 5;
    object-position: center 12%;
  }

  /* --- navigation: the full set, always visible ----------------------------- */

  .nav-toggle {
    display: none;
  }

  .nav-links {
    display: flex;
    position: static;
    flex-direction: row;
    align-items: center;
    gap: var(--space-1);
    padding: 0;
    background: transparent;
    border-bottom: 0;
  }

  .nav-links a {
    padding: 0 var(--space-3);
  }

  .nav-tools {
    gap: var(--space-4);
  }

  /* --- display control: the word comes back beside the glyph --------------- */

  .display-toggle-label {
    display: inline;
  }

  /* --- story strip: six beats across the page ------------------------------- */

  .story-line {
    grid-template-columns: repeat(6, minmax(0, 1fr));
    gap: var(--space-4);
    padding: 1.6rem 0 0;
  }

  .story-beat::after {
    left: 0;
    top: -1.6rem;
  }

  /* Across the page the line runs left to right, drawn from the node to the
     next column's node. */
  .story-beat::before {
    left: 12px;
    right: calc(-1 * var(--space-4));
    top: calc(-1.6rem + 5px);
    bottom: auto;
    width: auto;
    height: 2px;
    transform-origin: left center;
  }

  html.js .story-beat::before {
    transform: scaleX(0);
  }

  html.js .story-beat.is-seen + .story-beat.is-seen::before,
  html.js .story-beat.is-seen:has(+ .story-beat.is-seen)::before {
    transform: scaleX(1);
  }

  .story-what {
    font-size: 0.9rem;
  }

  /* --- ledger: folio, record, and the side column ---------------------------- */

  .ledger-row,
  .ledger-link {
    grid-template-columns: minmax(0, 1fr) minmax(12rem, 16rem);
    column-gap: var(--space-7);
    padding-left: 4rem;
  }

  .ledger-row > .ledger-head {
    grid-column: 1;
  }

  .ledger-row > h2,
  .ledger-row > h3,
  .ledger-row > p,
  .ledger-row > .chips,
  .ledger-link > .ledger-head,
  .ledger-link > h2,
  .ledger-link > p,
  .ledger-link > .chips {
    grid-column: 1;
  }

  .ledger-actions,
  .ledger-side {
    grid-column: 2;
    grid-row: 1 / span 5;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-3);
    margin-top: 0.35rem;
  }

  /*
   * Six projects read as a 2 × 3 grid, in source order. Each cell is about half
   * the measure, too narrow for the side column, so inside the grid a row
   * stacks its record and its actions in one column.
   */
  .ledger {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: var(--space-6);
  }

  .ledger .ledger-row,
  .ledger .ledger-link {
    grid-template-columns: minmax(0, 1fr);
    column-gap: 0;
    padding-left: 2.75rem;
  }

  .ledger .ledger-actions,
  .ledger .ledger-side {
    grid-column: 1;
    grid-row: auto;
    margin-top: var(--space-2);
  }

  /* --- records: let the grid find its own column count ---------------------- */

  /*
   * auto-fit rather than a fixed column count: a 900px window and a 1400px
   * monitor are both "wide", and this fits two or three cards accordingly
   * without either one needing a breakpoint of its own.
   */
  .card-grid {
    grid-template-columns: repeat(auto-fit, minmax(min(19rem, 100%), 1fr));
    gap: var(--space-4);
  }

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

  .about-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-8) var(--space-6);
    margin-top: var(--space-5);
  }

  .spec-list {
    grid-template-columns: minmax(9rem, 14rem) minmax(0, 1fr);
    gap: 0 var(--space-5);
  }

  .spec-list dt {
    padding: var(--space-3) 0;
    border-bottom: 1px solid var(--border);
  }

  /* The timeline earns a rail of its own once the prose no longer needs the
     full measure. */
  .project-layout {
    grid-template-columns: minmax(0, 1fr) minmax(0, 23rem);
    gap: var(--space-8) var(--space-7);
  }

  /*
   * Pinned once the page scrolls past it, rather than left in normal flow.
   * The rail's own content (a five-entry timeline window) is much shorter
   * than the prose beside it, so unpinned it simply ends partway down the
   * page and leaves the rest of that column empty for however much prose
   * remains — sticky uses that space instead of wasting it.
   *
   * This used to be deliberately not sticky, back when scrolling the page
   * changed which timeline entry was open (an entry had to visually travel
   * past a fixed focus line). That coupling is gone — selection is now
   * explicit, by click, arrow keys or the pager (DESIGN.md B4) — so nothing
   * about the timeline's behaviour depends on the rail's screen position
   * any more, and pinning it no longer risks reintroducing that bug.
   */
  .project-rail {
    position: sticky;
    top: calc(var(--nav-h) + var(--space-5));
    align-self: start;
  }

  /*
   * Résumé entries two at a time. Paired whole SECTIONS were tried first and
   * lost: halving a column's width more than doubles the height of the prose in
   * it, so Experience beside Education was taller than the two stacked. Entries
   * are short enough to survive the narrower measure, and four of them in two
   * rows is most of a screen back.
   */
  .experience {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-4);
    align-items: start;
  }

  .contact-pair {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: var(--space-7);
  }

  /* The form sets its own measure for compact; in the pair the column is it. */
  .contact-form {
    max-width: none;
    margin-block: 0;
  }

  .contact-list {
    grid-template-columns: minmax(0, 1fr);
  }

  .guest-title-actions {
    margin-left: auto;
  }

  .guest-stage,
  .guest-frame,
  .guest-panel {
    min-height: 28rem;
  }
}

/* Two spacing hooks that used to be inline styles on the home page. */
.experience-after-stats {
  margin-top: var(--space-6);
}

.sub-head-spaced {
  margin-top: var(--space-6);
}

/*
 * ============================================================= BENTO, WIDE
 *
 * From 76rem the overview has its twelve columns: intro and spec sheet
 * stacked in the left seven beside the portrait, the story across, the three
 * labs abreast, then Homework Central / EMR / the MiTM lab / skills (four
 * even columns), then now / reach.
 */
@media (min-width: 76rem) {
  .bento {
    grid-template-columns: repeat(12, minmax(0, 1fr));
    grid-auto-rows: minmax(7rem, auto);
  }

  .tile-intro { grid-column: span 7; padding: var(--space-7) var(--space-6) var(--space-6); }
  .tile-portrait { grid-column: span 5; grid-row: span 2; }
  .tile-spec { grid-column: span 7; }
  .tile-story { grid-column: span 12; }
  .tile-filter { grid-column: span 12; }
  .tile-lab { grid-column: span 4; }
  .tile-hc { grid-column: span 3; }
  .tile-emr { grid-column: span 3; }
  .tile-mitm { grid-column: span 3; }
  .tile-skills { grid-column: span 3; }
  .tile-now { grid-column: span 5; }
  .tile-reach { grid-column: span 7; }

  .tile-reach .reach-list {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: var(--space-4);
  }

  /* Beside the portrait, the spec sheet reads as two columns of rows. */
  .tile-spec .spec-sheet {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: var(--space-5);
  }

  .tile-spec .spec-sheet > div {
    grid-template-columns: minmax(6rem, 7.5rem) minmax(0, 1fr);
  }

  .tile-spec .spec-sheet > div:nth-last-child(2) {
    border-bottom: 0;
  }
}
