/* Plain, dependency-free CSS. Monospace; weight and space over color.

   THEME. Every design value lives as a token in :root below — colors, type,
   spacing, radii, opacity, motion. Reach for a token and extend a scale rather
   than hardcoding a one-off. Only the theme override and the two layout
   breakpoints live outside :root. */

:root {
  /* Layout */
  --measure: 900px; /* max content width */

  /* Color — semantic, weight/space carries hierarchy so the palette stays tiny.
     Each token carries its light and dark value via light-dark(); the page
     follows the system by default, and the masthead theme toggle overrides by
     pinning color-scheme via [data-theme] (below the token block). */
  color-scheme: light dark;
  /* The two neutral poles, named once. --bg/--fg pick the current theme's side
     via light-dark(); --bg-invert/--fg-invert pick the *other* side (args
     swapped) — the theme-toggle tooltip wears them to preview the mode it
     switches to. Both compose from the same four values, so no hex repeats. */
  --pole-bg-light: oklch(0.9873 0.0040 106.47);
  --pole-bg-dark: oklch(0.2543 0.0194 218.85);
  --pole-fg-light: oklch(0.2099 0.0039 286.06);
  --pole-fg-dark: oklch(0.9221 0.0071 88.65);
  --bg: light-dark(var(--pole-bg-light), var(--pole-bg-dark));
  --fg: light-dark(var(--pole-fg-light), var(--pole-fg-dark));
  --bg-invert: light-dark(var(--pole-bg-dark), var(--pole-bg-light));
  --fg-invert: light-dark(var(--pole-fg-dark), var(--pole-fg-light));
  --muted: light-dark(oklch(0.5477 0.0046 106.55), oklch(0.6361 0.0074 97.41));
  --rule: light-dark(oklch(0.9094 0.0055 95.1), oklch(0.2812 0.0039 84.58));
  --photo-edge: light-dark(oklch(1 0 0), rgb(0 0 0 / 0.5));
  /* the photo rim — a wash of the theme's pole
                                   (full white in light; half-opacity black in
                                   dark), a halo just outside the photo (outset
                                   outline, --border-rim wide) that feathers
                                   the photo into the page */

  /* Opacity tints — percentages consumed by color-mix() */
  --tint-line: 30%; /* quiet (tier-2) underline — visible at rest in both themes */
  --tint-surface: 6%; /* subtle filled surface */
  --surface: color-mix(in srgb, var(--fg) var(--tint-surface), transparent);
  --tint-surface-deep: 12%; /* the surface's emphatic step — reads at a
                                   glance where 6% only reads on inspection */
  --surface-deep: color-mix(
    in srgb,
    var(--fg) var(--tint-surface-deep),
    transparent
  );
  --tint-glass: 85%; /* sticky chrome over scrolled content — mostly
                                   opaque page bg, blur-backed (--blur-glass) */
  --glass: color-mix(in srgb, var(--bg) var(--tint-glass), transparent);
  /* Lightbox surface — a theater near-black behind the photo, the same in
     light and dark (a gallery's black wall: the unavoidable letterbox around a
     landscape photo becomes intentional negative space, and the photo's edges
     pop). The .lightbox scope sets --bg to this — so the glass chrome wash
     recomputes from it — and flips color-scheme: dark so the chrome's neutral
     text reads light-on-black. Terminal keeps its phosphor bg (overridden in
     the terminal block). This is the *only* dialog that goes dark; the command
     palette stays the page bg. */
  --lightbox-bg: oklch(0.1504 0.0027 67.55);

  /* Typography */
  --font-mono:
    ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas,
    "DejaVu Sans Mono", monospace;
  /* The reading alternative — a warm old-style serif, system fonts only (no
     web font, per the hard rules). The masthead font toggle swaps the body
     family to this; code always stays --font-mono (see below). */
  --font-serif:
    "Iowan Old Style", "Palatino", Charter, ui-serif, Georgia, serif;
  /* The active body family — defaults to mono; the toggle aliases it to the
     serif stack via [data-font="serif"] (below the token block). */
  --font-body: var(--font-mono);
  --text-sm: 0.85rem;
  --text-base: 1rem;
  --text-md: 1.15rem; /* no consumer — headings all rest on base */
  --text-lg: 1.35rem; /* (one type size site-wide); scale kept complete */
  --weight-normal: 400;
  --weight-bold: 700;
  --leading-tight: 1.3; /* headings */
  --leading-snug: 1.5; /* code */
  --leading-normal: 1.65; /* body */
  --leading-relaxed: 1.7; /* long-form articles */

  /* Spacing scale */
  --space-3xs: 0.2rem;
  --space-2xs: 0.4rem;
  --space-xs: 0.6rem;
  --space-sm: 0.85rem;
  --space-md: 1rem;
  --space-lg: 1.5rem;
  --space-xl: 2rem;
  --space-2xl: 3rem;
  --space-3xl: 4.5rem;
  --gap-section: 3.25rem; /* rhythm between major sections */

  /* Rounded-corner scale */
  --radius-sm: 3px;
  --radius-md: 6px;
  --radius-lg: 10px;

  /* Borders & motion */
  --border-thin: 1px;
  --border-thick: 2px; /* emphatic strokes (the focus ring) */
  --border-rim: 0.5ch; /* the photo halo (--photo-edge) — half a
                                   character cell, the one typed-width stroke
                                   (everything else stays px) */
  --blur-glass: 8px; /* backdrop blur behind --glass chrome */
  --duration-snap: 75ms; /* half-beat — the lightbox page-turn phases (2 ≈ one turn, ~150ms) */
  --duration-fast: 120ms;
  --duration-med: 240ms; /* sprung transform motion needs room to settle */
  --duration-blink: 2s; /* the opener's `/` blink cycle — long holds, quick fades */
  --ease: ease;
  /* Damped spring baked to linear() stops — simulated, not guessed (Harmonica
     sensibility: motion from physics, not arbitrary curves). ωn·T = 8, ζ = 0.8
     → ~1.5% overshoot peaking ~65% in, settled by t = 1. Consumer: the
     icon-title tooltip's rise on reveal. Transforms
     only: springs are for things with position and mass; keep fades on --ease. */
  --ease-spring: linear(
    0,
    0.095,
    0.291,
    0.498,
    0.676,
    0.81,
    0.903,
    0.961,
    0.993,
    1.009,
    1.015,
    1.015,
    1.012,
    1.009,
    1.006,
    1.004,
    1
  );

  /* Components */
  /* Inline horizontal gaps ride ch — one character cell of the current font,
     so the spacing reads as *typed*, not measured. ch is for the horizontal
     axis only: vertical rhythm stays on the rem spacing scale, strokes and
     radii stay px, and the breakpoint stays rem (font-relative units in
     media queries resolve against the browser's initial font, not ours). */
  --link-pad-x: 0.2em; /* horizontal breathing room on the hover highlight —
                                   em on purpose: painted outside the text flow, not grid space */
  --link-glow: transparent; /* phosphor halation on the lit invert chip — a third
                               box-shadow copy on hover/focus, transparent (so inert)
                               in light/dark; only the terminal theme lights it */
  --glow-blur: 6px; /* the halation's softness — px like the other strokes/radii */
  --gap-inline: 1ch; /* between inline grouped links/tags (social, tag rows) —
                                   one typed space (ch ≈ 0.6em in most monos ≈ the old 0.6rem) */
  --gap-dir: 0.5ch; /* breath after a path's slash (palette dir prefixes) */
  --indent-sub: 2ch; /* TUI tree indent — two typed spaces (palette nested rows) */
  --tool-size: 2rem; /* the masthead tools (social, theme + font toggles) — a
                                   square chip floor. rem, not ch/em-of-family: this
                                   cluster is chrome, not type (and holds the font
                                   toggle itself), so it must stay put when the family
                                   swaps — see .masthead-tools */
  --masthead-nav-all-w: 12ch; /* the pinned "all tags" overlay (fade + label) —
                                   the scrolling nav reserves this much end padding
                                   so its last tag can clear the overlay */
  --preview-w: 21rem; /* photo preview pane (palette + list pages) —
                                   width cap; the gutter caps it further */
  --toc-w: 16rem; /* the table-of-contents sidebar (long entries) — width cap in
                                   the *right* gutter, mirror of the left preview pane;
                                   the gutter caps it further */
  --preview-ratio: 3 / 2; /* every pane slot crops to this (cover), so
                                   the pane is one consistent shape no matter
                                   the photos' own dimensions */
  --shots-row: clamp(8rem, 55vw, 15rem);
  /* photo rows (figure.shots) — the target row
                                   height the justified layout packs toward:
                                   2–3 photos per row at the full measure,
                                   easing down on phones (the clamp), where
                                   the imagery wants near-full-bleed —
                                   landscapes go one per row, portraits pair */
  --hero-max: min(
    22rem,
    55vh
  ); /* entry hero (figure.shots.hero) height cap —
                                   a portrait hero is allowed but never shoves
                                   the prose past the fold (the vh term leaves a
                                   peek even on short viewports); pixel-review
                                   tunes this */
  --hero-mosaic-h: min(
    26rem,
    60vh
  ); /* mosaic hero (.hero.mosaic) total height —
                                   a definite height so the grid rows split and
                                   the photos crop to fill; sized so the left
                                   tile reads ~square at desktop measure;
                                   pixel-review tunes */

  /* Base palette — the full spectrum, three steps per hue: the default, a
     -light tint, a -deep shade ("deep" predates the scale; "dark" belongs to
     the theme, "bright" implies saturation). Shared by both themes; the
     intent accents below pick from it. Not every step has a consumer
     downstream — keep the scale complete anyway (the footer strip shows it
     all, and future intents reach for it). Steel is the one desaturated
     family: blue with a tilt toward turquoise. */
  --red: oklch(0.5771 0.2152 27.33);
  --red-light: oklch(0.7106 0.1661 22.22);
  --red-deep: oklch(0.4437 0.1613 26.9);
  --orange: oklch(0.6461 0.1943 41.12);
  --orange-light: oklch(0.7576 0.1590 55.93);
  --orange-deep: oklch(0.4698 0.1430 37.3);
  --yellow: oklch(0.7952 0.1617 86.05);
  --yellow-light: oklch(0.9052 0.1657 98.11);
  --yellow-deep: oklch(0.5538 0.1207 66.44);
  --green: oklch(0.6271 0.1699 149.21);
  --green-light: oklch(0.8003 0.1821 151.71);
  --green-deep: oklch(0.4479 0.1083 151.33);
  /* Phosphor — the terminal theme's signature green, distinct from the success
     --green above. Named in :root (not just the terminal block) so the
     theme-toggle tooltip can *preview* terminal from the dark theme, where the
     terminal tokens aren't active. The terminal block aliases its accent/ink/
     glow to these, so there's one source of truth. */
  --phosphor: oklch(0.7530 0.1866 151.9); /* accent fill */
  --phosphor-ink: oklch(0.1681 0.0309 159.04); /* text knocked out on phosphor */
  --phosphor-glow: rgba(92, 245, 155, 0.55); /* the halation bloom */
  /* Amber (P3) — the second terminal tube. Same role as green above; named in
     :root for the same reason (the toggle previews amber-from-green in its
     tooltip, where the amber-variant tokens aren't active). */
  --phosphor-amber: oklch(0.8238 0.1408 70.17); /* accent fill */
  --phosphor-amber-ink: oklch(0.1588 0.0342 70.88); /* text knocked out on amber */
  --phosphor-amber-glow: rgba(255, 176, 84, 0.5); /* the halation bloom */
  --steel: oklch(0.5396 0.0890 240);
  --steel-light: oklch(0.6673 0.0878 242.29);
  --steel-deep: oklch(0.4023 0.0656 243.51);
  --blue: oklch(0.5537 0.2458 266.04);
  --blue-light: oklch(0.7058 0.1481 270.71);
  --blue-deep: oklch(0.4167 0.2127 264.75);
  --violet: oklch(0.5413 0.2466 293.01);
  --violet-light: oklch(0.7090 0.1592 293.54);
  --violet-deep: oklch(0.4320 0.2106 292.76);
  --fuchsia: oklch(0.5916 0.2180 0.58);
  --fuchsia-light: oklch(0.7253 0.1752 349.76);
  --fuchsia-deep: oklch(0.5246 0.1990 3.96);

  /* Vibrant accents — calm/monochrome at rest, vivid on interaction (hover,
     selection). Hover color is assigned by link intent, never at random;
     all chosen for white knockout text. */
  --accent: var(
    --violet
  ); /* the default link hover (links' selection follows it) */
  --accent-category: var(
    --violet
  ); /* category links (section titles, More →) + the heading
                                       layer — currently matches the default on purpose; kept
                                       as its own intent so it can diverge */
  --accent-category-text: light-dark(
    var(--accent-category),
    var(--violet-light)
  );
  /* heads *resting as text* — the default violet sinks into
                                       the dark bg, so dark steps up to -light; hover/focus
                                       inverts (fills) stay on --accent-category */
  --accent-social: var(
    --fuchsia
  ); /* masthead social links + theme toggle —
                                       the name's hue, so the masthead reads
                                       as one fuchsia family */
  --accent-tag: var(--steel); /* tag links */
  --accent-tag-text: light-dark(var(--accent-tag), var(--steel-light));
  /* a tag *resting as text* — the active tag in the masthead nav and on a tag
     page's rows (the --accent-category-text precedent: steel sinks into the
     dark bg, so dark steps up to -light; hover/focus inverts stay on
     --accent-tag) */
  --accent-year: var(--orange); /* year links */
  --accent-draft: var(
    --red
  ); /* draft chrome (banner, row marks) —
                                       red's first consumer. Development builds only:
                                       production never renders drafts (buildDrafts
                                       lives in config/development/), so this intent
                                       can never ship */
  --accent-error: var(
    --red
  ); /* error/danger intent — a failed login, a
                                       form error. Red's first *production* consumer
                                       (the draft red above is dev-only); reach for it
                                       only where the calm must yield to a warning,
                                       never for emphasis */
  --accent-error-text: light-dark(var(--red), var(--red-light));
  /* an error *resting as text* — red sinks into the dark bg, so dark steps up to
     -light (the --accent-tag-text precedent); stays red in the phosphor tubes,
     like draft red, since an error is meant to break the calm */
  --subhead-text: light-dark(var(--steel-deep), var(--steel));
  /* the subhead rung (h2/h3, Related) resting as
                                       text — dark steel; deep sinks into the dark
                                       bg, so dark steps up one (the
                                       --accent-category-text precedent) */
  --accent-fg: oklch(1 0 0);
  /* Hover rests one step lighter than commitment: links fill with their
     intent's -light step on hover, the full accent on focus/active/selection
     — the focus rule paints after the hover rule, so focus always wins. Both
     weights knock the text out to --accent-fg (Harlan's call, 2026-06-11:
     light ink on the light fills; the state is transient). */
  --accent-hover: var(--violet-light); /* the default link hover fill */
  --accent-category-light: var(
    --violet-light
  ); /* category links' hover — its own
                                                   intent so it can diverge, like
                                                   --accent-category */
  --accent-social-light: var(--fuchsia-light);
  --accent-tag-light: var(--steel-light);
  --accent-year-light: var(--orange-light);
  --accent-draft-light: var(--red-light); /* draft mark's hover — dev-only */
  /* The site name's hue — a header like the section titles, but fuchsia
     instead of violet: rests as colored text, fills on hover (see .name).
     The -light step carries the quiet voice of the family (the palette
     filter's placeholder). */
  --mark: var(--fuchsia);
  --mark-light: var(--fuchsia-light);
  --mark-fg: var(--accent-fg);

  /* Selected text — highlighter yellow by default; links reassign these (with
     the ::selection rule, below) so a selection inside a link wears the link's
     own intent accent, the same invert it shows on hover. */
  --selection: var(--yellow);
  --selection-fg: oklch(0.2099 0.0039 286.06); /* dark ink in both themes — the white
                                       knockout the accents use fails on yellow */
}

/* Theme override — set by the masthead toggle (site.js). Pinning color-scheme
   resolves every light-dark() token to that side; no attribute means follow
   the system. The override is cleared whenever the system theme changes. */
:root[data-theme="light"] {
  color-scheme: light;
}
:root[data-theme="dark"] {
  color-scheme: dark;
}
/* The secret terminal themes — old-school phosphor monitors, entered by cycling
   the masthead toggle past dark and persisted like light/dark (site.js). Two
   tubes share one structure and differ only in phosphor colour: green (P1) and
   amber (P3). The per-variant blocks below set the base --term-* tokens; the
   shared [data-theme^="phosphor"] block consumes them, so every structural rule
   (recolour, scanlines, vignette, greenscreen filter, glow) is written once.
   Exact and prefix attribute selectors carry identical specificity, so the
   shared block must follow the variant blocks in source order to read their
   tokens cleanly (it only *sets* properties the variants don't, and vice versa).
   Green: the classic P1 tube. */
:root[data-theme="phosphor-green"] {
  --term-bg: oklch(0.1298 0.0301 143.6);
  --term-fg: oklch(0.8868 0.2098 150.33);
  --term-muted: oklch(0.5490 0.1290 152.24); /* ΔL .34 below --term-fg — the
     light-theme muted step (read it straight off the L's). Two-currency muting:
     muted separates from fg by *metric* distance (ΔL/ΔC) plus *categorical* (does
     it land in a different colour name?). Amber gets the categorical assist (its
     muted reads as brown); green has none — a dark green is still green — so it
     pays the whole gap in lightness: a darker, chroma-held green, not the duller,
     closer one that blended. The rule lives in AGENTS.md (Aesthetic). */
  --term-rule: oklch(0.3286 0.0721 149.8);
  --term-phosphor: var(--phosphor);
  --term-ink: var(--phosphor-ink);
  --term-glow: var(--phosphor-glow);
  --term-hover: oklch(0.8670 0.1809 154.02); /* the -light invert step */
  --term-text-glow: rgba(80, 255, 140, 0.45); /* resting body bloom */
  --term-pool: rgba(40, 200, 100, 0.05); /* the centre phosphor wash */
  --term-img-hue: 75deg; /* greenscreen hue-rotate off sepia */
}
/* Amber: the P3 tube. Same structure, warmer phosphor. */
:root[data-theme="phosphor-amber"] {
  --term-bg: oklch(0.1251 0.0256 90.11);
  --term-fg: oklch(0.8813 0.1020 75.85);
  --term-muted: oklch(0.6213 0.1101 70.77); /* the categorical exemplar: dark +
     chroma-held amber reads as brown — a different colour name from the pale fg —
     so a moderate ΔL already separates it (green, above, must spend more). */
  --term-rule: oklch(0.2865 0.0541 74.8);
  --term-phosphor: var(--phosphor-amber);
  --term-ink: var(--phosphor-amber-ink);
  --term-glow: var(--phosphor-amber-glow);
  --term-hover: oklch(0.9043 0.0842 77.47);
  --term-text-glow: rgba(255, 180, 90, 0.45);
  --term-pool: rgba(220, 150, 40, 0.06);
  --term-img-hue: 5deg; /* sepia is already amber — barely rotate */
}
/* Shared tube — recolours by overriding the neutral + resting-text tokens, so
   the whole cascade follows (glass masthead/footer bars included — --glass mixes
   from --bg) with no per-element brute force. color-scheme dark keeps any
   light-dark() neutrals (e.g. the palette's untouched neutral swatch) dark. */
:root[data-theme^="phosphor"] {
  color-scheme: dark;
  --bg: var(--term-bg);
  --fg: var(--term-fg);
  --muted: var(--term-muted);
  --rule: var(--term-rule);
  /* The lightbox keeps the tube's phosphor bg, not the neutral theater black —
     point at --term-bg directly, not var(--bg): the .lightbox scope sets
     --bg: var(--lightbox-bg), so resolving this through --bg would cycle. */
  --lightbox-bg: var(--term-bg);
  /* Resting text → phosphor (heads, tags, the name, the cmd prompt + value). */
  --accent-category-text: var(--fg);
  --accent-tag-text: var(--fg);
  --mark: var(--fg);
  /* Prose links carry only a quiet --tint-line underline at rest (the body
     colour does the rest in light/dark). On a tube that signal collapses: the
     resting bloom (--glow-text) blooms *over* the thin line and the low-contrast
     near-black fills the gap, so a 30% underline of the fg vanishes and a link
     reads as plain prose. The underline has to be *self-sufficient at rest* —
     strong enough that nothing transient is carrying it. Two things conspire
     against a faint line here: the fixed scanline overlay (a .35-alpha dark grid
     painted over everything, including text) darkens it, and the resting bloom
     washes its edges — so a sub-full line reads as plain prose. Full currentColor
     carries it on opacity alone, at the site's normal 1px weight — no thickening
     needed once
     it's opaque. Both tubes. Heads/tags/nav kill the underline outright, so this
     touches prose + the list/palette link rows. */
  --tint-line: 100%;
  /* Every hover/focus invert fill + its -light hover step → one phosphor family,
     knocking text out to near-black, so nothing keeps its original hue (the
     name, social tools, nav links, tag roving-focus, the active year, the
     cmd placeholder which rides --mark-light). Draft red stays — dev-only. */
  --accent: var(--term-phosphor);
  --accent-hover: var(--term-hover);
  --accent-category: var(--term-phosphor);
  --accent-category-light: var(--term-hover);
  --accent-social: var(--term-phosphor);
  --accent-social-light: var(--term-hover);
  --accent-tag: var(--term-phosphor);
  --accent-tag-light: var(--term-hover);
  --accent-year: var(--term-phosphor);
  --accent-year-light: var(--term-hover);
  --mark-light: var(--term-hover);
  --accent-fg: var(--term-ink);
  --mark-fg: var(--term-ink);
  --selection: var(--term-phosphor);
  --selection-fg: var(--term-ink);
  /* A soft dark raster line every 3px — faint over lit text + photos, all but
     gone on the near-black bg, so it never fights legibility. */
  --scanlines: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0) 0,
    rgba(0, 0, 0, 0) 2px,
    rgba(0, 20, 6, 0.35) 3px,
    rgba(0, 20, 6, 0.35) 3px
  );
  /* The glass tube, in three stacked radials (topmost first). A faint phosphor
     wash pooled at centre — the beam glows brightest where it's busiest, never
     enough to lift the near-black bg off the text. Below it a wide, shallow
     curvature highlight — light catching the bowed glass across the middle band.
     Beneath both, the tube-shadow vignette: the displayed image darkening into
     the physical housing, biting hardest in the corners (ellipse defaults to
     farthest-corner). Authentic CRT is corner-dark + centre-lit, not edge-glow —
     and a monochrome phosphor tube has no shadow mask, so scanlines are the only
     raster cue (no RGB triads, no chromatic split — those are colour-CRT
     artifacts). */
  --vignette:
    radial-gradient(ellipse at center, var(--term-pool) 0%, transparent 55%),
    radial-gradient(
      ellipse 120% 60% at center 42%,
      rgba(255, 255, 255, 0.018) 0%,
      transparent 60%
    ),
    radial-gradient(ellipse at center, transparent 50%, rgba(0, 8, 2, 0.5) 100%);
  /* Phosphor halation — the brightest pixels bloom. The hover/focus invert is
     the brightest phosphor on the page, so its chip glows: light the --link-glow
     box-shadow slot (see the base link rule). One soft tint, kept low so the
     bloom reads as light, not a ring. */
  --link-glow: var(--term-glow);
  /* Graded phosphor glow, tuned by hierarchy so brightness blooms like a real
     tube: resting body soft (legibility first), heads/name/marked rows brighter
     (set on those elements), --muted left unglowed. Photos emit via drop-shadow
     in the img filter. One place to tune each. */
  --glow-text: 0 0 2px var(--term-text-glow);
  --glow-text-strong: 0 0 7px var(--term-text-glow);
  --glow-photo: 0 0 7px var(--term-glow);
  /* Persistence: the lit cell cools instead of snapping off (the decay block
     below). Fast attack, this slow release — the afterglow of a moving beam. */
  --term-decay: 450ms;
  background: var(--term-bg);
}
:root[data-theme^="phosphor"] body {
  font-family: var(--font-mono) !important; /* phosphor terminals are mono */
  text-shadow: var(--glow-text); /* graded resting bloom — see the glow tokens */
}
/* The brightest text blooms more, like a real tube — heads, the name, and the
   page h1 carry the stronger glow; body copy stays on the soft step (set on
   body) for legibility. No resting *animation* anywhere; this is static bloom. */
:root[data-theme^="phosphor"] :is(h1, h2, h3, .section-title, .name) {
  text-shadow: var(--glow-text-strong);
}
/* Photos go phosphor-screen — desaturated, tinted to the tube's hue — so they
   read as a vintage terminal display (covers list/preview, hero, body shots, and
   the lightbox image, which the filter follows into the top layer). The
   drop-shadow lets the brightest pixels emit into the dark page past the frame;
   the lifted brightness/contrast blooms highlights toward overexposed phosphor. */
:root[data-theme^="phosphor"] img {
  mix-blend-mode: hard-light;
  filter: grayscale(1) contrast(0.62) brightness(1.32) sepia(1)
    hue-rotate(var(--term-img-hue)) saturate(1.5) drop-shadow(var(--glow-photo));
}
/* Scanlines + the tube vignette, baked into a fixed pseudo so they survive
   navigation (no JS overlay to re-inject). A second copy rides each open dialog
   so they land in the top layer too — the lightbox and command palette sit
   *above* the page pseudo, so they'd otherwise escape the glass (and the modal
   sheet covers the page, so its own corners need the same shadow). */
:root[data-theme^="phosphor"]::after,
:root[data-theme^="phosphor"] dialog[open]::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 9991;
  pointer-events: none;
  background: var(--vignette), var(--scanlines);
}
/* Mobile browsers float their toolbars *over* the page (iOS 26 Liquid Glass
   most visibly), and Safari won't paint a fixed overlay behind them — so this
   overlay gets clipped at the toolbar line, leaving a hard scanline/brightness
   cutoff. We can't make a fixed layer bleed behind the chrome (only in-flow
   content does), so instead we dissolve the overlay's top and bottom edges to
   nothing: by the time the toolbar would clip it, it's already transparent, so
   there's no line to see. The fade is deliberately tall (esp. the bottom, where
   iOS 26 parks its address bar) so it spans the toolbar zone whether Safari
   sizes the fixed box to the small or large viewport. Coarse-pointer only — the
   devices with floating toolbars — so desktop keeps full edge-to-edge scanlines.
   px (not %) so the fade tracks the real toolbar height across screen sizes. */
@media (pointer: coarse) {
  :root[data-theme^="phosphor"]::after,
  :root[data-theme^="phosphor"] dialog[open]::after {
    -webkit-mask-image: linear-gradient(
      to bottom,
      transparent 0,
      #000 56px,
      #000 calc(100% - 120px),
      transparent 100%
    );
    mask-image: linear-gradient(
      to bottom,
      transparent 0,
      #000 56px,
      #000 calc(100% - 120px),
      transparent 100%
    );
  }
}

/* Font override — set by the masthead font toggle (site.js, persisted to
   localStorage and pre-applied in the head so there's no flash). Aliases the
   body family to the serif stack; no attribute means the default monospace.
   Code keeps --font-mono regardless (its chip geometry rides the character
   grid), so blocks and inline tokens stay monospace under either family. */
:root[data-font="serif"] {
  --font-body: var(--font-serif);
}

/* Honor reduced-motion: keep transitions/animations effectively instant. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}

/* Theme change (the toggle, another tab, or the system) cross-fades lightning
   quick — a same-document view transition that site.js types 'theme' (page
   navigations are deliberately instant, like a terminal). Browsers without
   typed view transitions just swap instantly (site.js falls back); reduced
   motion is honored in site.js, since the universal rule above can't reach
   these pseudo-elements. */
:root:active-view-transition-type(theme)::view-transition-old(root),
:root:active-view-transition-type(font)::view-transition-old(root) {
  animation: fade-out var(--duration-fast) var(--ease) both;
}
:root:active-view-transition-type(theme)::view-transition-new(root),
:root:active-view-transition-type(font)::view-transition-new(root) {
  animation: fade-in var(--duration-fast) var(--ease) both;
}

@keyframes fade-out {
  to {
    opacity: 0;
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
}

* {
  box-sizing: border-box;
}

/* Always reserve the scrollbar gutter so page width doesn't jump between
   scrolling and non-scrolling pages. */
html {
  font-size: 17px;
  scrollbar-gutter: stable;
  /* Anchor jumps (TOC, deep links) + the roving walk's scrollIntoView land clear
     of the sticky masthead bar with comfortable headroom: the bar's stuck height
     (1lh + its two --space-sm pads, like the cmd dialog's filter) plus a
     --space-lg breath so a heading never tucks against the bar's underside. */
  scroll-padding-top: calc(1lh + 2 * var(--space-sm) + var(--space-lg));
}
/* Dev only: the draft banner sits above the bar (sticky, z-100), so anchor
   targets must clear *both* — add the banner's height (matches the masthead
   bar's draft top offset). Production never renders the banner. */
:root:has(.draft-flag) {
  scroll-padding-top: calc(
    2lh + 2 * var(--space-sm) + 2 * var(--space-3xs) + var(--space-lg)
  );
}
/* One knob scales the whole site down as the viewport narrows: every length
   rides rem (vertical rhythm, type) or ch (inline gaps — a monospace cell, so
   it tracks the font), so stepping the root font-size reflows everything in
   proportion. Breakpoints in rem resolve against the browser's initial font
   (16px), not ours, so these thresholds stay fixed in px as the size steps.

   max-width is inclusive, and later (smaller) blocks win on the cascade, so each
   threshold belongs to the *smaller* size — the ranges are half-open, (lower,
   upper]:
     17px  — width > 1024px        (the base; no query matches)
     16px  —  768 < width ≤ 1024    (max-width: 64rem)
     15px  —  544 < width ≤  768    (max-width: 48rem)
     14px  —  352 < width ≤  544    (max-width: 34rem)
     13px  —        width ≤  352    (max-width: 22rem)
   So a 1024px screen is already 16px, a 768px tablet 15px, etc. — full size is
   reserved for genuinely large viewports. */
@media (max-width: 64rem) {
  html {
    font-size: 16px;
  }
}
@media (max-width: 48rem) {
  html {
    font-size: 15px;
  }
}
@media (max-width: 34rem) {
  html {
    font-size: 14px;
  }
}
@media (max-width: 22rem) {
  html {
    font-size: 13px;
  }
}

body {
  font-family: var(--font-body);
  font-size: var(--text-base);
  line-height: var(--leading-normal);
  color: var(--fg);
  background: var(--bg);
  margin: 0;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* Sticky footer: the page starts at the viewport top; on short pages the
     footer's auto top margin pushes it to the viewport bottom, and on taller
     pages it just follows the content and scrolls normally. dvh (with a vh
     fallback for older engines) tracks iOS Safari's dynamic toolbars, so under
     viewport-fit=cover the footer rests at the live viewport bottom instead of
     being shoved behind the bottom Liquid Glass toolbar on short pages. */
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

/* Draft chrome — development builds only (production never renders draft
   pages, so none of this can ship). Deliberately loud, against the site's
   grain on purpose: a red banner across the top of any page touching a draft,
   and a bold red `draft` link in the meta cluster of any list/palette row
   pointing at one. (A red page-bg wash was tried and removed — Harlan's call,
   2026-06-14: the banner carries the warning, the wash was too much.) */
.draft-flag {
  margin: 0;
  padding: var(--space-3xs) var(--space-md);
  background: var(--accent-draft);
  color: var(--accent-fg);
  font-weight: var(--weight-bold);
  text-align: center;
  position: sticky;
  top: 0;
  z-index: 100;
}
/* The `draft` mark is now a link to the /drafts/ facet page (a tag for draft
   state — dev-only, like everything red here). Rests loud red and bold, not
   muted like tags; inverts in its own red intent (the tag/year precedent). */
.draft-mark {
  --link-fg: var(--accent-draft);
  --accent: var(--accent-draft);
  --accent-hover: var(--accent-draft-light);
  font-weight: var(--weight-bold);
}
/* Forced colors strips the fills — keep the banner reading as a strip with
   a real border (the code chip's precedent). */
@media (forced-colors: active) {
  .draft-flag {
    border-block-end: var(--border-thick) solid;
  }
}

main {
  width: 100%; /* flex item: fill up to max-width (auto margins alone would shrink to content) */
  max-width: var(--measure);
  margin-inline: auto;
  padding: var(--space-3xl) var(--space-lg) 0;
}

footer {
  width: 100%;
  max-width: var(--measure);
  margin-inline: auto;
  margin-top: auto; /* sticky footer (see body) */
  padding: var(--gap-section) var(--space-lg) var(--space-2xl);
  color: var(--muted);
  font-size: var(--text-sm);
  /* Copyright left, palette strip right, sharing one baseline. */
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-md);
}
footer p {
  margin: 0;
}

/* The palette strip (see baseof.html) — swatches butted into one bar that
   stretches across all the negative space to the right of the copyright. Each
   keeps its caps height (0.7em) but shares the width equally; its -light and
   -deep steps hang below as two thin rows (pseudo-elements, so they're absolute
   and don't disturb the baseline math), like a descender. Colors arrive inline
   as --c / --c-light / --c-deep from the tokens. */
.palette {
  display: inline-flex;
  flex: 1; /* fill the row right of the copyright */
  min-width: 0;
}
.swatch {
  position: relative;
  flex: 1; /* each hue an equal share of the stretched width */
  height: 1em;
  background: var(--c);
}
.swatch::before,
.swatch::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 0.175em;
  height: 0.25cap;
}
.swatch::before {
  top: 100%;
  background: var(--c-light);
}
.swatch::after {
  top: calc(100% + 0.175em);
  top: calc(100% + 0.25cap);
  background: var(--c-deep);
}

/* ── Easter egg: the footer palette ────────────────────────────────────────
   The strip idles through a steady hue-cycle at rest (10s a full rotation) —
   ambient proof of life in the footer corner, present but unhurried. Hovering
   speeds it to a brighter shimmer (the invitation); a click bursts the whole
   page plus a transmission-static flicker (site.js). It stays clickable always.
   Decorative + JS-only: without JS it's an inert bar. Light/dark only — under a
   terminal tube the strip is monochrome phosphor, and hue-rotating it would
   break the single-colour illusion, so it holds still there. Reduced motion
   flattens the cycle via the universal rule. */
.palette {
  cursor: pointer;
}
:root:not([data-theme^="phosphor"]) .palette {
  animation: fx-hue 10s linear infinite;
}
@media (hover: hover) {
  :root:not([data-theme^="phosphor"]) .palette:hover {
    animation-duration: 2s;
  }
}
/* Under either terminal theme the whole strip goes monochrome phosphor — every
   hue becomes the tube's colour — except the neutral (light/dark) swatch, pinned
   to the dark neutral ramp so it stays the odd one out, the literal light/dark
   control. Token-driven off --term-phosphor, so green and amber each get their
   own strip; overrides background directly, since the inline --c can't be
   reached from CSS. */
:root[data-theme^="phosphor"] .swatch:not([data-hue="neutral"]) {
  background: var(--term-phosphor);
}
:root[data-theme^="phosphor"] .swatch:not([data-hue="neutral"])::before {
  background: var(--term-hover);
}
:root[data-theme^="phosphor"] .swatch:not([data-hue="neutral"])::after {
  background: color-mix(in srgb, var(--term-phosphor) 50%, #000);
}
:root[data-theme^="phosphor"] .swatch[data-hue="neutral"] {
  background: var(--pole-fg-dark);
}
:root[data-theme^="phosphor"] .swatch[data-hue="neutral"]::before {
  background: #8c8b86;
}
:root[data-theme^="phosphor"] .swatch[data-hue="neutral"]::after {
  background: #2a2927;
}

/* The disco hue-cycle — a body class site.js toggles on a palette click (and
   the strip's own hover shimmer). 6s, three rotations; flattened to nothing
   under the global reduced-motion rule, so site.js skips it there. */
.fx-disco {
  animation: fx-hue 2s linear 3;
}
@keyframes fx-hue {
  to {
    filter: hue-rotate(360deg);
  }
}

/* CRT transmission effects — a single full-viewport overlay site.js injects
   once and drives by class, decorative (aria-hidden) and JS-only. Two layers,
   one per pseudo, so they can fire together without clobbering each other:
   ::before is the static snow + a rolling hold-bar (the channel losing signal),
   ::after is the power-on raster line. A palette click bursts static under the
   disco hue-cycle; switching *into* a terminal tube boots the screen (static +
   power-on). Both are short and self-clearing; reduced motion skips them in JS,
   and the element sits above the terminal scanline pseudo (z 9991). */
.fx-overlay {
  position: fixed;
  inset: 0;
  z-index: 9995;
  pointer-events: none;
}
.fx-overlay::before,
.fx-overlay::after {
  content: "";
  position: absolute;
  inset: 0;
  opacity: 0;
}
/* Snow, four stacked layers screened over the page so they read as light, not
   paint: (1) a grayscale feTurbulence tile that jitters position — crawling
   static; (2) a soft wide band rolling top→bottom, a lost vertical hold;
   (3) a sharp bright retrace line sweeping down fast, the flyback the eye
   catches when a set drops sync; (4) a fixed fine raster grid — the scanline
   structure the snow rides on, so even over a non-terminal page the burst reads
   as a tube, not a flat noise wash. ~0.6s, stepped so it judders, not glides. */
.fx-overlay.is-static::before {
  background:
    url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='120'%20height='120'%3E%3Cfilter%20id='n'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='0.6'%20numOctaves='2'%20stitchTiles='stitch'/%3E%3CfeColorMatrix%20type='saturate'%20values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR%20type='linear'%20slope='2.2'%20intercept='-0.6'/%3E%3CfeFuncG%20type='linear'%20slope='2.2'%20intercept='-0.6'/%3E%3CfeFuncB%20type='linear'%20slope='2.2'%20intercept='-0.6'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect%20width='100%25'%20height='100%25'%20filter='url(%23n)'%20opacity='0.9'/%3E%3C/svg%3E")
      repeat,
    linear-gradient(
      to bottom,
      transparent 44%,
      rgba(255, 255, 255, 0.08) 50%,
      transparent 56%
    ),
    linear-gradient(
      to bottom,
      transparent 46%,
      rgba(200, 255, 215, 0.18) 49%,
      rgba(200, 255, 215, 0.18) 51%,
      transparent 54%
    ),
    repeating-linear-gradient(
      to bottom,
      rgba(255, 255, 255, 0.6) 0,
      rgba(255, 255, 255, 0.6) 1px,
      transparent 1px,
      transparent 4px
    );
  background-size:
    180px 180px,
    100% 240%,
    100% 16%,
    100% 100%;
  mix-blend-mode: screen;
  animation:
    fx-static 0.6s ease-out forwards,
    fx-snow 0.6s steps(6) forwards;
}
/* The phosphor energise flash on a cold power-on. The snow stays flat
   full-screen here — the *content itself* does the raster bloom (main.fx-bloom-
   page), so the snow reads as the no-signal noise the picture tunes in through.
   This is just the bright wash as the tube catches, a quick lock pulse, gone.
   --boot-color is the tube being entered. */
.fx-overlay.is-boot::after {
  background: var(--boot-color, var(--term-phosphor, #2bcf70));
  animation: fx-boot 0.5s ease-out forwards;
}
@keyframes fx-static {
  0% {
    opacity: 0;
  }
  8% {
    opacity: 0.5;
  }
  55% {
    opacity: 0.26;
  }
  100% {
    opacity: 0;
  }
}
@keyframes fx-snow {
  0% {
    background-position:
      0 0,
      0 -30%,
      0 -120%,
      0 0;
  }
  20% {
    background-position:
      13% 7%,
      0 0%,
      0 60%,
      0 0;
  }
  40% {
    background-position:
      -8% 21%,
      0 35%,
      0 260%,
      0 0;
  }
  60% {
    background-position:
      24% -12%,
      0 70%,
      0 440%,
      0 0;
  }
  80% {
    background-position:
      -17% 9%,
      0 100%,
      0 620%,
      0 0;
  }
  100% {
    background-position:
      6% 18%,
      0 130%,
      0 800%,
      0 0;
  }
}
/* The phosphor flash — opacity only; the element bloom does the opening. Bright
   energise as the band appears, dim, a quick lock-flash pulse as the picture
   catches, then gone. */
@keyframes fx-boot {
  0% {
    opacity: 0.9;
  }
  30% {
    opacity: 0.12;
  }
  62% {
    opacity: 0.42;
  }
  100% {
    opacity: 0;
  }
}
/* ── Content-driven power on/off ──────────────────────────────────────────
   The real page (<main>) does the CRT geometry, not an overlay abstraction, so
   it's *your content* that energises and collapses. site.js sets transform-
   origin to the viewport centre (in main's own coords, scroll-corrected) before
   each, so the motion always pivots on the middle of the screen. */

/* Power-ON: the picture blooms from a hot horizontal line. Horizontal leads —
   a narrow core snaps wide with overscan while still a thin band — then vertical
   opens (the slow, dominant motion), brightness ramping down from overexposed.
   The flat snow + phosphor flash play over it. (Not `forwards` — ends at the
   element's natural full size.) */
main.fx-bloom-page {
  animation: fx-bloom-page 0.42s ease-out;
}
@keyframes fx-bloom-page {
  0% {
    transform: scaleX(0.3) scaleY(0.012);
    opacity: 0.5;
    filter: brightness(2.6);
  }
  26% {
    transform: scaleX(1.06) scaleY(0.05);
    opacity: 0.85;
    filter: brightness(1.9);
  }
  55% {
    transform: scaleX(0.99) scaleY(0.7);
    opacity: 1;
    filter: brightness(1.2);
  }
  100% {
    transform: scaleX(1) scaleY(1);
    opacity: 1;
    filter: brightness(1);
  }
}
/* Power-OFF: the classic shutoff. The picture crushes vertically to a hot line
   (brightness ramping up as the energy concentrates), pinches horizontally to a
   centre dot, and winks out. site.js swaps the destination theme in at the pinch
   and fades it back to size (fx-reveal). Holds the dot (`forwards`) until then. */
main.fx-collapse {
  animation: fx-collapse 0.4s ease-in forwards;
}
@keyframes fx-collapse {
  0% {
    transform: scaleY(1) scaleX(1);
    filter: brightness(1);
    opacity: 1;
  }
  42% {
    transform: scaleY(0.012) scaleX(1.02);
    filter: brightness(2.4);
  }
  74% {
    transform: scaleY(0.012) scaleX(0.06);
    filter: brightness(3);
  }
  100% {
    transform: scaleY(0.02) scaleX(0.02);
    filter: brightness(3.4);
    opacity: 0;
  }
}
/* The destination theme, fading up to full size after the dot dies. */
main.fx-reveal {
  animation: fx-reveal 0.32s ease-out;
}
@keyframes fx-reveal {
  0% {
    opacity: 0;
    transform: scale(0.97);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}
/* The bright phosphor dot the collapse pinches to — a small hot point at the
   viewport centre that surfaces as the content reaches the pinch, blooms, and
   fades. Mostly transparent so the content's own collapse shows through.
   --boot-color is the tube being left. */
.fx-overlay.is-poweroff {
  background: radial-gradient(
    circle at center,
    color-mix(in srgb, var(--boot-color, #2bcf70) 35%, #fff) 0%,
    var(--boot-color, #2bcf70) 4%,
    transparent 13%
  );
  animation: fx-dot 0.46s ease-in forwards;
}
@keyframes fx-dot {
  0% {
    opacity: 0;
    transform: scale(1);
  }
  62% {
    opacity: 0;
    transform: scale(1);
  }
  76% {
    opacity: 0.9;
    transform: scale(1);
  }
  88% {
    opacity: 1;
    transform: scale(1.6);
    filter: brightness(1.5);
  }
  100% {
    opacity: 0;
    transform: scale(1.9);
  }
}

/* Section/category heads rest in the accent violet — the same hue as the
   default link hover — with no underline; page h1s hold bold fg at body size
   beneath them; every other link gets a quiet --tint-line underline. On
   hover/focus/active any link inverts —
   filled with its intent accent, text knocked out to --accent-fg, with softly
   rounded corners, like a terminal highlight. Adapts to light/dark on its own. */
a,
.btn-link {
  color: var(--link-fg, inherit);
  text-decoration-line: underline;
  text-decoration-style: solid;
  text-decoration-thickness: var(--border-thin);
  text-underline-offset: 0.18em;
  text-decoration-color: color-mix(
    in srgb,
    currentColor var(--tint-line),
    transparent
  );
  /* Two horizontal copies of the box extend the hover highlight sideways for
     breathing room — no layout shift, and the underline/flex gaps stay untouched.
     Geometry is present (transparent) at rest so only the color fades in. A third
     copy is the phosphor halation slot — transparent here, lit on hover/focus
     under the terminal theme (--link-glow); it rides the same box-shadow
     transition, so it fades out cleanly instead of ghosting like a filter. */
  box-shadow:
    calc(-1 * var(--link-pad-x)) 0 0 transparent,
    var(--link-pad-x) 0 0 transparent,
    0 0 var(--glow-blur) transparent;
  transition:
    background-color var(--duration-fast) var(--ease),
    color var(--duration-fast) var(--ease),
    box-shadow var(--duration-fast) var(--ease);
}
/* Disabled tool button — dimmed and inert, no hover invert or tooltip (the
   font toggle while the terminal theme forces monospace; site.js sets it). The
   60% matches the global disabled dim; no strikethrough on an icon glyph. */
.btn-link:disabled {
  opacity: 0.6;
  pointer-events: none;
}

/* The site name — a header like the section titles, but fuchsia instead of
   violet: rests as colored text with no underline, and the generic hover
   rules below fill it with the same hue. */
.name a,
a.name {
  --link-fg: var(--mark);
  --accent: var(--mark);
  --accent-hover: var(--mark-light);
  --accent-fg: var(--mark-fg);
  text-decoration-line: none;
}

/* Two invert weights: hover suggests in the intent's -light step; focus,
   click, and the roving selection commit in the full accent. The focus rule
   paints after the hover rule, so when both apply focus wins. */
a:hover,
.btn-link:hover {
  background: var(--accent-hover);
  color: var(--accent-fg);
  border-radius: var(--radius-sm);
  box-shadow:
    calc(-1 * var(--link-pad-x)) 0 0 var(--accent-hover),
    var(--link-pad-x) 0 0 var(--accent-hover),
    0 0 var(--glow-blur) var(--link-glow);
  text-decoration-color: transparent;
}
a:focus-visible,
a:active,
.btn-link:focus-visible,
.btn-link:active,
a.is-active,
.btn-link.is-fresh {
  background: var(--accent);
  color: var(--accent-fg);
  border-radius: var(--radius-sm);
  box-shadow:
    calc(-1 * var(--link-pad-x)) 0 0 var(--accent),
    var(--link-pad-x) 0 0 var(--accent),
    0 0 var(--glow-blur) var(--link-glow);
  text-decoration-color: transparent;
}

/* The invert above *is* the focus indicator — drop the UA ring so the two
   don't compete. The outline stays present but transparent so forced-colors
   mode (which strips backgrounds) still paints a visible ring. */
a:focus-visible,
.btn-link:focus-visible {
  outline: var(--border-thick) solid transparent;
}

/* Green tube on first visit: the theme defaults to terminal (head script) until
   the visitor ever switches by hand, and the toggle stays *lit* the whole time
   to advertise it — the standard focus/active invert (the green chip + halation
   above), held steady (no blink), via .is-fresh. site.js adds it when the
   `switched` flag is unset and strips it the moment they toggle. */

/* Phosphor halation flicker — interaction-only. The "no animation while static"
   rule is about *resting* chrome (no flickering screen while you read); a
   hover/focus is transient and user-initiated, so animating its bloom is fair
   game (the same licence the cursor blink and tooltip rise take). Registering
   --glow-blur as an animatable <length> lets a keyframe wobble just the glow
   copy of the lit chip's box-shadow (0 0 var(--glow-blur) var(--link-glow)) —
   the fill copies and layout never move. Irregular stops + linear interpolation
   read as an unsteady beam settling, not a strobe; amplitude is small. Terminal
   only (light/dark leave --link-glow transparent, so there'd be nothing to
   flicker). Gated to no-preference so reduced motion gets a steady glow rather
   than a 0.01ms-looped stutter (the cursor-blink precedent). */
@property --glow-blur {
  syntax: "<length>";
  inherits: true;
  initial-value: 6px;
}
/* --glow-bright rides the same keyframe to wobble the *whole lit chip*, not just
   its bloom — applied as filter: brightness(), it pulses the flat invert fill,
   the knocked-out text, and the glow together, the way a phosphor cell's output
   actually fluctuates. A <number> so it animates; 1 at rest (a no-op). */
@property --glow-bright {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}
@keyframes term-glow-flicker {
  0% {
    --glow-blur: 6px;
    --glow-bright: 1;
  }
  14% {
    --glow-blur: 9px;
    --glow-bright: 1.11;
  }
  22% {
    --glow-blur: 5px;
    --glow-bright: 0.92;
  }
  47% {
    --glow-blur: 8px;
    --glow-bright: 1.06;
  }
  63% {
    --glow-blur: 6px;
    --glow-bright: 0.97;
  }
  81% {
    --glow-blur: 9.5px;
    --glow-bright: 1.13;
  }
  100% {
    --glow-blur: 7px;
    --glow-bright: 1;
  }
}
@media (prefers-reduced-motion: no-preference) {
  :root[data-theme^="phosphor"]
    :is(a, .btn-link):is(:hover, :focus-visible, :active, .is-active) {
    animation: term-glow-flicker 1.5s linear infinite;
    filter: brightness(var(--glow-bright));
  }
}

/* Phosphor persistence — the whole lit cell (fill, knocked-out ink, halation)
   cools as one when it loses the pointer or focus, instead of snapping off: an
   afterimage of the beam as it moves on. Asymmetric — the attack stays fast (the
   lit states reset the transition to --duration-fast), only the *return to rest*
   is slowed to --term-decay on an ease-out, so light-up is instant and the ghost
   trails. Every invert state, so the roving walk leaves a wake and a hovered list
   ghosts behind the cursor — not "limited to roving". Interaction-only, gated to
   no-preference (reduced motion keeps the snappy default, the flicker's
   precedent). */
@media (prefers-reduced-motion: no-preference) {
  :root[data-theme^="phosphor"] :is(a, .btn-link) {
    transition:
      background-color var(--term-decay) ease-out,
      color var(--term-decay) ease-out,
      box-shadow var(--term-decay) ease-out;
  }
  :root[data-theme^="phosphor"]
    :is(a, .btn-link):is(:hover, :focus-visible, :active, .is-active, .is-fresh) {
    transition:
      background-color var(--duration-fast) var(--ease),
      color var(--duration-fast) var(--ease),
      box-shadow var(--duration-fast) var(--ease);
  }
}

/* Composable link variant — add to any <a>; composes with the hover invert.
   Resting color rides on --link-fg, so the explicit inverted hover color always
   wins regardless of source order (the bug the bare `.more a` rule once had). */
.link-muted {
  --link-fg: var(--muted);
} /* rests muted: More, tags, … */

/* Hover accents by intent (the default --accent covers everything else). */
.section-title,
.more a {
  --accent: var(--accent-category);
  --accent-hover: var(--accent-category-light);
}
/* Masthead tools rest muted like tags; fuchsia invert on hover. Each is a
   square chip: content centered both axes, a square floor that wider content
   (the Aa specimen, a future label) grows past horizontally — never below it.
   Square *including the breathing*: the hover box-shadow extends the chip
   --link-pad-x past the box on each side, so the box's own width is set
   2×--link-pad-x short of --tool-size and the breathing pays the difference
   back — the visible chip lands a true --tool-size square, no negative margin
   needed. Geometry is rem-based (the --tool-size floor, the centering), so a
   font-family toggle re-centers the glyph in place without resizing the box. */
.masthead-tools a,
.theme-toggle,
.font-toggle {
  --link-fg: var(--muted);
  --accent: var(--accent-social);
  --accent-hover: var(--accent-social-light);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  block-size: var(--tool-size);
  min-inline-size: calc(var(--tool-size) - 2 * var(--link-pad-x));
}
/* The toggles and the divider ship hidden (JS-only — site.js reveals them);
   the display rules above are author-origin and would otherwise beat the UA's
   [hidden] { display: none }, leaving empty boxes and a dangling pipe with no
   JS. Restore the hide so the no-JS masthead stays clean. */
.masthead-tools [hidden] {
  display: none;
}

/* The font toggle's glyph is a real specimen, not a masked icon: the letters
   "Aa" set in the family the button switches *to* (serif while the site is
   mono, mono once it's serif), so the glyph previews the swap. Bold so the
   serif's modulation reads at masthead size. */
.font-specimen {
  font-family: var(--font-serif);
  font-weight: var(--weight-bold);
  letter-spacing: -0.15ch;
}
:root[data-font="serif"] .font-specimen {
  font-family: var(--font-mono);
}
/* The toggle's tooltip is set in the family it switches *to* — the whole
   "Switch to serif" / "Switch to monospace" string is a specimen of the swap,
   flipped off [data-font] exactly like .font-specimen. */
.font-toggle .ico-title {
  font-family: var(--font-serif);
}
:root[data-font="serif"] .font-toggle .ico-title {
  font-family: var(--font-mono);
}

/* A <button> dressed as a link — shares all the link rules above, so it
   inverts on hover like any link. Carried by the theme toggle and the
   command-palette opener; both ship hidden and are revealed by site.js,
   since without JS they could do nothing. */
.btn-link {
  font: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}

/* Inline link decorations — favicon-sized monochrome glyphs that ride on
   currentColor, so they invert right along with the link on hover. Put a
   <span class="ico ico-<name> ico-left|ico-right" aria-hidden="true"> inside a
   link; .ico sizes + masks it, ico-<name> supplies the --ico graphic.
   New icons: 24-unit grid, stroke-width 2 (sun/moon are the reference;
   Streamline sets ship 1.5 — bump it on import). Brand logos (GitHub,
   LinkedIn) stay solid fills. The mask reads only alpha, so stroke color in
   the file is irrelevant. */
.ico {
  display: inline-block;
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  background: currentColor;
  -webkit-mask: var(--ico) center / contain no-repeat;
  mask: var(--ico) center / contain no-repeat;
}
.ico-left {
  margin-right: 0.35em;
}
.ico-right {
  margin-left: 0.35em;
}
.ico-github {
  --ico: url("../icons/github.svg");
}
.ico-linkedin {
  --ico: url("../icons/linkedin.svg");
}
.ico-sun {
  --ico: url("../icons/sun.svg");
} /* theme toggle → light */
.ico-moon {
  --ico: url("../icons/moon.svg");
} /* theme toggle → dark */
.ico-terminal {
  --ico: url("../icons/terminal.svg");
} /* theme toggle → exit the secret terminal theme */

/* Icon-only links (social, theme toggle) carry their title as *real text* in
   an .ico-title span — no-CSS shows it as a plain text link and screen
   readers always read it. Visually it becomes a tooltip below the icon,
   revealed on hover/focus: the same invert chip the link itself wears, in
   the link's own intent accent. */
:is(a, .btn-link):has(.ico-title) {
  position: relative;
  text-decoration-line: none;
}
.ico-title {
  position: absolute;
  top: calc(100% + var(--space-3xs));
  left: 50%;
  translate: -50%;
  padding-inline: var(--link-pad-x);
  /* The chip matches the link's own state: hover weight here, stepped up to
     the full accent on focus below — the same two invert weights links wear. */
  background: var(--accent-hover);
  color: var(--accent-fg);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  white-space: nowrap;
  opacity: 0;
  transform: translateY(-0.2em);
  pointer-events: none;
  /* Hide: a quick fade as it tucks back up toward the icon. */
  transition:
    opacity var(--duration-fast) var(--ease),
    transform var(--duration-fast) var(--ease);
  z-index: 1;
}
/* Reveal: drop away from the icon into place on the spring (transform
   motion); the fade stays on --ease. Keyboard focus reveals everywhere; hover
   reveals only on real pointers — a touch tap sticks :hover until the next tap,
   which left the tooltip stranded on phones (and a tooltip is meaningless with
   no cursor to anchor it). */
:is(a, .btn-link):focus-visible .ico-title {
  opacity: 1;
  transform: none;
  transition:
    opacity var(--duration-fast) var(--ease),
    transform var(--duration-med) var(--ease-spring);
}
@media (hover: hover) {
  :is(a, .btn-link):hover .ico-title {
    opacity: 1;
    transform: none;
    transition:
      opacity var(--duration-fast) var(--ease),
      transform var(--duration-med) var(--ease-spring);
  }
}
:is(a, .btn-link):focus-visible .ico-title {
  background: var(--accent);
}
/* The theme toggle's tooltip previews the mode it switches *to* — painted in
   that mode's own bg + fg (the inverse of the current theme) instead of the
   fuchsia accent the other tooltips wear. The :focus-visible variant overrides
   the accent focus rule above (equal specificity, wins by coming later). */
.theme-toggle .ico-title,
.theme-toggle:focus-visible .ico-title {
  background: var(--bg-invert);
  color: var(--fg-invert);
}
/* When the *next* theme is terminal, the toggle wears the terminal glyph — so
   its tooltip previews the phosphor screen itself: the green chip, knocked-out
   ink, and the halation bloom, instead of the light/dark inverse above. Pure
   CSS off the glyph class (site.js bakes in .ico-terminal when next is
   terminal), so it's right whether dark is explicit or the system default.
   Source order beats the equal-specificity focus rule above. */
.theme-toggle:has(.ico-terminal) .ico-title {
  background: var(--phosphor);
  color: var(--phosphor-ink);
  box-shadow: 0 0 var(--glow-blur) var(--phosphor-glow);
}
/* The two terminal tubes cycle green → amber, both wearing the terminal glyph.
   So once you're *in* the green tube the next stop is amber — the preview chip
   goes amber to match. (From dark, next is green; from amber, next is light and
   the glyph is the sun, so neither rule applies.) */
:root[data-theme="phosphor-green"] .theme-toggle:has(.ico-terminal) .ico-title {
  background: var(--phosphor-amber);
  color: var(--phosphor-amber-ink);
  box-shadow: 0 0 var(--glow-blur) var(--phosphor-amber-glow);
}
/* Forced colors strips backgrounds — both the chips and the masked glyphs
   vanish. Surface the real-text titles inline so these stay usable links. */
@media (forced-colors: active) {
  :is(a, .btn-link):has(.ico-title) .ico {
    display: none;
  }
  .ico-title {
    position: static;
    opacity: 1;
    transform: none;
    translate: none;
  }
}
/* The masthead tools hug the page's right edge — their tooltips right-align
   under the icon so they never poke past the viewport. */
.masthead-tools .ico-title {
  left: auto;
  right: 0;
  translate: none;
}

/* Hover highlights are full line-height everywhere: links default to
   inline-block, matching the taller highlight flex/grid-item links (rows,
   titles, tags, social) get for free — no per-context opt-in, so a link
   nested in a span (the job-row company name) is correct by default.
   Flowing prose is the one exception — inside paragraphs, list items,
   figcaptions, and blockquotes links stay inline so they wrap mid-sentence.
   Structural links that live inside a <p> (the name, More, the meta line)
   opt back in by class; extend that list, not per-page rules. */
a {
  display: inline-block;
}
:is(p, li, figcaption, blockquote) a {
  display: inline;
}
.name a,
a.name,
.more a,
.meta a {
  display: inline-block;
}

/* Selected text: highlighter yellow by default; inside a link, the link's own
   intent accent — because --accent is reassigned per intent (tags steel, the
   name fuchsia), each link's selection matches its hover invert for free. */
::selection {
  background: var(--selection);
  color: var(--selection-fg);
}
a,
.btn-link {
  --selection: var(--accent);
  --selection-fg: var(--accent-fg);
}

/* Masthead — display: contents so its three rows (top, bio, nav) flow as
   direct children of <main>. That makes the page-tall <main> the sticky top
   row's containing block, so it stays pinned for the whole scroll (a sticky
   child only sticks while its parent box is in view — and the masthead's own
   box ends at the nav). The <header> stays a banner landmark; the section gap
   that lived on its margin moves to the nav-wrap, the last row. */
.masthead {
  display: contents;
}
/* Name + social links on one row — same grid pattern as a career row. On narrow
   screens the social set drops to its own line, left-aligned, moving as a unit.
   It sticks to the viewport top while the page scrolls under, wearing the
   command palette's filter-bar treatment: a blurred glass wash of the page bg,
   --space-sm vertical padding, bled to the measure edge by negative margins
   that are paid back as padding (so the resting geometry is untouched). */
.masthead-top {
  display: grid;
  /* Three cells on one row: name · / · tools. The opener's typed preview grows
     in the middle (auto) track; the trailing 1fr soaks the slack and pushes the
     tools hard right. .masthead-bar is display:contents (below), so the name and
     tools promote into this grid alongside the opener and land by grid-column. */
  grid-template-columns: auto auto 1fr;
  /* Baseline aligns the name with the slash chip (a unit); the tool chips
     re-center themselves (align-self, below). */
  align-items: baseline;
  position: sticky;
  top: 0;
  z-index: 2;
  margin: calc(-1 * var(--space-sm)) calc(-1 * var(--space-lg));
  padding: var(--space-sm) var(--space-lg);
  background: var(--glass);
  -webkit-backdrop-filter: blur(var(--blur-glass));
  backdrop-filter: blur(var(--blur-glass));
}
/* The bar collapses into the grid on desktop so name + tools share the sticky
   row with the `/`; on narrow screens it becomes a real row of its own (see the
   breakpoint), and .masthead-top goes display:contents instead. */
.masthead-bar {
  display: contents;
}
.masthead-tools {
  grid-row: 1;
  grid-column: 3;
  justify-self: end;
  align-self: center;
}
/* Dev only: the draft banner owns the very top (sticky, z-100). Drop the
   masthead bar to sit just below it so it isn't hidden under the banner once
   the page scrolls. Production never renders the banner, so this never fires. */
body:has(.draft-flag) .masthead-top {
  top: calc(1lh + 2 * var(--space-3xs));
}
.name {
  font-size: var(--text-base);
  font-weight: var(--weight-bold);
  margin: 0;
}
/* Wraps the name (the tools sit beside it in .masthead-bar). */
.masthead-id {
  grid-row: 1;
  grid-column: 1;
  display: flex;
  align-items: baseline;
}
/* The opener — rests *reversed*: the name's fuchsia as a filled chip, the `/`
   knocked out white and blinking like an idle terminal cursor (a rapid fade
   each way, holds between — see cursor-blink). On hover/focus it steadies the
   cursor and grows to faux-type a rotating sample of real tags and titles
   (.cmd-type, filled by site.js) — a live preview of the palette in place of a
   static tooltip; it's already inverted, so nothing else changes. */
.cmd-open {
  /* Middle grid cell — between the name and the right-aligned tools, a typed
     space off the name. grid-row pins it onto the one masthead row: its DOM
     order is after the (col 3) tools, so sparse auto-placement would otherwise
     drop it to a phantom second row. */
  grid-row: 1;
  grid-column: 2;
  margin-inline-start: var(--gap-inline);
  --accent: var(--mark);
  /* Already inverted at rest — pin hover to the same fill, or the lighter
     hover layer would repaint the chip. */
  --accent-hover: var(--mark);
  /* The chip's fill reaches --chip-pad past the text on each side, via two side
     box-shadow copies — a roomy resting padding (a touch wider than the generic
     --link-pad-x). Named --chip-shadow so the scrolled-idle shed can drop it to
     none and the restore bring it back. */
  --chip-pad: 0.45ch;
  --chip-shadow:
    calc(-1 * var(--chip-pad)) 0 0 var(--accent),
    var(--chip-pad) 0 0 var(--accent);
  background: var(--accent);
  color: var(--mark-fg);
  border-radius: var(--radius-sm);
  box-shadow: var(--chip-shadow);
  white-space: nowrap; /* the typed preview grows on one line */
  /* The scroll shed/restore fades the chip in/out — never snaps. */
  transition:
    background-color var(--duration-fast) var(--ease),
    box-shadow var(--duration-fast) var(--ease),
    color var(--duration-fast) var(--ease);
}
/* The typed preview — placeholder-styled (italic, dimmed knockout) so it reads
   as a sample query, not a label. Empty at rest (chip is just the `/`); a typed
   character earns the gutter space after the prompt. */
.cmd-type {
  font-style: italic;
  opacity: 0.6;
}
.cmd-type:not(:empty) {
  margin-left: var(--gap-inline);
}
/* Once the bar is stuck over content and the masthead is idle (no hover, no
   focus within), the opener sheds its chip entirely — no fill, no box-shadow —
   leaving just the bare fuchsia `/` blinking on the page (the rest-state
   cursor-blink keeps running). Engaging the masthead anywhere (its rows are
   <main>'s children, since .masthead is display: contents) restores the chip.
   Desktop only: on a phone the opener is its own plain glass bar with no
   chip to shed or restore — and the restore's :focus-within would otherwise
   fire (and outrank the mobile rule) the moment the bar is tapped. */
@media (min-width: 34.0625rem) {
  .is-scrolled .cmd-open {
    background: none;
    box-shadow: none;
    color: var(--mark);
  }
  main:has(
      .masthead-top:hover,
      .masthead-top:focus-within,
      .bio:hover,
      .masthead-nav-wrap:hover,
      .masthead-nav-wrap:focus-within
    )
    .cmd-open {
    background: var(--accent);
    box-shadow: var(--chip-shadow);
    color: var(--mark-fg);
  }
}
/* The opener trades its tooltip for the typed preview — keep the .ico-title
   text for assistive tech + forced colors, but never pop it as a chip. */
.cmd-open:is(:hover, :focus-visible) .ico-title {
  opacity: 0;
  transform: none;
}
.cmd-blink {
  animation: cursor-blink var(--duration-blink) infinite;
}
/* Steady cursor: reached for (hover/focus), or held visible for a
   view-transition capture (.is-steady, set by site.js around open/close). */
.cmd-open:is(:hover, :focus-visible, .is-steady) .cmd-blink {
  animation: none;
}
/* Opening/closing the palette, the slash *travels* between its two homes —
   the masthead chip and the filter prompt — via a same-document view
   transition typed 'palette' (site.js). Each state names exactly one slash
   (the :has() flip below), so the browser pairs old with new and animates
   the move; the rest of the page rides the default root crossfade, held to
   --duration-fast. The travel is transform motion, so the group gets the
   spring. Browsers without typed view transitions, and reduced motion, swap
   instantly (site.js falls back). */
.cmd-open {
  view-transition-name: cmd-slash;
}
body:has(.cmd[open]) .cmd-open {
  view-transition-name: none;
}
.cmd[open] .cmd-prompt {
  view-transition-name: cmd-slash;
}
:root:active-view-transition-type(palette)::view-transition-old(root),
:root:active-view-transition-type(palette)::view-transition-new(root) {
  animation-duration: var(--duration-fast);
}
:root:active-view-transition-type(palette)::view-transition-group(cmd-slash),
:root:active-view-transition-type(palette)::view-transition-old(cmd-slash),
:root:active-view-transition-type(palette)::view-transition-new(cmd-slash) {
  animation-duration: var(--duration-med);
}
:root:active-view-transition-type(palette)::view-transition-group(cmd-slash) {
  animation-timing-function: var(--ease-spring);
}
/* The slash carries its name at rest, so *theme* and *font* flips capture it
   as its own group too (a stationary one). Hold it to the root fade's tempo so
   the chip can't trail the page. */
:root:active-view-transition-type(theme)::view-transition-group(cmd-slash),
:root:active-view-transition-type(theme)::view-transition-old(cmd-slash),
:root:active-view-transition-type(theme)::view-transition-new(cmd-slash),
:root:active-view-transition-type(font)::view-transition-group(cmd-slash),
:root:active-view-transition-type(font)::view-transition-old(cmd-slash),
:root:active-view-transition-type(font)::view-transition-new(cmd-slash) {
  animation-duration: var(--duration-fast);
}
/* On (hold) → fast fade out → off (hold) → fast fade back. Weighted toward
   visible: the on hold is 64% of the cycle (~1.28s), the off hold 24% (~480ms),
   each fade still 6% (~--duration-fast) — so the `/` reads as mostly-present
   with a quick wink, the switches stay snappy, never a strobe. */
@keyframes cursor-blink {
  0%,
  64% {
    opacity: 1;
  }
  70%,
  94% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}
/* The universal reduced-motion rule would leave this looping at 0.01ms — a
   flicker, worse than the blink. Stop it outright; the chip stays steady. */
@media (prefers-reduced-motion: reduce) {
  .cmd-blink {
    animation: none;
  }
}
.social {
  display: flex;
  align-items: center;
  /* rem, not the ch --gap-inline: this cluster holds its geometry static across
     a font-family toggle (see --tool-size); ch would shift with the family. */
  gap: var(--space-xs);
}
/* Icon-only links — bump the glyph up a touch for presence. */
.social .ico,
.theme-toggle .ico {
  width: 1.2em;
  height: 1.2em;
  vertical-align: -0.25em;
}
/* Textmode social labels — under either terminal tube the GitHub/LinkedIn marks
   become real bracketed text, the way a TUI labels its controls: [gh] / [in].
   The .ico span drops its mask + fixed box and shows a ::before instead;
   currentColor carries it, so it still inverts with the link, and the chip's
   square floor + centring (the masthead-tools rule) hold the layout. The
   .ico-title tooltip keeps the real accessible name. The theme toggle keeps its
   crafted sun/moon/terminal glyph (scoped to .social, not the whole tools row),
   and light/dark keep the SVG brand marks. */
:root[data-theme^="phosphor"] .social .ico {
  -webkit-mask: none;
  mask: none;
  background: none;
  width: auto;
  height: auto;
  vertical-align: baseline;
}
:root[data-theme^="phosphor"] .ico-github::before {
  content: "[gh]";
}
:root[data-theme^="phosphor"] .ico-linkedin::before {
  content: "[in]";
}
/* Social links | theme toggle — right of the name on every page; drops to its
   own left-aligned line (as a unit) on narrow screens via the masthead grid. */
.masthead-tools {
  display: flex;
  align-items: center;
  /* rem gap (not the ch --gap-inline) so the whole cluster — boxes and the
     divider both — keeps a fixed footprint when the font family toggles. */
  gap: var(--space-xs);
}
.masthead-sep {
  color: var(--muted);
  /* Fixed footprint, centered: the pipe's glyph advance differs between the
     mono and serif families, so a bare glyph here would shift the toggles on a
     font swap. A fixed box absorbs the variance — the glyph re-centers in place. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  inline-size: var(--space-2xs);
}
.bio {
  margin: var(--space-2xs) 0 0;
}
.masthead-back {
  margin: 0;
}
/* Global nav + subject-area tags, one line under the bio on every page. A
   single non-wrapping row that scrolls horizontally once the tag list outgrows
   the measure — overlay scrollbars (mac/ios) surface only while scrolling, so
   the row rests calm. */
/* The wrap anchors the pinned "all tags" link over the scrolling row. */
.masthead-nav-wrap {
  position: relative;
  margin-top: var(--space-lg); /* a blank line's breath below the bio */
  /* The masthead's box-bottom gap, relocated here since .masthead is now
     display: contents (no box of its own) — this is the masthead's last row. */
  margin-bottom: var(--gap-section);
}
.masthead-nav {
  display: flex;
  flex-wrap: nowrap;
  align-items: baseline;
  gap: var(--gap-inline);
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  /* overflow-x clips the first link's hover/focus chip on the left edge (its
     box-shadow side copies bleed --link-pad-x past the text) — so pad the
     inline-start by that bleed and pay it back as negative margin, keeping the
     resting geometry put while giving the chip room (the TOC list's trick). */
  padding-inline-start: var(--link-pad-x);
  margin-inline-start: calc(-1 * var(--link-pad-x));
}
/* "all tags" — pinned to the right, riding over the scrolling row as its
   overflow destination. A left-edge fade (the start padding) dissolves the
   scrolling tags into the page bg as they pass beneath, so the row reads
   clean to the very edge. Resting muted with the tag intent on hover — it
   belongs to the tag list it caps.

   Shown only when the row actually overflows: pure CSS can't yet detect
   overflow across browsers, so site.js sets .nav-overflows on the wrap (a
   ResizeObserver, no scroll/poll) when the tags outrun the measure. Hidden by
   default, so without JS the row simply scrolls to reach every tag. When
   shown, the row reserves end padding so its last tag can clear the overlay. */
.masthead-nav-all {
  display: none;
  position: absolute;
  inset-block: 0;
  inset-inline-end: 0;
  padding-inline-start: var(--space-xl);
  background: linear-gradient(to right, transparent, var(--bg) var(--space-xl));
}
.masthead-nav-wrap.nav-overflows .masthead-nav-all {
  display: block;
}
.masthead-nav-wrap.nav-overflows .masthead-nav {
  padding-inline-end: var(--masthead-nav-all-w);
}
.masthead-nav-all a {
  --link-fg: var(--muted);
  --accent: var(--accent-tag);
  --accent-hover: var(--accent-tag-light);
  white-space: nowrap;
}
/* Section links lead the row as violet links (underlined, same weight as the
   tags) — global nav resting in the category hue like the section heads they
   point at, set apart from the muted #tags that follow by colour and the
   absent #. Each item holds its own line so the row scrolls as one piece. */
.masthead-nav .nav-link {
  --link-fg: var(--accent-category-text);
}
/* The active category — the section the current page lives in (its index, an
   entry within it, or a career page). Bold like the section heads it points
   at, so the nav itself is the "you are here" indicator and the per-page
   eyebrow head can go. Monospace bold keeps the advance width, so the row
   never reflows. */
.masthead-nav .nav-link[aria-current] {
  font-weight: var(--weight-bold);
}
/* The active tag — on its own /tags/<term>/ page. Bold and resting steel (its
   own intent, stepped up as text), so it stands out of the muted tag row the
   way the active section stands out of the nav. The h1 still carries the
   reliable indicator; this echoes it when the tag is in view. */
.masthead-nav .tag[aria-current] {
  --link-fg: var(--accent-tag-text);
  font-weight: var(--weight-bold);
}
/* Half a cell of extra air between consecutive section links (1.5ch total with
   the flex gap) — a touch more breath than the tag row, still tighter than the
   3ch break to the tag list that follows. */
.masthead-nav .nav-link + .nav-link {
  margin-left: var(--gap-dir);
}
/* An extra cell of air between the last nav link and the first tag, so the
   nav reads as its own group ahead of the tag list. */
.masthead-nav .nav-link + .tag {
  margin-left: var(--indent-sub);
}
.masthead-nav > * {
  white-space: nowrap;
}

@media (max-width: 34rem) {
  /* Less air over the masthead — the desktop --space-3xl reads as a vast empty
     band on a phone. main goes flex so the promoted masthead pieces can reorder
     (below): the `/` opener drops between the bio and the nav row while staying
     a child of the page-tall <main> that anchors its sticky. Every masthead +
     section margin is single-sided, so flex (which doesn't collapse margins)
     keeps the exact same rhythm; content sections (order 0) stay after the nav. */
  main {
    padding-top: var(--space-lg);
    display: flex;
    flex-direction: column;
  }
  .masthead-bar {
    order: -4;
  }
  .bio {
    order: -3;
    /* A blank line's breath under the name row. */
    margin-top: var(--space-lg);
  }
  .cmd-open {
    order: -2;
  }
  .masthead-nav-wrap {
    order: -1;
  }
  /* The header splits its rows. .masthead-top stops being a box so its children
     promote into <main> as flex items: the bar (name + tools) and the `/` bar.
     Static position so the is-scrolled reader (site.js) knows it isn't sticky. */
  .masthead-top {
    display: contents;
    position: static;
  }
  /* Name left, tools hard right — one non-sticky row. */
  .masthead-bar {
    display: grid;
    grid-template-columns: 1fr auto;
    align-items: center;
    column-gap: var(--space-md);
  }
  .masthead-tools {
    grid-column: 2;
  }
  .cmd-open:not([hidden]) {
    display: block;
  }
  /* The `/` opener — its own full-width sticky bar between the bio and the nav,
     the only sticky part of the header. The whole row is the tap target into the
     palette; full-bleed glass wash (negative margins reach the viewport edge,
     paid back as padding). It stays a plain search prompt in *every* state — no
     hover/focus/active/roving invert (a phone tap would otherwise flip it to a
     fuchsia chip, the funky reversed look).

     Selectors are scoped to `.masthead-top` to clear the global
     `:is(a, .btn-link):has(.ico-title)` anchor rule (0,2,0), which forces
     position:relative for the desktop tooltip — at this breakpoint the opener
     has no tooltip (its label is inline) and must *stick*, not scroll away with
     the page. Without the bump the resting `.cmd-open` (0,1,0) loses and the bar
     silently un-sticks. */
  .masthead-top .cmd-open,
  .masthead-top .cmd-open:is(:hover, :focus-visible, :active, .is-active) {
    grid-column: auto;
    margin: var(--space-md) calc(-1 * var(--space-lg)) 0;
    padding: var(--space-sm) var(--space-lg);
    position: sticky;
    top: 0;
    z-index: 2;
    text-align: left;
    background: var(--glass);
    -webkit-backdrop-filter: blur(var(--blur-glass));
    backdrop-filter: blur(var(--blur-glass));
    border-radius: 0;
    box-shadow: none;
    color: var(--mark);
    text-decoration-line: none;
    outline: none;
    transition: none;
  }
  /* Dev only: clear the draft banner so the bar isn't tucked under it. */
  body:has(.draft-flag) .cmd-open {
    top: calc(1lh + 2 * var(--space-3xs));
  }
  /* The typed preview is a pointer delight; on this bar just show the static
     "jump to…" label inline after the `/`, muted like a placeholder — held in
     every state so a tap never knocks it out. */
  .cmd-open .cmd-type {
    display: none;
  }
  .cmd-open .ico-title,
  .cmd-open:is(:hover, :focus-visible, :active, .is-active) .ico-title {
    position: static;
    opacity: 1;
    transform: none;
    translate: none;
    margin-inline-start: var(--gap-inline);
    padding: 0;
    background: none;
    color: var(--muted);
    font-size: inherit;
  }
  /* Drop the slash-travel morph here. On a phone the opener is a full-width bar,
     so the shared `cmd-slash` snapshot is a big rectangle that the view
     transition zooms down to the tiny dialog prompt — reading as a swoop "from
     some huge unknown place." Unnaming both ends leaves only the plain root
     crossfade (held to --duration-fast by the 'palette' type), so the palette
     just fades in. The desktop morph (where opener and prompt are both small
     `/` chips) is untouched. */
  .cmd-open,
  .cmd[open] .cmd-prompt {
    view-transition-name: none;
  }
}

/* Command palette (baseof.html, opened by site.js) — a <dialog> that covers
   the page with a flat sheet of the page bg: no border, no chrome, just the
   filter field and the page list sitting where the content was. The inner box
   shares main's measure + padding so the field lands roughly where the name
   sits. Esc closes natively; site.js closes on a click that lands on nothing. */
.cmd {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  margin: 0;
  border: 0;
  padding: 0;
  background: var(--bg);
  color: var(--fg);
  overflow-y: auto;
  overscroll-behavior: contain;
  /* The filter field sticks to the top (below) — pad programmatic
     scroll-into-view (the roving arrows) so a row surfacing at the top
     lands under air, not under the bar. 1lh + the bar's two pads. */
  scroll-padding-top: calc(1lh + 2 * var(--space-sm));
}
/* Both dialog sheets (.cmd, .lightbox) fade in on open — one plain fade-in
   animation, robust everywhere. Closing is instant, like navigation. The
   sheets paint the full viewport themselves, so the backdrops stay
   transparent (UA default) and the page crossfades behind the arriving
   sheet. (A symmetric closing fade via allow-discrete/overlay/
   @starting-style was tried and removed — three stacked cutting-edge
   features, and Safari painted a transparent sheet. Don't reintroduce.) */
:is(.cmd, .lightbox)[open] {
  animation: fade-in var(--duration-fast) var(--ease);
}
/* Lock the underlying page while any modal sheet is open — one shared
   primitive for both dialogs (and any future one). The lightbox is a flex
   box, not a scroll container, so its own overscroll-behavior can't catch a
   wheel/keyboard scroll — it chains to the root. Killing the root scrollport
   stops the leak at the source. scrollbar-gutter: stable (on html) keeps the
   gutter reserved, so width doesn't jump; scroll position is preserved. Both
   sheets are JS-opened, so no-JS never reaches this. */
html:has(dialog[open]) {
  overflow: hidden;
}
.cmd-box {
  max-width: var(--measure);
  margin-inline: auto;
  padding: var(--space-3xl) var(--space-lg);
}
/* On a phone the sheet drops its tall lead so the filter sits up near the top,
   matching the masthead's reduced top air. (Placed after the base rule so it
   wins on source order — both are single-class, equal specificity.) */
@media (max-width: 34rem) {
  .cmd-box {
    padding-top: var(--space-lg);
  }
}
/* The filter field — a bare input behind a `/` prompt, like a terminal
   search, all in the opener's fuchsia: bold prompt, fuchsia value and caret,
   the placeholder in the family's -light step. The caret is the focus
   indicator; there's nothing else to focus. */
.cmd-field {
  display: flex;
  align-items: baseline;
  /* Prompt + one typed space = the rows' two-space gutter: the `/` sits one
     typed space into the gutter, the input's first character on the
     parent-title column. */
  gap: var(--gap-inline);
  /* Sticky at the sheet's top while the list scrolls, on a mostly-opaque
     blur of the page bg (--glass) so rows slide under it legibly. The
     negative margins are paid back as padding: resting geometry is
     untouched, but the chrome carries its own breathing room when stuck
     and its wash bleeds into the box's gutters, covering the full strip. */
  position: sticky;
  top: 0;
  z-index: 1;
  margin: calc(-1 * var(--space-sm)) calc(-1 * var(--space-lg))
    calc(var(--space-lg) - var(--space-sm));
  padding: var(--space-sm) var(--space-lg) var(--space-sm)
    calc(var(--space-lg) - var(--indent-sub));
  background: var(--glass);
  -webkit-backdrop-filter: blur(var(--blur-glass));
  backdrop-filter: blur(var(--blur-glass));
}
.cmd-prompt {
  color: var(--mark);
  font-weight: var(--weight-bold);
}
.cmd-input {
  /* type=search (it exempts the field from contact autofill) — shed the
     UA's search dressing so it stays a bare line of text. */
  appearance: none;
  font: inherit;
  color: var(--mark);
  background: none;
  border: 0;
  padding: 0;
  outline: none;
  flex: 1;
  caret-color: var(--mark);
}
.cmd-input::placeholder {
  color: var(--mark-light);
  opacity: 1;
}
.cmd-input::-webkit-search-cancel-button,
.cmd-input::-webkit-search-decoration {
  display: none;
}
/* The close link — the input's flex:1 carries it to the field's right edge
   (above the rows' tag column), baseline-aligned with the prompt. Same muted
   resting + link-invert treatment as the lightbox's close, all via btn-link. */
.cmd-close {
  --link-fg: var(--muted);
}
/* Each line is a site .row — page link left, its tags right as real links,
   wrapping below when narrow. The main link keeps the standard resting
   underline (it inherits the base `a` rule — the dir prefix and tags already
   wear theirs, so the title joining them reads as one consistent column of
   links). Landmark pages (Home, the categories, Career, Tags) are bold —
   weight, not color. The keyboard-active link (.is-active, set by site.js)
   wears the same invert as hover, via the shared rule up top. */
.cmd-line[hidden] {
  display: none;
} /* .row's display: flex would beat [hidden] */
/* The key hint above the first row — Bubbles-style help: just the arrows,
   dim and small. Each is a real button that steps the selection (site.js),
   so the help line is honest: press it or click it. */
.cmd-hint {
  display: flex;
  gap: var(--gap-inline);
  font-size: var(--text-sm);
  margin: 0 0 var(--space-2xs);
}
.cmd-step {
  --link-fg: var(--muted);
}
/* At a selection limit a step arrow disables itself — dimmed and inert, no
   hover invert (pointer-events off), so the help line stays honest about where
   the selection can still travel: ↑/↓ in the top/bottom row, ←/→ on a row's
   first/last link (or anytime focus rests in the filter). */
.cmd-step:disabled {
  opacity: 0.45;
  cursor: default;
  pointer-events: none;
  text-decoration-line: none; /* a dead control isn't a link */
}
/* Section entries and tag pages indent under their landmarks — a TUI
   tree, two typed spaces. */
.cmd-sub {
  padding-left: var(--indent-sub);
}
.cmd-top {
  font-weight: var(--weight-bold);
}
.cmd-dir {
  color: var(--muted);
  margin-right: var(--gap-dir);
}
/* The dir prefix is redundant while browsing — the section landmark heads the
   group right above it. So show it only while filtering, when the landmarks
   drop out and the prefix becomes the row's only section signal (site.js sets
   .is-filtering on the dialog; matching still reads dataset.text, so a hidden
   prefix keeps scoping the list). The palette is JS-only — a closed dialog
   renders nothing — so there's no no-JS path to lose the prefix on. */
.cmd:not(.is-filtering) .cmd-dir {
  display: none;
}
.cmd-row:is(:hover, :focus-visible, .is-active) .cmd-dir {
  color: inherit;
}
/* "You are here" — the page the palette opened from (aria-current, baked at
   build time) rests on a quiet surface chip: the inline-code geometry
   (background + side copies) at the surface's deep step — code's 6% was
   too subtle for a you-are-here — still neutral, nothing like the vivid
   accent fills. :where() keeps this rule's specificity under a:hover, so the
   interactive inverts above — hover, focus, click, the roving selection —
   simply win the cascade and the chip vanishes beneath them. (A muted `→`
   cursor hung in the gutter was the first take — too obtuse; this replaced
   it.) Forced colors strips tint fills — a real border marks the row there,
   the code chip's precedent. */
.cmd-row:where([aria-current]) {
  background: var(--surface-deep);
  border-radius: var(--radius-sm);
  box-shadow:
    calc(-1 * var(--link-pad-x)) 0 0 var(--surface-deep),
    var(--link-pad-x) 0 0 var(--surface-deep);
}
@media (forced-colors: active) {
  .cmd-row[aria-current] {
    border: var(--border-thin) solid;
  }
}
.cmd-empty {
  color: var(--muted);
  margin: 0;
}

/* Photo preview pane — fzf --preview made literal. On wide viewports the
   gutter left of the measure previews the active row's entry: site.js
   fetches its thumb after a short dwell (the fetch-spray guard — held-down
   arrows never fire a request) and swaps it in once loaded, the current
   photo holding the pane until then. Two instances of one partial
   (preview-pane.html): the palette's, inside its dialog, and the page's at
   body level — every list page walks its rows with the palette's selection
   grammar and previews here. The hero wears the plain .shots
   frame; when the entry has more photos, two more sit in a tight
   secondary row beneath it. Geometry is fixed — every slot crops to
   --preview-ratio — so the pane is one consistent shape and layout
   never jumps. The whole pane is one link to the
   page (click navigates; tabindex -1 keeps the aria-hidden duplicate out
   of the tab order). Hidden below the threshold — fzf hides its preview
   in narrow windows too — hero-only in the middle band (75–88rem, where
   the gutter fits a photo but not a grid), and aria-hidden always. site.js
   aligns the pane's top with its row, nudged up half a line, clamped
   inside the viewport (the top here is just the pre-JS resting value),
   and re-tracks it as the list scrolls. Career rows bake no preview data — their content is gated at the
   hosting layer. (Tried and removed, in order: a pixelated resolve, an
   accent ring, empty stack cards twice, a second photo peeking from
   below. The hero + secondary grid won.) */
.preview {
  display: none;
}
@media (min-width: 75rem) {
  .preview {
    display: block;
    position: fixed;
    top: var(--space-3xl);
    right: calc(50% + var(--measure) / 2 + var(--space-lg));
    width: min(
      var(--preview-w),
      calc(50vw - var(--measure) / 2 - 2 * var(--space-lg))
    );
    margin: 0;
    visibility: hidden;
    /* position:fixed makes the pane its own stacking context, which isolates
       its images' terminal-theme mix-blend-mode: hard-light — with nothing
       behind them in the group they'd blend against transparency and read pale
       and washed, unlike the article/lightbox photos that blend against the
       dark page. Painting the page bg here (invisible in every theme — it's the
       same colour the gutter already shows) restores the backdrop so the
       greenscreen matches everywhere. */
    background: var(--bg);
  }
  .preview.has-photo {
    visibility: visible;
  }
  .preview-link {
    display: block;
    /* The pane is photos + a caption, never a text link — kill the resting
       underline at the source. A descendant can't cancel an ancestor's
       propagated decoration, so the title's underline has to die here. */
    text-decoration-line: none;
  }
  .preview-link:is(:hover, :focus-visible, :active) {
    background: none;
    box-shadow: none;
  }
  /* The previewed page's name above the hero — fzf's preview header. Muted
     and small like a lightbox caption, but left-aligned to the gutter and
     clipped to one line so a long title never wraps the pane taller. It's a
     caption, not a link — the link's underline is killed above, and the hover
     override holds it muted so the invert never knocks it out. */
  .preview-title {
    display: block;
    margin-bottom: var(--space-2xs);
    color: var(--muted);
    font-size: var(--text-sm);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .preview-link:is(:hover, :focus-visible, :active) .preview-title {
    color: var(--muted);
    background: none;
  }
  /* Fixed geometry: every slot crops to --preview-ratio (cover), so
     the pane is one consistent shape whatever the photos' own dimensions —
     a portrait phone capture and a wide landscape land identically. */
  .preview img {
    display: block;
    width: 100%;
    aspect-ratio: var(--preview-ratio);
    object-fit: cover;
    outline: var(--border-rim) solid var(--photo-edge);
    border-radius: var(--radius-sm);
  }
  /* The secondary row — two more picks in equal columns, same crop. */
  .preview-more {
    display: none;
    margin-top: var(--space-2xs);
    gap: var(--space-2xs);
  }
  .preview-more img {
    width: auto;
    min-width: 0;
    flex: 1 1 0;
  }
  .preview-more img[hidden] {
    display: none;
  }
}
/* The middle band (the pane fits, a grid doesn't): hero only. The
   secondary row joins once the gutter has real room. */
@media (min-width: 88rem) {
  .preview.has-more .preview-more {
    display: flex;
  }
}

/* Table of contents — the right-gutter mirror of the left preview pane: a fixed
   sidebar, desktop-only (the same ≥75rem the preview appears at), holding the
   page's heading links. It stays put as the prose scrolls; site.js lights the
   active section (.is-active). Hidden below the breakpoint — a phone reads the
   prose, the preview-pane precedent. */
.toc {
  display: none;
}
@media (min-width: 75rem) {
  .toc {
    display: block;
    position: fixed;
    /* Drop the first item onto the article's first *body* line, not the title
       row: main's top padding (--space-3xl) lands at the title, so add the title
       block below it — one title line (--leading-normal × the base size, the
       .row-pinned leading) + the seam to the prose (--space-md, the first
       paragraph's collapsed top margin). */
    top: calc(var(--space-3xl) + var(--leading-normal) * 1rem + var(--space-md));
    /* left edge just right of the centred measure — the preview pane's right
       formula, reflected across centre into the opposite gutter. */
    left: calc(50% + var(--measure) / 2 + var(--space-lg));
    width: min(
      var(--toc-w),
      calc(50vw - var(--measure) / 2 - 2 * var(--space-lg))
    );
    max-height: calc(100vh - var(--space-3xl) - var(--space-lg));
    overflow-y: auto;
    /* overflow-y forces overflow-x to clip, which would cut a link's hover chip
       (its box-shadow side copies bleed --link-pad-x past the text) at the left
       edge — so pad the inline axis and pay it back as negative margin, keeping
       the resting geometry put while giving the chips room (the masthead bar's
       trick). */
    padding-inline: var(--space-2xs);
    margin-inline: calc(-1 * var(--space-2xs));
    font-size: var(--text-sm);
    line-height: var(--leading-snug);
    scrollbar-width: thin; /* overlay scrollbar, the masthead-nav precedent */
  }
  /* The title item rides .toc-list; the headings come from
     .Fragments.ToHTML's <nav id="TableOfContents"><ul>. Both share one look. */
  .toc-list,
  .toc #TableOfContents ul {
    list-style: none;
    margin: 0;
    padding: 0;
  }
  /* h3s indent one TUI step under their h2 (the palette nested-row indent). */
  .toc #TableOfContents ul ul {
    margin-left: var(--indent-sub);
  }
  .toc li {
    margin: var(--space-3xs) 0;
  }
  /* Quiet by default: muted resting text via --link-fg (so the global hover/focus
     invert still knocks out cleanly). No resting underline — the sidebar is a
     pure list of links, so the muted tone carries it and the active invert does
     the rest (the palette/list-row precedent). The active section reads in the
     heading's own weight + full fg — hierarchy from weight, not colour. */
  .toc a {
    --link-fg: var(--muted);
    text-decoration-line: none;
  }
  .toc a.toc-current {
    --link-fg: var(--fg);
    font-weight: var(--weight-bold);
  }
  /* The Related jump (toc.html) — a blank line below the heading tree, a link to
     the footer Related / "More in" section (whichever the page has). */
  .toc-related {
    margin-top: var(--space-sm);
  }
}

/* Index sections */
.index-section {
  margin-bottom: var(--gap-section);
}

.section-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-md);
  margin-bottom: var(--space-sm);
  line-height: var(
    --leading-normal
  ); /* pin against .post's relaxed leading —
     heads sit at the same position on every page, list or article */
}
/* Section titles rest in the heading hue, no underline. --link-fg covers the
   linked ones (hover invert still wins); color covers the plain <span> on a
   category's own page. */
.section-title {
  font-weight: var(--weight-bold);
  color: var(--accent-category-text);
  --link-fg: var(--accent-category-text);
  text-decoration-line: none;
}

.section-intro {
  color: var(--muted);
  margin: calc(-1 * var(--space-3xs)) 0 var(--space-md);
}

.row {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-3xs) var(--gap-inline);
  padding-block: var(--space-3xs);
  line-height: var(
    --leading-normal
  ); /* a list row, never prose — pin against
     .post's relaxed leading so a row's hover/focus chip (its line box) is the
     same height in an article as on a list page, and matches its head */
}
/* The roving walk (site.js) scrollIntoViews the selected line — keep it off
   the viewport edge. Page lines only (rows, linked heads, More): the
   palette's container carries its own scroll-padding (the sticky filter
   bar). */
main :is(.row, .section-head, .more) {
  scroll-margin-block: var(--space-lg);
}
/* The seam below a head belongs to the head's margin: the first row sheds its
   top padding so head → first text is --space-sm exactly, matching the detail
   pages' title row (already padding-less). Covers list rows and job-rows
   alike; the palette has no section-head, so its rhythm is untouched. */
.section-head + .row {
  padding-block-start: 0;
}
.row .meta {
  color: var(--muted);
  white-space: nowrap;
  /* Stay hard right even when the row is too narrow for both and the cluster
     wraps to its own line — margin auto pushes it to the line end instead of
     letting flex-wrap drop it flush-left, so years/tags keep their column. */
  margin-left: auto;
}

.more {
  margin-top: var(--space-xs);
  line-height: var(--leading-normal);
} /* list grammar — pin against .post leading, like .row */

/* Career rows: company | title (right). On narrow screens company + title
   stack, left-aligned. */
.job-row {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: baseline;
  gap: 0 var(--space-2xs);
}
.career-acquired {
  color: var(--muted);
}
.career-title {
  text-align: right;
}

@media (max-width: 34rem) {
  .job-row {
    grid-template-columns: 1fr;
  }
  .career-title {
    text-align: left;
    color: var(--muted);
  }
}

/* Tags read muted (a muted-link variant) and keep the faint default underline. */
.tag {
  --link-fg: var(--muted);
  --accent: var(--accent-tag);
  --accent-hover: var(--accent-tag-light);
}
/* Years read like tags — muted at rest, their own intent (orange) on the
   invert. The same class covers the unlinked span a year wears before its
   /years/ stub exists (see partials/year.html). */
.year {
  --link-fg: var(--muted);
  --accent: var(--accent-year);
  --accent-hover: var(--accent-year-light);
}
/* A tag page has no h1 — its label is the bold lead tag in the masthead nav
   (see baseof.html / term.html). A year page still carries one (years aren't in
   the nav, so there's nothing to lead with). */
/* A year page's heading is a word-wrapping time strip: the current year down to
   the last with content, latest first. It mirrors the masthead tag row's
   manners — baseline-aligned, one cell of inline air — but wraps instead of
   scrolling (a year page is a destination, not a glance). */
.year-strip {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-3xs) var(--gap-inline);
  margin: 0 0 var(--space-xl); /* the strip owns the heading's bottom margin */
}
/* A year page's h1 rests in the year intent (orange); inside the strip it sheds
   its own margin (the strip carries it) and sits at its place in time. */
.year-title {
  color: var(--accent-year);
}
.year-strip .year-title {
  margin: 0;
}
/* A year with no content is plain muted text, never a link — no underline. */
.year-empty {
  color: var(--muted);
}
/* The drafts page's h1 rests in the draft intent (red) — dev-only, like the
   marks that link here; the page wears the draft banner besides. */
.draft-title {
  color: var(--accent-draft);
}
/* The row's right-side cluster: tags, then the entry's year — outermost, so
   years stack in a column down the right edge. */
.meta.tags {
  display: inline-flex;
  flex-wrap: wrap;
  gap: var(--space-3xs) var(--gap-inline);
}

/* Page titles — one type size site-wide: every h1 outside the masthead
   (articles, tag pages, /tags/) holds body size in bold fg under its violet
   section head — a row promoted by weight, never a second section head.
   .name carries its own color. */
h1 {
  font-size: var(--text-base);
  color: var(--fg);
  margin: 0 0 var(--space-xl);
}

/* Articles — the category head above the title is the same section-head
   partial the landing pages render, linked back to the category. */
.post h1 {
  margin-bottom: var(--space-3xs);
}
/* The title row (page.html) — h1 left, the entry's tags right, the list-row
   grammar; tags drop to a left-aligned line below when narrow. The row takes
   over the h1's bottom spacing so wrapped tags hug the title. */
.title-row {
  padding-block: 0;
  margin-bottom: var(--space-3xs);
  /* leading is pinned to normal by .row — it's a list row promoted, not prose */
}
.title-row h1 {
  margin-bottom: 0;
}
.post .meta {
  color: var(--muted);
  margin-top: 0;
}
/* Coordinates sit a blank line below the title row, set off from it like a
   dateline (page.html — optional lat/lng). */
.post .meta.coords {
  margin-top: var(--space-md);
}
.post {
  line-height: var(--leading-relaxed);
}
/* Markdown tables (the rare data table — Overland Finder's source list). The UA
   centers th; left-align it so each head sits over its column's cells, the way
   every other row grammar on the site reads left-to-right. Cells top-align too,
   so a wrapped Source (or a stacked Field list) keeps its first line on the
   row's baseline instead of floating to the cell's vertical middle. */
.post :is(th, td) {
  text-align: left;
  vertical-align: top;
}
/* Related rows (page.html) sit a full section seam below the article body. */
.post .index-section {
  margin-top: var(--gap-section);
}

/* The /career umbrella — positioning + arc between the section head and the
   role list. .post carries the leading; the top margin
   is the same block seam the company pages use (.career-arcs), so the outline
   reads as its own zone below the list, not more rows. */
.career-outline {
  margin-top: var(--space-xl);
}

/* Career company pages (/career/<company>/) — the context block under the title:
   the one-liner lede hugs the h1, then the stage/scope/domain arcs stack as
   one muted block (layouts/career/page.html). */
.career-lede {
  margin: 0 0 var(--space-2xs);
}
.career-arcs {
  margin-bottom: var(--space-xl);
}

/* /career sign-in (HLC-56) — the cookie-session gate's login page. A calm form
   that reuses the global field + button grammar (the surface chip, the accent
   invert, the terminal costume), so it tracks all four themes for free; only its
   own layout lives here. */
.login {
  max-width: 48ch;
  margin-block: var(--space-2xl);
  display: flex;
  flex-direction: column;
}
/* Reset children's block margins so the rhythm is set here (in a flex column the
   global h1/p margins would add to any gap instead of collapsing). */
.login > * {
  margin: 0;
}
.login-h {
  font-weight: var(--weight-bold);
}
.login-form {
  margin-top: var(--space-lg);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-xs);
}
/* A plain field label, on its own line above the controls. */
.login-label {
  color: var(--muted);
}
/* Input + enter on one row, filling the measure. */
.login-controls {
  display: flex;
  align-items: center;
  gap: var(--gap-inline);
  align-self: stretch;
}
.login-controls input {
  flex: 1 1 18ch;
  min-width: 0;
}
/* The incorrect-password note: the error intent (red), normal weight — one of the
   few places the calm gives way to colour. index.js reveals it on ?error=1;
   without JS it stays hidden and the form simply reappears for another try. */
.login-error {
  margin-top: var(--space-sm);
  color: var(--accent-error-text);
}

/* The heading ladder — one type size site-wide (AGENTS Aesthetic), so the
   rungs separate by weight, *style*, and colour, never size (Harlan's call,
   2026-06-15). The article h1 rests bold upright fg (a row promoted by
   weight). h2 — and the index-section subheads (Related, "More in #term")
   that share its rung — turn italic: bold italic fg. h3 drops the weight
   (italic fg, regular), h4 the colour too (italic muted). */
h2,
.subhead {
  font-size: var(--text-base);
  line-height: var(--leading-tight);
  font-weight: var(--weight-bold);
  font-style: italic;
  color: var(--fg);
}
h2 {
  margin-top: var(--space-xl);
}
h3,
h4 {
  font-size: var(--text-base);
  line-height: var(--leading-tight);
  font-style: italic;
  font-weight: var(--weight-normal); /* drop the UA heading bold — h3/h4 separate by style + colour, not weight */
  margin-top: var(--space-xl);
}
h3 {
  color: var(--fg);
}
h4 {
  color: var(--muted);
}
/* A subhead is a list head (Related, tag-groups), never a wrapping heading:
   rest it at the list leading (like .section-title) so its hover/focus chip is
   the same height as the rows it heads. The link still inverts (--link-fg). */
.subhead {
  --link-fg: var(--fg);
  text-decoration-line: none;
  line-height: var(--leading-normal);
}
/* The tag-groups head is split: a calm "More in" prefix (plain --fg, no link)
   + #term as the lone link, which rests in the tag steel (--accent-tag,
   matching the head's bold-italic weight/style) and inverts in the tag intent
   like any #tag. .has-prefix re-flows the head left (the prefix and the link
   are siblings, not the usual single child) with a one-cell gap. */
.section-head.has-prefix {
  justify-content: flex-start;
  gap: var(--gap-inline);
}
/* .subhead sets `color` directly (it must, for the non-link spans — Related,
   the prefix), which beats `a { color: var(--link-fg) }` on specificity. So
   the tag's rest colour is set the same way — but through :where() so it stays
   at .subhead's own specificity (0,1,0): a plain .subhead.head-tag (0,2,0)
   would also outrank the hover/focus knockout (a:hover, 0,1,1) and strand the
   inverted chip steel-on-steel. */
.subhead:where(.head-tag) {
  color: var(--accent-tag);
}
.subhead.head-tag {
  --accent: var(--accent-tag);
  --accent-hover: var(--accent-tag-light);
}

img {
  max-width: 100%;
  height: auto;
}

/* Photo sets (figure.shots) — justified rows, Google-Photos style. A figure
   is a *set*, not a row: each image's preferred width is its aspect ratio ×
   the target row height (flex-basis), so flex wrap breaks the lines itself —
   fewer images per row as the viewport narrows, no media queries. Within a
   row, flex-grow ∝ --ar stretches to the full measure with heights staying
   level (the ×10 keeps grow sums ≥ 1, so a lone portrait still claims its
   space). **Every row fills the measure** — growth is uncapped (Harlan's
   call, 2026-06-11: no ragged rows, ever), so row heights vary around the
   target: a row that packed loose runs taller, and a photo the packing
   strands alone goes full-bleed — a feature, not a bug. The ratio rides
   --ar, set inline per image from its own pixel dims by the shot-img
   partial (the shots shortcode's render hook, and the hero figure). */
.shots {
  display: flex;
  flex-wrap: wrap; /* figcaption (width: 100%) drops to its own line */
  align-items: flex-start;
  gap: var(--space-sm);
  margin: var(--space-2xl) 0; /* double the prose seam *outside* a run of
     photo rows — air above the first and below the last */
}
/* Consecutive figures read as one set: their seam matches the wrap gap, so
   the gutter stays uniform in both axes (adjacent figures should usually
   just be merged — split only across prose). Margins collapse to the deeper
   one, so the seam is set on both sides of the join. */
.shots:has(+ .shots) {
  margin-bottom: var(--space-sm);
}
.shots + .shots {
  margin-top: var(--space-sm);
}
/* The flex items — child combinators on purpose: post-JS the lightbox
   button is the item and its inner img must not size itself. */
.shots > img,
.shots > picture,
.shots > .lightbox-link {
  min-width: 0;
  flex: calc(var(--ar, 1) * 10) 1 calc(var(--ar, 1) * var(--shots-row));
}
.shots img {
  outline: var(--border-rim) solid var(--photo-edge);
  border-radius: var(--radius-sm);
}
/* An art-directed shot — a <picture> carrying motion/theme sources (the
   still rides the <img> so Hugo can thumb it; animation arrives by <source>
   only when motion is welcome). The picture is the flex item and carries
   --ar inline; its img just fills it. */
.shots picture img {
  width: 100%;
}
/* width: 100% drops the caption to its own flex line. */
.shots figcaption {
  width: 100%;
  color: var(--muted);
  font-size: var(--text-sm);
}

/* Entry hero (HLC-51) — the featured figure at the top of a page. Rides the
   same justified engine as any photo set (1 → full-bleed, 2 → side-by-side,
   3 → packed by ratio, no parallel layout), but sits flush under the title.
   The default is full-bleed: a lone hero fills the measure at natural ratio
   like any stranded body photo — no special rule needed, the base .shots
   flex grows the only item to the full width. The cap is the *exception*
   (.native, below). */
.shots.hero {
  margin-top: var(
    --space-lg
  ); /* flush under the title block, not the double
                                  prose seam a mid-article set gets */
}

/* Native hero (HLC-53) — opt out of the full-measure fill: show the asset at
   its own size, centered, under a height cap so it can't run tall. The rare
   case — a hero that shouldn't stretch the way a photo wants to (a logo, a
   small diagram, a portrait that would otherwise push the prose past the
   fold). `hero_layout: native`. Holds pre-JS (the img is the flex item) AND
   post-JS (site.js wraps it in a .lightbox-link button, which becomes the
   flex item — the img's parent changes, so both selectors are load-bearing). */
.shots.hero.native > img:only-child,
.shots.hero.native > .lightbox-link:only-child {
  flex: 0 1 auto;
  width: auto;
  max-width: 100%;
  max-height: var(--hero-max);
  margin-inline: auto; /* center the contained asset in the measure */
}
.shots.hero.native > .lightbox-link:only-child img {
  width: auto; /* override .lightbox-link img { width: 100% } so the cap drives
                  the size and the ratio is preserved under it */
  max-width: 100%;
  max-height: var(--hero-max);
}

/* Mosaic hero (HLC-52) — the deliberate "1 big left, 2 stacked right"
   arrangement the justified engine can't make. Opt in per entry with
   `hero_layout: mosaic`; off the default path, which stays plain justified
   .shots. A CSS grid with a definite height so the rows split evenly and the
   photos crop to fill (object-fit: cover — the one place a hero crops, on
   purpose). Items are grid cells whether the lightbox has wrapped them
   (.lightbox-link) or not (bare img, no-JS). */
.shots.hero.mosaic {
  display: grid;
  grid-template-columns: 1.2fr 1fr;
  grid-template-rows: 1fr 1fr;
  gap: var(--space-sm);
  height: var(--hero-mosaic-h);
}
.shots.hero.mosaic > img,
.shots.hero.mosaic > .lightbox-link {
  flex: none; /* grid items now — drop the .shots flex sizing */
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  margin: 0;
}
.shots.hero.mosaic > :first-child {
  grid-column: 1;
  grid-row: 1 / 3; /* the big left tile, spanning both rows */
}
.shots.hero.mosaic img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Lightbox (baseof.html, wired by site.js) — the same flat sheet as the
   palette: page bg edge to edge, no border, no chrome. The image floats
   centered wearing the .shots frame; beneath it one quiet pager line —
   ← n/m → — the arrows inverting like any link. JS wraps every shots image
   in a real <button> (this class) — focus, keys, role, cursor all native;
   without JS the closed dialog renders nothing and images stay plain
   content. Open and close ride the shared dialog fade (see .cmd);
   pagination is instant, like navigation, and the drag and its spring back
   are the only other motion. */
/* The button stands in for its image as the flex item in the .shots set —
   site.js copies the image's --ar up, and the .shots > * rules size it —
   wearing no chrome of its own: the frame stays on the image. (zoom-in
   matched the gesture better than pointer, but Safari can't be trusted to
   render it — WebKit 271743.) */
.lightbox-link {
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm); /* the focus ring follows the frame */
  background: none;
  cursor: pointer;
}
.lightbox-link img {
  display: block;
  width: 100%;
}
/* Images can't take the invert, so here the ring itself is the indicator —
   the accent violet in place of the UA blue, offset clear of the frame
   (the photo halo reaches --border-rim out, so the ring starts past it). */
.lightbox-link:focus-visible {
  outline: var(--border-thick) solid var(--accent);
  outline-offset: var(--border-rim);
}
/* Full-bleed: no padding — the photo fits the viewport edges and the chrome
   overlays it (.lightbox-close, .lightbox-chrome), so nothing pads the image
   the way a flowed caption + pager used to (HLC-79). */
.lightbox {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  margin: 0;
  border: 0;
  padding: 0;
  /* Override --bg to the theater near-black (so --glass recomputes from it for
     the chrome wash) and flip color-scheme so the light-dark() neutrals
     (--fg/--muted) resolve to their dark, light-on-black side. One token
     override drives the whole chrome cascade — the terminal-theme pattern. */
  color-scheme: dark;
  --bg: var(--lightbox-bg);
  background: var(--bg);
  color: var(--fg);
  overflow: hidden;
  overscroll-behavior: contain;
}
/* Author display beats the UA's dialog:not([open]) rule, so the closed
   state is restated explicitly. */
.lightbox:not([open]) {
  display: none;
}
/* The chrome overlays the photo on a *solid* sheet of the lightbox's own
   near-black (--bg, the letterbox colour) — not a translucent --glass blur.
   Glass is for sticky bars over scrolling content you want to glimpse through;
   here the opposite is true — letting the photo bleed up through the chrome is
   exactly what muddied the old band (a bright photo edge tinting the wash). A
   solid status bar is also the more honest terminal grammar: a TUI footer
   (lf/ranger/tmux) is opaque, crisp-edged, never frosted. Over the letterbox
   it's seamless (same colour); over a tall photo it's a clean opaque bar with a
   crisp top edge. Safe-area insets keep it clear of notches / the home
   indicator in every rotation. */
.lightbox-close,
.lightbox-chrome {
  position: absolute;
  z-index: 1;
  background: var(--bg);
}
/* The close button overlays the top-right corner; first in the dialog and
   autofocused, so the keyboard arrives on it. */
.lightbox-close {
  top: calc(var(--space-sm) + env(safe-area-inset-top));
  right: calc(var(--space-sm) + env(safe-area-inset-right));
  padding: var(--space-2xs) var(--space-sm);
  border-radius: var(--radius-sm);
  --link-fg: var(--muted);
}
/* Pager + caption on ONE row, pinned to the two bottom corners: caption
   bottom-left, pager bottom-right (margin-left:auto on the bar). Fixed corners
   beat the old centered layout — there the pager drifted with caption length,
   so its position was unpredictable slide to slide. Now the pager never moves;
   the caption flows left and wraps within its own column (flex:1, min-width:0),
   bottom-aligned (flex-end) so a multi-line caption grows upward off the same
   baseline as the pager. margin-left:auto (not justify:space-between) keeps the
   pager hard-right even on a pager-only slide, where space-between would yank a
   lone item to the left. Side safe-area insets matter in landscape, where the
   notch sits on a long edge. */
.lightbox-chrome {
  left: 0;
  right: 0;
  bottom: 0;
  margin: 0;
  padding: var(--space-sm) calc(var(--space-md) + env(safe-area-inset-right))
    calc(var(--space-sm) + env(safe-area-inset-bottom))
    calc(var(--space-md) + env(safe-area-inset-left));
  display: flex;
  flex-flow: row nowrap;
  align-items: flex-end;
  column-gap: var(--space-md);
}
/* Landscape handhelds (and any short window): height is the scarce axis — the
   width-driven --scale shrinks type but not this, so tighten the status line's
   own padding to hand the photo back its vertical room. */
@media (max-height: 30rem) {
  .lightbox-chrome {
    padding-top: var(--space-2xs);
    padding-bottom: calc(var(--space-2xs) + env(safe-area-inset-bottom));
  }
  .lightbox-close {
    top: calc(var(--space-2xs) + env(safe-area-inset-top));
  }
}
.lightbox-chrome[hidden] {
  display: none;
}
/* The stage fills the whole sheet; the image fits within the viewport edges
   (max-width/height keeps it whole and centred — object-fit would crop). */
.lightbox-stage {
  position: absolute;
  inset: 0;
  margin: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  /* The gesture engine (site.js) owns every touch here — pinch, pan, drag —
     so the UA's own scroll/zoom never competes, even for a second finger that
     lands beside the photo. */
  touch-action: none;
}
/* Full-bleed, so no frame — the in-page .shots halo would only clip against
   the viewport edge here. */
.lightbox-img {
  max-width: 100%;
  max-height: 100%;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  /* Settling a gesture (spring back from a short drag, snap a zoom into
     bounds, recentre) rides the spring on transform — translate, scale, and
     the dismiss tilt are one property, so one transition covers them all;
     the dismiss fade rides --ease like every fade. site.js suspends both with
     .is-dragging while a finger leads, so direct manipulation has no lag. */
  transform-origin: center;
  transition:
    transform var(--duration-med) var(--ease-spring),
    opacity var(--duration-fast) var(--ease);
}
.lightbox-img.is-dragging {
  transition: none;
}
/* Page turns — one quick crossfade: the photo fades out, the next fades in,
   two --duration-fast phases on --ease chained in site.js via animationend.
   (Directional travel and zoom were tried and removed — overdone; the fade
   is enough. Don't reintroduce them.) A committed swipe skips the fade-out
   — the drag was it. */
.lightbox-img.is-leave {
  animation: lightbox-leave var(--duration-snap) var(--ease) forwards;
}
.lightbox-img.is-enter {
  animation: lightbox-enter var(--duration-snap) var(--ease) backwards;
}
@keyframes lightbox-leave {
  to {
    opacity: 0;
  }
}
@keyframes lightbox-enter {
  from {
    opacity: 0;
  }
}
/* The pager — muted like a status line; the arrow buttons are links
   (btn-link), so hover/focus inverts them. Hidden by site.js when the page
   has a single image. */
/* Pinned to the bottom-right corner (margin-left:auto eats the free space to
   its left). flex-shrink:0 so it never compresses — the caption yields width,
   the pager stays whole. One size across the whole line (--text-sm) so its
   baseline sits level with the caption's. */
.lightbox-bar {
  margin: 0;
  margin-left: auto;
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  gap: var(--gap-inline);
  color: var(--muted);
  font-size: var(--text-sm);
}
.lightbox-step {
  --link-fg: var(--muted);
}
/* A lone image needs no pager — site.js sets [hidden] on the bar. The bar is
   display:flex (author), which beats the UA [hidden]{display:none}, so the
   hide needs its own author rule (the masthead-tools precedent). */
.lightbox-bar[hidden] {
  display: none;
}
.lightbox-count {
  font-size: var(--text-sm);
}
/* The desc toggle — a muted underlined link appended inline after the caption
   (`hide`), or standing alone bottom-left when the description is hidden
   (`show description`). The leading gap from the caption text collapses when
   the text is gone so it sits flush in the corner. */
.lightbox-desc-toggle {
  --link-fg: var(--muted);
  margin-left: var(--gap-inline);
}
/* The active photo's caption, bottom-left of the status line — muted, left
   aligned, NO max-width: it flows to the viewport (capped only by the pager's
   column to its right via flex), wrapping naturally rather than to an arbitrary
   measure. min-width:0 lets it shrink-and-wrap instead of overflowing. */
.lightbox-caption {
  margin: 0;
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
  color: var(--muted);
  font-size: var(--text-sm);
}
.lightbox-caption[hidden] {
  display: none;
}
/* Description hidden (the `hide` toggle): the caption text collapses, the
   status bar's solid bg clears to fully transparent (no blur — it was already
   solid, not glass) so only the photo and the bare `show description` /
   pager float, and the toggle drops its leading gap to sit flush in the
   bottom-left corner. The pager stays — navigation never vanishes. */
.lightbox.desc-hidden .lightbox-chrome {
  background: transparent;
}
.lightbox.desc-hidden .lightbox-cap-text {
  display: none;
}
.lightbox.desc-hidden .lightbox-desc-toggle {
  margin-left: 0;
}

/* Code — the whole site is monospace, so code can't differentiate by font:
   inline code wears a quiet chip of the surface tint instead. The chip is
   the link-highlight's geometry (background + two side copies via
   box-shadow), so the glyphs hold the character grid — but it is never
   mistakable for a link: no underline, no accent hue, no hover invert; the
   neutral 6% tint sits far from the vivid accent fills. */
code {
  /* Always monospace, even under the serif body family — the chip rides the
     character grid and code reads as code. */
  font-family: var(--font-mono);
  background: var(--surface);
  border-radius: var(--radius-sm);
  box-shadow:
    calc(-1 * var(--link-pad-x)) 0 0 var(--surface),
    var(--link-pad-x) 0 0 var(--surface);
  /* A code span is one token — never split mid-chip at a line break (a
     wrapped `--preview` read as two spans). Long commands belong in blocks. */
  white-space: nowrap;
}
/* Inside a block the pre is the chip — the inline treatment comes off. */
pre code {
  background: none;
  border-radius: 0;
  box-shadow: none;
  white-space: inherit;
}

pre {
  overflow-x: auto;
  padding: var(--space-md);
  background: var(--surface);
  border-radius: var(--radius-md);
  line-height: var(--leading-snug);
}

/* Forced colors strips the tint fills — keep code reading as code with a
   real border there (currentColor → CanvasText). */
@media (forced-colors: active) {
  code,
  pre {
    border: var(--border-thin) solid;
  }
  pre code {
    border: 0;
  }
}

/* Copy on code blocks (site.js, JS-only) — the wrapper anchors a muted
   `copy` button over the block's top right corner; on copy it flips to a
   `copied` chip in green, the success intent's first consumer, then fades
   back on the shared link transition. Hover/focus invert in the same
   intent. */
.codeblock {
  position: relative;
}
.copy-code {
  position: absolute;
  top: var(--space-2xs);
  right: var(--space-xs);
  font-size: var(--text-sm);
  --link-fg: var(--muted);
  --accent: var(--green);
  --accent-hover: var(--green-light);
  /* Rests on a glass chip — a code line that reaches (or scrolls under)
     the corner slides beneath it legibly, the sticky filter bar's
     precedent; over an empty corner the wash all but vanishes. Real
     padding, not the link's shadow copies: the button is absolute, so
     there's no layout to protect. */
  padding-inline: var(--link-pad-x);
  background: var(--glass);
  -webkit-backdrop-filter: blur(var(--blur-glass));
  backdrop-filter: blur(var(--blur-glass));
  border-radius: var(--radius-sm);
}
.copy-code.is-copied {
  background: var(--green);
  color: var(--accent-fg);
  text-decoration-color: transparent;
}

hr {
  border: none;
  border-top: var(--border-thin) solid var(--rule);
  /* The section rhythm, plus a blank line of breathing room on each side (1lh —
     one line of the current leading, so the rule sits in its own clear band). */
  margin: calc(var(--gap-section) + 1lh) 0;
}

/* ───────────────────────────────────────────────────────────────────────────
   FORM CONTROLS

   The site barely uses forms, but when one appears the primitives wear the same
   chip + invert grammar as everything else: a surface-tinted, rule-bordered
   resting chip that fills with the link-intent accent on hover/focus, text
   knocked out to --accent-fg — the two-weight invert links use (hover in the
   -light step, focus/active in the full accent). Everything rides tokens, so
   the terminal tubes recolour these to phosphor for free; the terminal block at
   the foot of this section only adds the character/line *costume*, not colour.

   :where() keeps the whole layer at *zero* specificity, so any styled component
   (the palette's .cmd-input, the .btn-link links-in-disguise) always wins
   without !important. The tube article's interactive demo (.crt-demo) uses these
   same globals — its index.css keeps only its own layout. */

/* Text-like fields, select, textarea — a bordered surface chip. */
:where(
    input:where(
        [type="text"], [type="email"], [type="url"], [type="password"],
        [type="number"], [type="tel"], [type="search"], [type="date"]
      ),
    textarea,
    select
  ):where(:not(.cmd-input)) {
  font: inherit;
  color: var(--fg);
  background: var(--surface);
  border: var(--border-thin) solid var(--rule);
  border-radius: var(--radius-sm);
  padding: var(--space-3xs) var(--space-xs);
  accent-color: var(--accent); /* caret + any native affordance → link intent */
  transition:
    border-color var(--duration-fast) var(--ease),
    box-shadow var(--duration-fast) var(--ease);
}
:where(input, textarea, select):where(:not(.cmd-input)):focus-visible {
  border-color: var(--accent);
  outline: var(--border-thick) solid var(--accent);
  outline-offset: 2px;
}
/* Hover suggests in the accent's -light step (the link hover idiom): a quiet
   border highlight on the bordered controls (text fields, select, textarea,
   checkbox, radio). Never on a disabled control (also inert via the global
   disabled rule's pointer-events). */
:where(input, textarea, select):where(:not(.cmd-input, :disabled)):hover {
  border-color: var(--accent-hover);
}
/* Select — drop the native arrow (it ignores the theme: it renders in the OS
   colour, white on dark/terminal in Safari) and draw a chevron from two
   gradients in currentColor, so it follows the text into every theme. */
:where(select):where(:not(.cmd-input)) {
  appearance: none;
  -webkit-appearance: none;
  padding-inline-end: 1.7em;
  background-image:
    linear-gradient(45deg, transparent 50%, currentColor 50%),
    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position:
    right 0.85em center,
    right 0.5em center;
  background-size: 0.34em 0.34em;
  background-repeat: no-repeat;
}
/* The one block control, not an inline chip. */
:where(textarea) {
  display: block;
  width: 100%;
  line-height: var(--leading-snug);
}
::placeholder {
  color: var(--muted);
  opacity: 1;
}

/* Buttons — a pushable chip, always bold (every theme, no primary/secondary
   distinction), filling with the accent on hover/focus (the two link weights).
   Non-terminal buttons carry no underline; the terminal costume adds one (the
   underline treatment is pre-set here so terminal only flips the line on). Never
   .btn-link (a link in disguise, styled with the links above). */
:where(
    button,
    input:where([type="button"], [type="submit"], [type="reset"])
  ):where(:not(.btn-link)) {
  font: inherit;
  font-weight: var(--weight-bold);
  color: var(--fg);
  background: var(--surface);
  border: var(--border-thin) solid var(--rule);
  border-radius: var(--radius-sm);
  padding: var(--space-3xs) var(--space-sm);
  cursor: pointer;
  text-decoration-line: none;
  text-decoration-thickness: var(--border-thin);
  text-underline-offset: 0.18em;
  text-decoration-color: color-mix(
    in srgb,
    currentColor var(--tint-line),
    transparent
  );
  transition:
    background-color var(--duration-fast) var(--ease),
    border-color var(--duration-fast) var(--ease),
    color var(--duration-fast) var(--ease);
}
:where(button, input:where([type="button"], [type="submit"], [type="reset"])):where(
    :not(.btn-link)
  ):hover {
  background: var(--accent-hover);
  border-color: var(--accent-hover);
  color: var(--accent-fg);
  text-decoration-color: transparent;
}
/* Focus, active, and a pressed/toggled state (.is-on / aria-pressed) commit in
   the full accent — the persistent fill is the standard "on" toggle (the crt
   demo's effect toggles ride it). */
:where(button, input:where([type="button"], [type="submit"], [type="reset"])):where(
    :not(.btn-link)
  ):is(:focus-visible, :active, .is-on, [aria-pressed="true"]) {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-fg);
  text-decoration-color: transparent;
  outline: var(--border-thick) solid transparent; /* forced-colors keeps a ring */
}

/* Checkbox + radio — a box drawn from tokens (appearance:none), the tick the
   accent, the whole control inverting like a link. The mark is a ::before that
   springs in on :checked (the tooltip's --ease-spring). */
:where(input:where([type="checkbox"], [type="radio"])):where(:not(.cmd-input)) {
  appearance: none;
  -webkit-appearance: none;
  flex: none;
  display: inline-grid;
  place-content: center;
  inline-size: 1.25em;
  block-size: 1.25em;
  margin: 0;
  vertical-align: -0.25em;
  background: var(--surface);
  border: var(--border-thin) solid var(--rule);
  cursor: pointer;
  transition:
    background-color var(--duration-fast) var(--ease),
    border-color var(--duration-fast) var(--ease);
}
:where(input[type="checkbox"]):where(:not(.cmd-input)) {
  border-radius: var(--radius-sm);
}
:where(input[type="radio"]):where(:not(.cmd-input)) {
  border-radius: 50%;
}
:where(input:where([type="checkbox"], [type="radio"]))::before {
  content: "";
  inline-size: 0.72em;
  block-size: 0.72em;
  transform: scale(0);
  background: var(--accent-fg);
  transition: transform var(--duration-fast) var(--ease-spring);
}
:where(input[type="checkbox"])::before {
  /* a checkmark clipped from the filled box */
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
}
:where(input[type="radio"])::before {
  border-radius: 50%;
  inline-size: 0.62em;
  block-size: 0.62em;
}
:where(input:where([type="checkbox"], [type="radio"]):checked):where(:not(.cmd-input)) {
  background: var(--accent);
  border-color: var(--accent);
}
:where(input:where([type="checkbox"], [type="radio"]):checked)::before {
  transform: scale(1);
}
:where(input:where([type="checkbox"], [type="radio"])):where(:not(.cmd-input)):focus-visible {
  outline: var(--border-thick) solid var(--accent);
  outline-offset: 2px;
}

/* Range — a thin rule track with an accent thumb (the filled side rides
   accent-color where the browser paints it; the thumb is drawn for the rest). */
:where(input[type="range"]) {
  appearance: none;
  -webkit-appearance: none;
  width: min(100%, 14rem);
  height: 1.2em;
  background: none;
  cursor: pointer;
  accent-color: var(--accent);
}
:where(input[type="range"])::-webkit-slider-runnable-track {
  height: var(--border-thick);
  background: var(--rule);
  border-radius: var(--radius-sm);
}
:where(input[type="range"])::-moz-range-track {
  height: var(--border-thick);
  background: var(--rule);
  border-radius: var(--radius-sm);
}
:where(input[type="range"])::-moz-range-progress {
  height: var(--border-thick);
  background: var(--accent);
  border-radius: var(--radius-sm);
}
:where(input[type="range"])::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  inline-size: 1em;
  block-size: 1em;
  margin-top: calc((var(--border-thick) - 1em) / 2); /* centre on the track */
  background: var(--accent);
  border: none;
  border-radius: 50%;
}
:where(input[type="range"])::-moz-range-thumb {
  inline-size: 1em;
  block-size: 1em;
  background: var(--accent);
  border: none;
  border-radius: 50%;
}
:where(input[type="range"]):focus-visible {
  outline: var(--border-thick) solid var(--accent);
  outline-offset: 2px;
}
/* Hover brightens the thumb to the accent's -light step (the link suggestion). */
:where(input[type="range"]):hover::-webkit-slider-thumb {
  background: var(--accent-hover);
}
:where(input[type="range"]):hover::-moz-range-thumb {
  background: var(--accent-hover);
}
/* Stop marker — a quiet tick on the track at a slider's default (or any "home"
   value), so a touch user who can't reach the dblclick/snap-to-default sees
   where the resting value lives. Opt in per input by setting --range-default to
   the value's *fraction* of the range, (value - min) / (max - min) ∈ 0–1 (the
   attribute gate keeps every other range tick-free). Drawn on the input's own
   background — taller than the 1px track so it straddles it — and inset half a
   thumb each side so the tick lands under the thumb centre at that value, the
   way the thumb itself tracks. Rides --range-stop (muted, so it recolours with
   the theme — green under terminal — and never competes with the accent thumb);
   UA datalist ticks are unstyleable and invisible on WebKit, so this replaces
   them. More than one stop = more background layers, same recipe. */
:where(input[type="range"]) {
  --range-stop: color-mix(in srgb, var(--muted) 60%, transparent);
}
:where(input[type="range"][style*="--range-default"]) {
  background:
    linear-gradient(var(--range-stop), var(--range-stop)) no-repeat
      calc(0.5em + (100% - 1em) * var(--range-default)) center /
      var(--border-thick) 0.7em;
}

/* ── Terminal costume — the easter egg. Phosphor colour came free from the token
   recolour (the terminal block recolours --accent / --rule / --fg …); this only
   swaps the *shapes* for characters and lines, the [gh]/[in] social treatment
   extended to the form primitives: no rounded corners anywhere, checkboxes and
   radios become [ ]/[x] and ( )/(•) glyphs, buttons wear < > brackets. The base
   hover/focus inverts above still fire (now phosphor), so only the resting
   costume is restated here. */
:root[data-theme^="phosphor"]
  :where(input, textarea, select, button):where(:not(.btn-link, .cmd-input)) {
  border-radius: 0;
}
/* Checkbox + radio → bold bracket glyphs: drop the drawn box, let the ::before
   carry the literal characters (bold, so the small marks read clearly). The
   glyph rides currentColor, so set the control's colour to the phosphor (--fg) —
   without it the ::before takes the UA default and reads white on the dark
   tube. */
:root[data-theme^="phosphor"]
  :where(input:where([type="checkbox"], [type="radio"])):where(:not(.cmd-input)) {
  inline-size: auto;
  block-size: auto;
  border: 0;
  background: none;
  color: var(--fg);
  vertical-align: baseline;
  font-family: var(--font-mono);
}
:root[data-theme^="phosphor"]
  :where(input:where([type="checkbox"], [type="radio"]))::before {
  content: "[\00a0]";
  inline-size: auto;
  block-size: auto;
  background: none;
  clip-path: none;
  transform: none;
  border-radius: 0;
  font-weight: var(--weight-bold);
}
:root[data-theme^="phosphor"] :where(input[type="radio"])::before {
  content: "(\00a0)";
}
:root[data-theme^="phosphor"]
  :where(input:where([type="checkbox"], [type="radio"]):checked):where(:not(.cmd-input)) {
  background: none;
}
:root[data-theme^="phosphor"] :where(input[type="checkbox"]:checked)::before {
  content: "[X]"; /* capital — terminal-authentic (Turbo Vision), reads bold */
  transform: none;
}
:root[data-theme^="phosphor"] :where(input[type="radio"]:checked)::before {
  content: "(\2022)"; /* (•) */
  transform: none;
}
/* A checkbox/radio's label reads as an interactive line in the tube — underline
   it like the bracket buttons (the link-made-physical idiom), the quiet
   --tint-line stroke. The stroke rides the label's *text span* only, never the
   [ ]/[x] state indicator (the input's ::before glyph), so the box stays a box.
   Enabled options only; the disabled block below (line-through, last + 0,2,0)
   strikes the inert ones, which this skips via :has(:not(:disabled)). */
:root[data-theme^="phosphor"]
  label:where(:has(input:where([type="checkbox"], [type="radio"]):not(:disabled)))
  > span {
  text-decoration-line: underline;
  text-decoration-thickness: var(--border-thin);
  text-underline-offset: 0.18em;
  text-decoration-color: color-mix(
    in srgb,
    currentColor var(--tint-line),
    transparent
  );
}
/* Button → < label > brackets, no box, underlined label (terminal buttons read
   as links-made-physical). The base hover/focus invert can't reach here — the
   terminal resting rule outscores it (:root[data-theme] is 0,2,0), so the fill
   is restated below at terminal scope, in the link's two weights. */
:root[data-theme^="phosphor"]
  :where(
    button,
    input:where([type="button"], [type="submit"], [type="reset"])
  ):where(:not(.btn-link)) {
  border: 0;
  background: none;
  /* The costume strips rounded corners from the other primitives, but a button's
     lit fill keeps the site's standard chip radius — it reads as a pressable
     chip, not a raw block. */
  border-radius: var(--radius-sm);
  padding-inline: var(--gap-dir);
  text-decoration-line: underline;
}
:root[data-theme^="phosphor"]
  :where(button, input:where([type="button"], [type="submit"], [type="reset"])):where(
    :not(.btn-link)
  )::before {
  content: "<\00a0";
}
:root[data-theme^="phosphor"]
  :where(button, input:where([type="button"], [type="submit"], [type="reset"])):where(
    :not(.btn-link)
  )::after {
  content: "\00a0>";
}
/* Terminal button interaction — hover suggests in the -light step, focus/active/
   pressed commit in the full phosphor; brackets invert with the fill. */
:root[data-theme^="phosphor"]
  :where(button, input:where([type="button"], [type="submit"], [type="reset"])):where(
    :not(.btn-link)
  ):hover {
  background: var(--accent-hover);
  color: var(--accent-fg);
  text-decoration-color: transparent;
  /* The lit chip is the brightest phosphor on the row, so it blooms — the link
     chip's halation slot, riding --glow-blur so the flicker block can wobble it. */
  box-shadow: 0 0 var(--glow-blur) var(--link-glow);
}
:root[data-theme^="phosphor"]
  :where(button, input:where([type="button"], [type="submit"], [type="reset"])):where(
    :not(.btn-link)
  ):is(:focus-visible, :active, .is-on, [aria-pressed="true"]) {
  background: var(--accent);
  color: var(--accent-fg);
  text-decoration-color: transparent;
  box-shadow: 0 0 var(--glow-blur) var(--link-glow);
}
/* …except the lightbox wrapper, which is an *image* button, not a text one —
   `< img >` brackets + an underline + a lit chip behind the photo are all wrong.
   The terminal-button rules above are :where()-scoped (0 specificity inside the
   brackets), so this plain `.lightbox-link` class outscores them and restores the
   bare base wrapper. The base green focus ring (untouched) stays the indicator,
   since a photo can't take the invert. */
:root[data-theme^="phosphor"] .lightbox-link {
  padding: 0;
  text-decoration-line: none;
}
:root[data-theme^="phosphor"] .lightbox-link::before,
:root[data-theme^="phosphor"] .lightbox-link::after {
  content: none;
}
:root[data-theme^="phosphor"] .lightbox-link:is(:hover, :focus-visible, :active) {
  background: none;
  box-shadow: none;
}
/* Range thumb → a square phosphor block (no curve). */
:root[data-theme^="phosphor"] :where(input[type="range"])::-webkit-slider-thumb {
  border-radius: 0;
}
:root[data-theme^="phosphor"] :where(input[type="range"])::-moz-range-thumb {
  border-radius: 0;
}
/* Hover/focus blooms the [x]/(•) indicator in phosphor — the glyph lights like a
   beam dwelling on a cell. Triggered by the input itself or anywhere on its label
   (the whole row is the target). The glow rides --term-glow (the brighter beam,
   not the resting body --term-text-glow) and --glow-blur, so the flicker block
   below can wobble it; steady here for reduced motion. */
:root[data-theme^="phosphor"]
  :where(input:where([type="checkbox"], [type="radio"])):where(:not(.cmd-input, :disabled)):is(:hover, :focus-visible),
:root[data-theme^="phosphor"]
  label:has(input:where([type="checkbox"], [type="radio"]):not(:disabled)):hover
  :where(input:where([type="checkbox"], [type="radio"])) {
  text-shadow: 0 0 var(--glow-blur) var(--term-glow);
}
/* Phosphor flicker on interaction — interaction-only, reduced-motion opt-out (the
   resting screen never moves). The lit button's halation and the hovered
   checkbox/radio indicator wobble like an unsteady beam, reusing the link's
   term-glow-flicker keyframe; the glow itself is set in the rules above, this
   only animates the --glow-blur / --glow-bright it rides. */
@media (prefers-reduced-motion: no-preference) {
  :root[data-theme^="phosphor"]
    :where(button, input:where([type="button"], [type="submit"], [type="reset"])):where(:not(.btn-link)):is(:hover, :focus-visible, :active, .is-on, [aria-pressed="true"]),
  :root[data-theme^="phosphor"]
    :where(input:where([type="checkbox"], [type="radio"])):where(:not(.cmd-input, :disabled)):is(:hover, :focus-visible),
  :root[data-theme^="phosphor"]
    label:has(input:where([type="checkbox"], [type="radio"]):not(:disabled)):hover
    :where(input:where([type="checkbox"], [type="radio"])) {
    animation: term-glow-flicker 1.5s linear infinite;
    filter: brightness(var(--glow-bright));
  }
}

/* ── Disabled — 60% opacity + struck through + inert, everywhere: form controls
   (:disabled) and links/elements marked aria-disabled (the one global disabled
   treatment, used site-wide). The dim goes on the whole option — the wrapping
   <label> when one is present, else the control itself — so it never compounds
   (a label-wrapped control resets its own opacity below). No interactive state
   reaches a disabled item (pointer-events: none — so a disabled checkbox/radio
   never lights its symbol on hover). The :root prefix lifts these to 0,2,0 to
   win over the terminal bracket buttons' underline (also 0,2,0); this block is
   last, so it also wins the source-order tie. */
/* The opacity + strike land on text items — buttons, fields, aria-disabled
   links, label-wrapped options — but never .btn-link (an icon/link in disguise:
   striking its ↑ arrow or Aa glyph reads as damage, not "disabled"; it carries
   its own dim, below). */
:root :where(button:not(.btn-link), input, select, textarea):disabled,
:root :where([aria-disabled="true"]):where(:not(.btn-link)),
:root :where(label):has(:disabled) {
  opacity: 0.6;
  text-decoration-line: line-through;
}
:root :where(label):has(:disabled) :disabled {
  opacity: 1; /* the label already dims it — don't stack the opacities */
}
/* Inert reaches every disabled item, .btn-link included. */
:root :where(button, input, select, textarea):disabled,
:root :where([aria-disabled="true"]) {
  cursor: default;
  pointer-events: none;
}
