/* ============================================================
   TOKENS
   ============================================================ */
:root {
  /* --- colour ------------------------------------------------ */
  --bg:           #0009FF;
  /* --bg-secondary is THE media surface: the letterbox behind contained
     artwork and the fill of an empty placeholder. One 6pp lightness step
     below --bg in every theme, so a frame reads as a slightly deeper shade
     of the page rather than as a black hole punched through it.
     6pp is the ceiling, and YELLOW sets it: olive gains luminance fast and
     --fg-dim on it falls under 4.5:1 past that step. */
  --bg-secondary: #0008E0;
  --fg:           #FFFFFF;
  --fg-muted:     #C7C7FF;
  --fg-dim:       #B8B8FF;
  --accent:       #FFFFFF;

  /* translucent white, used for de-emphasised type and hairlines */
  --fg-a72:       rgba(255,255,255,0.72);
  --fg-a65:       rgba(255,255,255,0.65);
  --fg-a55:       rgba(255,255,255,0.55);
  --fg-a45:       rgba(255,255,255,0.45);
  /* Hairlines are theme-COLOURED, not translucent white/black: a deeper
     shade of the page's own hue. Solved per theme for constant visibility
     (1.95:1 and 1.45:1 against --bg everywhere) rather than a constant
     lightness step, because the same step reads far weaker on white and
     black than it does on blue. */
  --border:       #000475;
  --border-light: #0006B8;
  /* EXPLORATION 2026-09-25: the ticker recedes into the page -- a DARKER
     shade of the page's own hue rather than the foreground colour.

     Pushed as legible as the idea physically allows, and that ceiling is low
     on the dark palettes: #0009FF is already so dark that even pure #000000
     is only 2.48:1 away from it, and pink's ceiling is 3.06:1. So every
     palette is matched at ~2.3:1, a hair under blue's wall. Past that blue
     stops reading as a dark BLUE and just reads black, which loses the point.
     Yellow and white have 18:1 and 21:1 of headroom going dark and are held
     back to the same 2.3 on purpose, so the strip feels equally recessed in
     every theme instead of jumping out on the light ones. */
  --ticker-fg:    #000240;   /* 2.29:1 on #0009FF -- ceiling is 2.48 */

  /* black surfaces (code blocks, inverted about-page blocks, masks) */
  --black:        #000000;
  /* on a black surface --fg-muted/--fg-dim read violet, so they are
     re-pointed locally to these two neutrals -- see ABOUT / BLOCKS */
  --on-black-muted: #AEBECF;
  --on-black-dim:   #8A9BAE;
  /* Absolute black, unlike --black, which the black theme lifts to #171717
     so its own surfaces separate from the page. Used where the surface must
     stay true black in every palette (the palette picker's quip bubble). */
  --black-abs:      #000000;
  /* Forced light foreground for those same black islands. The alpha pairs
     mirror --fg-a72/a65/a55/a45 and the two border tokens, which is what
     .blk re-points them to. */
  --on-black-fg:    #FFFFFF;
  --on-black-a72:   rgba(255,255,255,0.72);
  --on-black-a65:   rgba(255,255,255,0.65);
  --on-black-a55:   rgba(255,255,255,0.55);
  --on-black-a45:   rgba(255,255,255,0.45);
  --on-black-border:       rgba(255,255,255,0.28);
  --on-black-border-light: rgba(255,255,255,0.16);


  /* --- type -------------------------------------------------- */
  --font-mono: 'IBM Plex Mono', 'Courier New', monospace;
  --font-display: 'Barlow Condensed', 'Arial Narrow', sans-serif;
  --font-body: 'Barlow', 'Helvetica Neue', Arial, sans-serif;

  --fs-micro: 9px;    /* captions, corner marks */
  --fs-label: 11px;   /* eyebrow labels, meta keys */
  --fs-data:  12px;   /* data strips, ticker band */
  --fs-body:  13px;   /* base */
  --fs-lead:  16px;   /* lead paragraphs, buttons */
  --fs-h3:    18px;
  --fs-h2:    22px;
  --fs-h1:    34px;
  --fs-display:    clamp(48px, 7vw, 96px);
  --fs-card-title: clamp(22px, 2.8vw, 38px);

  /* --- tracking ---------------------------------------------- */
  /* Four steps, and tracking widens as size shrinks -- the usual rule, and
     roughly what the site was already doing by eye before this was tokenised
     on 2026-09-24. Nearly every label here is uppercase, so the driver is
     size, not case. Ten ad-hoc values collapsed into these four. */
  --ls-display: -0.01em;  /* Barlow Condensed at h1 / card-title size: big caps
                             want pulling in, not pushing apart */
  --ls-tight:    0.01em;  /* the default: display or mono at body-to-h2 size */
  --ls-label:    0.08em;  /* uppercase labels, 11-13px */
  --ls-micro:    0.16em;  /* uppercase micro, 9-11px, the widest step */

  /* --- space (4px scale) ------------------------------------- */
  /* 1px and 2px are deliberately NOT on this scale: they are
     hairlines (borders, --grid-gap, the 2px cursor bars), not space. */
  --sp-1:  4px;
  --sp-2:  8px;
  --sp-3:  12px;
  --sp-4:  16px;
  --sp-5:  20px;
  --sp-6:  24px;
  --sp-7:  28px;
  --sp-8:  32px;
  --sp-10: 40px;
  --sp-12: 48px;
  --sp-14: 56px;

  /* --- motion ------------------------------------------------ */
  --dur-snap:   0.08s;  /* cursor + glitch fades: must drop out the instant the pointer stops */
  --dur-fast:   0.12s;
  --dur-base:   0.22s;
  --dur-slow:   0.3s;
  --dur-hero:   0.6s;
  --dur-reveal: 500ms;
  --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);

  /* --- structure --------------------------------------------- */
  --grid-gap:  1px;
  /* THE PAGE GUTTER. One value for every component's horizontal inset, so
     the nav, cards, about blocks, footer and project copy all start on the
     same vertical line. Before this there were five different values (16 nav,
     21 card, 32 about block, 24 footer, 64 project) each from its own rule.
     Steps to 16px below 800px; see the :root override under THEMES.
     Exception by design: the project page's 76px back-rail column, which is a
     real layout column with the sticky back link in it, not a gutter. */
  --gutter:    24px;
  --nav-h:     84px;
  /* height of the phone ticker band -- see PHONE TICKER BAND near the end */
  --ticker-h:  28px;
  /* the footer is fixed to the bottom of the viewport, so its height has to
     be a known number: body reserves exactly this much bottom padding. 56px
     row + the 1px top rule. */
  --footer-h:  57px;
}

/* ============================================================
   THEMES
   Every colour below the TOKENS block is a var(), so a palette is
   nothing but a re-point of these names. Blue is :root (the default and
   the no-JS fallback); the four alternates (yellow, pink, white, black)
   are set as data-theme on <html> by an inline script in each page
   <head>, before first paint.

   Yellow and white are NOT hue swaps: neither #D9FF00 (white type is
   1.15:1) nor #FFFFFF can carry white type, so both invert the
   foreground to black and the translucent whites to translucent blacks. --fg-muted / --fg-dim are tinted
   toward each background's own hue so they read as de-emphasis rather
   than as a second colour.
   ============================================================ */

/* TopLists brand yellow. Dark type, olive-tinted greys. */
[data-theme="yellow"] {
  --bg:           #D9FF00;
  --bg-secondary: #BFE000;
  --fg:           #000000;
  --fg-muted:     #474700;
  --fg-dim:       #565600;
  --accent:       #000000;

  --fg-a72:       rgba(0,0,0,0.72);
  --fg-a65:       rgba(0,0,0,0.65);
  --fg-a55:       rgba(0,0,0,0.55);
  --fg-a45:       rgba(0,0,0,0.45);
  /* the tightest surface on the site: --fg-dim is 5.09:1 on #BFE000 */
  --border:       #9DB900;
  --border-light: #B6D600;
  --ticker-fg:    #91AB00;   /* 2.27:1 */
}

/* Deep magenta. Bright electric pink loses white type (3.7:1 on #FF0095),
   and even #CC0066 dropped --fg-dim to 3.74:1, so this sits two steps
   darker: white 6.85:1, --fg-dim 4.58:1, which lands on blue's own 4.57
   floor. */
[data-theme="pink"] {
  --bg:           #B3005A;
  --bg-secondary: #94004B;
  --fg:           #FFFFFF;
  --fg-muted:     #FFD9EC;
  --fg-dim:       #FFC2E0;
  --accent:       #FFFFFF;

  /* pink used to inherit the root hairlines; now that those are blue it has
     to declare its own. Same for the black theme below. */
  --border:       #630032;
  --border-light: #870044;
  --ticker-fg:    #4B0026;   /* 2.31:1 -- ceiling is 3.06 */
}

/* Neutral light. Same inversion logic as yellow -- dark type, translucent
   blacks -- but the greys are true neutrals rather than tinted, since there is
   no hue to tint toward. --fg-dim is 5.33:1 on white, --fg-muted 8.45:1. */
[data-theme="white"] {
  --bg:           #FFFFFF;
  --bg-secondary: #F0F0F0;
  --fg:           #000000;
  --fg-muted:     #4D4D4D;
  --fg-dim:       #6B6B6B;
  --accent:       #000000;

  --fg-a72:       rgba(0,0,0,0.72);
  --fg-a65:       rgba(0,0,0,0.65);
  --fg-a55:       rgba(0,0,0,0.55);
  --fg-a45:       rgba(0,0,0,0.45);
  --border:       #BABABA;
  --border-light: #D6D6D6;
  --ticker-fg:    #ABABAB;   /* 2.30:1, held back from a 21:1 headroom */
}

/* Neutral dark. Keeps the root's white foreground and translucent whites, so
   only the backgrounds and the two greys move.

   The catch that makes this theme different from every other one: --black is
   the background of the black ISLANDS (.blk, .card-img, hero, portrait), and
   at #000000 on a #000000 page they disappear entirely -- the about page's
   six blocks became invisible rectangles. So the islands are lifted to
   #171717 with them, which turns them into raised cards
   instead of holes. --bg-secondary sits between the two so the figure caption
   band still separates from both. */
[data-theme="black"] {
  --bg:           #000000;
  /* the one theme that cannot go darker, so its surface is LIFTED instead.
     #0F0F0F was only 1.10:1 off the page and the frames barely read; #1A1A1A
     is 1.24:1, a touch more separation than the white theme's #F0F0F0 (1.14:1),
     because a step this near black is harder to see than the same step near
     white. --fg-dim on it still measures 5.06:1, so the AA floor holds. */
  --bg-secondary: #1A1A1A;
  --fg:           #FFFFFF;
  --fg-muted:     #B3B3B3;
  --fg-dim:       #8C8C8C;
  --accent:       #FFFFFF;

  --border:       #3E3E3E;
  --border-light: #292929;
  /* the one theme with no darker direction to go, so the ticker is LIFTED
     instead -- the same exception --bg-secondary makes above. 2.30:1. */
  --ticker-fg:    #484848;

  --black:        #171717;
}

/* Black surfaces are islands: they stay dark in every theme, so the
   foreground tokens have to be forced back to light inside them.
   Harmless in blue, pink and black (the values already match);
   load-bearing in yellow and white, where --fg is black. */
/* Only .blk now. The media surfaces used to live here too, because they were
   black in every palette and so needed light type forced back on. Now that
   they are a 6pp step off the page they carry the palette's OWN foreground,
   which is what makes dark type appear on the yellow and white frames. */
.blk {
  /* `color` and not just the token: text inside these blocks mostly
     inherits rather than declaring var(--fg), so in the yellow theme it
     came through black-on-black until this line existed. */
  color:          var(--on-black-fg);
  --fg:           var(--on-black-fg);
  --accent:       var(--on-black-fg);
  --fg-a72:       var(--on-black-a72);
  --fg-a65:       var(--on-black-a65);
  --fg-a55:       var(--on-black-a55);
  --fg-a45:       var(--on-black-a45);
  --border:       var(--on-black-border);
  --border-light: var(--on-black-border-light);
  /* Cooler, bluer greys than any theme's muted pair -- see .blk */
  --fg-muted:     var(--on-black-muted);
  --fg-dim:       var(--on-black-dim);
}

/* One step down on phones: 24px is 6.4% of a 375px screen on each side.
   Placed here, after THEMES, so it cannot be shadowed by an earlier :root. */
@media (max-width: 800px) {
  :root { --gutter: 16px; }
}

/* ============================================================
   RESET
   ============================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

html {
  font-size: var(--fs-body);
  /* On the html rule rather than a second :root block on purpose: :root would
     be a duplicate selector for the token block above, and html is the same
     element at lower specificity, which nothing else competes for.
     Thumb then track. Both inherit, so every scroller on the page picks them
     up and the ones that hide their bar (.carousel-track, the pinned index)
     override with `scrollbar-width: none` further down. */
  scrollbar-color: var(--fg-dim) var(--bg-secondary);
  /* `thin` rather than `auto`: at auto the standard property leaves the OS
     width, which is a 15px grey-chrome-sized bar on Windows. */
  scrollbar-width: thin;
}

body {
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-mono);
  line-height: 1.5;
  min-height: 100vh;
  /* room for the fixed footer, so the last line of a page is never under it */
  padding-bottom: var(--footer-h);
}

a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
ul { list-style: none; }

/* ============================================================
   SCROLLBAR
   Themed, because the default is a grey chrome bar sitting against a
   saturated page -- the one piece of UI on the site that ignored the
   palette. Thumb is --fg-dim, track is --bg-secondary, so it recolours with
   the footer chips for free and never needs a per-theme override.

   Declared twice on purpose. `scrollbar-color` is the standard property
   (Firefox, Chrome 121+, Safari 18.2+) and takes exactly two colours;
   ::-webkit-scrollbar is the older Blink/WebKit pseudo-element tree, which
   is what actually answers in anything shipped before late 2024. A browser
   that understands both applies the pseudo-elements, so the two have to
   agree -- hence the same tokens in both.

   Note for testing on a Mac: the system scrollbar is an OVERLAY, hidden
   until you scroll, unless "Show scroll bars: Always" is set in System
   Settings. Giving ::-webkit-scrollbar a width is what forces the classic
   always-visible gutter in Blink; macOS Safari keeps overlay behaviour and
   only shows these colours mid-scroll. Windows shows them permanently.
   ============================================================ */
::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}

::-webkit-scrollbar-track {
  background: var(--bg-secondary);
}

::-webkit-scrollbar-thumb {
  background: var(--fg-dim);
  /* square, like everything else on the site -- the default is already
     square in Blink, but Safari rounds it unless told otherwise */
  border-radius: 0;
}

/* The thumb is --fg-dim at rest, so hover goes UP to --fg rather than down:
   dimming it would read as the bar becoming disabled under the pointer. */
::-webkit-scrollbar-thumb:hover {
  background: var(--fg);
}

/* Blink draws a button box at each end by default and a corner where two
   scrollbars meet. Both inherit the track colour unless zeroed, which left a
   lighter square in the corner of the one horizontal scroller on the site
   (the pinned carousel). */
::-webkit-scrollbar-button {
  display: none;
}

::-webkit-scrollbar-corner {
  background: var(--bg-secondary);
}

/* ============================================================
   LAYOUT
   Deliberately no shared container class: every band is full bleed
   and insets its own text by --gutter. The old .page-wrap
   (max-width 1400 + padding) was dead -- no HTML ever used it.
   ============================================================ */

/* ============================================================
   NAV
   ============================================================ */
nav.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  height: var(--nav-h);
  border-bottom: 1px solid var(--border-light);
  background: var(--bg);
  display: flex;
  align-items: stretch;
}

.nav-inner {
  display: flex;
  align-items: center;
  width: 100%;
  padding: 0 var(--gutter);
}

.nav-logo {
  display: flex;
  align-items: center;
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  font-weight: 800;
  letter-spacing: var(--ls-tight);
  text-transform: uppercase;
  color: var(--fg);
}

.nav-logo .fn-name { color: var(--fg); }

/* Takes the wordmark's slot on a project page at mobile widths, matching its
   font and size so the nav's rhythm is unchanged. Borderless like the logo
   it replaces -- the arrow already reads as "back". */
.nav-back {
  display: none;
  align-items: center;
  gap: var(--sp-3);
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  font-weight: 800;
  letter-spacing: var(--ls-tight);
  text-transform: uppercase;
  color: var(--fg);
}

.nav-back svg { width: 22px; height: 11px; }

/* Scoped to project pages: an unscoped `.nav-logo { display: none }` here
   wiped the wordmark off the work and about pages too. `.nav-back` only
   exists on project pages, but the logo does not, so the switch needs the
   body attribute rather than a sibling selector.
   `(hover: none) and (pointer: coarse)` on top of the width check is
   deliberate -- width alone fires on a narrow-but-unmaximized DESKTOP
   window too, which is exactly the bug this was reported as. Gating on a
   touch pointer means only actual phones/tablets get the horizontal nav
   link; any mouse/trackpad browser keeps the wordmark + the vertical
   .back-link below, no matter how narrow its window gets. */
@media (max-width: 800px) and (hover: none) and (pointer: coarse) {
  body[data-view="project"] .nav-back { display: flex; }
  body[data-view="project"] .nav-logo { display: none; }
  /* the nav carries it now; two back links would be redundant */
  body[data-view="project"] .header-copy .back-link { display: none; }

  /* Same swap on about.html, but only when main.js's referrer check finds
     we arrived from a project page -- it adds .has-back-context to <body>
     and points this same .nav-back at that project instead of index.html.
     The in-page .back-link--contextual stays hidden here; on mobile the
     header is the only place this back button lives, same as a project
     page. */
  body.has-back-context .nav-back { display: flex; }
  body.has-back-context .nav-logo { display: none; }
  body.has-back-context .back-link--contextual { display: none; }
}

.nav-meta {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 2px;
  /* measured off the logo's own text: the logo no longer carries trailing
     padding, so this margin is the whole gap */
  margin-left: var(--sp-4);
  font-family: var(--font-mono);
  /* was --fs-label (11px); dropped 2px, which lands on the existing --fs-micro
     step rather than inventing a 9px one-off */
  font-size: var(--fs-micro);
  line-height: 1.55;
  color: var(--fg-a72);
  white-space: nowrap;
}

.nav-meta .nm-indent { padding-left: var(--sp-6); }
.nav-meta .nm-key    { color: var(--fg); }
.nav-meta .nm-punct  { color: var(--fg-a45); }

/* The one link inside the header's code block. No underline (the global
   `a { text-decoration: none }` already handles that), so hover colour is the
   only affordance it gets -- same --accent move the card titles and the corner
   marks make, so it reads as part of the same vocabulary. */
.nav-meta .nm-link {
  color: inherit;
  transition: color var(--dur-fast) linear;
}

@media (hover: hover) and (pointer: fine) {
  .nav-meta .nm-link:hover { color: var(--accent); }
}

@media (max-width: 1080px) {
  .nav-meta { display: none; }
}

.nav-links {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  margin-left: auto;
}

.nav-btn {
  display: flex;
  align-items: center;
  padding: var(--sp-2) var(--sp-3);
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  font-weight: 700;
  letter-spacing: var(--ls-tight);
  text-transform: uppercase;
  color: var(--fg-muted);
  transition: color var(--dur-fast);
}

.nav-btn:hover { color: var(--fg); }

/* state is carried by colour now, so the dotted outline is gone */
.nav-btn.active { color: var(--fg); }

/* ============================================================
   PAGE HEADER (project detail pages)
   ============================================================ */
.page-header {
  position: relative;
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: var(--sp-12);
  max-width: 1400px;
  margin: 0 auto;
  padding: var(--sp-10) 0 var(--sp-8);
}

.header-copy {
  /* grows into whatever the stats bar does not need, so the title and
     descriptor run out toward the right margin instead of sitting at
     their own content width */
  flex: 1 1 auto;
  min-width: 0;
}

@media (max-width: 1450px) {
  /* stack: the stats column reads as a bar under the copy, not beside it.
     900px -> 1250px -> 1450px. The bar's cells are nowrap and cannot shrink,
     so side by side gives the h1 only (header width - bar - gap). .page-header
     caps at 1400px, and it reaches that cap at 1400 + 2 * 24px gutter = 1448px
     of viewport. Below that the header is narrower while the bar still takes
     its full natural width, so the title column collapses -- at 1280px it fell
     to 278px and broke FUTURE PIXELS OF AMERICA over four lines. 1450px means
     the two only sit side by side once the header is at full width, which is
     the only point at which the leftover column is predictable.
     align-items on the row layout is flex-end (baseline-matching the stats
     bar to the paragraph); once the axis turns vertical that same value
     means "flush right", which shoved the title off the left margin. */
  .page-header {
    flex-direction: column;
    align-items: stretch;
    /* Once stacked, the meta bar sits between the paragraph and the hero, so
       the gap above it and the padding below it are the same seam and have to
       match -- 32px below read as the hero drifting away from the bar. Both
       are --sp-5, one step tighter than the 24px they started from. */
    gap: var(--sp-5);
    padding-bottom: var(--sp-5);
  }
}


.page-header h1 {
  /* containing block for the .glitch-field the title-glitch builds inside it,
     and fit-content so that box hugs the type instead of running the full
     column width -- the hover target is then the words themselves, not the
     empty space to their right. Still a block, so the -0.16em lift below and
     the bottom margin behave exactly as before. */
  position: relative;
  width: fit-content;
  font-family: var(--font-display);
  font-size: var(--fs-display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: var(--ls-display);
  line-height: 0.95;
  color: var(--fg);
  /* cap height sits ~0.16em below the line box top in Barlow Condensed;
     this lifts it flush with the back tab's top border */
  margin-top: -0.16em;
  margin-bottom: var(--sp-4);
}

.page-header .descriptor {
  font-family: var(--font-body);
  font-size: var(--fs-h3);
  color: var(--fg);
  /* uncapped, this paragraph's max-content width squeezes .project-meta-bar
     to nothing and its nowrap cells overflow the page. 800px matches the
     body prose measure; below 900px the header stacks and this goes unused. */
  max-width: 800px;
  line-height: 1.6;
}

/* ============================================================
   PROJECT GRID
   ============================================================ */
.project-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--grid-gap);
}

@media (max-width: 720px) {
  .project-grid { grid-template-columns: 1fr; }
}

/* A third column above 1600px is NOT declared here -- it has to sit after the
   reveal stagger's :nth-child rules near the end of this file, because those
   carry the same (0,3,0) specificity and a media query adds none, so source
   order is the only thing that decides. See LARGE-DISPLAY GRID. */

/* ============================================================
   PROJECT CARD
   ============================================================ */
.project-card {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  /* Permanently transparent. It used to light up on hover, which was one
     reaction too many next to the glitch and the corner marks, so the colour
     change is gone -- but the 1px stays: the grid, the card's right-hand wall
     for the data strip, and .card-body's calc(--gutter - 1px) are all measured
     against it. */
  border: 1px solid transparent;
  overflow: hidden;
}


/* Corner registration marks — all four corners (2026-09-25; the top two were
   the whole set before).
   Four corners needs four pseudo-elements, so the bottom pair lives on the
   card's anchor. The anchor is position: static, so those two still resolve
   against .project-card (the nearest positioned ancestor) -- bottom: 8px is
   measured from exactly the same box as the top pair's top: 8px, inside the
   card's 1px border. Do not give the anchor position: relative; it would
   re-anchor them to a box 1px in and change nothing else.
   Absolute positioning also keeps them out of the anchor's flex flow -- an
   in-flow ::before there would become a flex item and push the image down. */
.project-card::before,
.project-card::after,
.project-card > a::before,
.project-card > a::after {
  content: '+';
  position: absolute;
  font-family: var(--font-mono);
  /* 11px read too faint at the card corners. Kept as a multiple of the token
     rather than a new hard-coded step, so it still tracks --fs-label. */
  font-size: calc(var(--fs-label) * 1.15);
  color: var(--fg-dim);
  line-height: 1;
  z-index: 2;
  /* hidden by default on EVERY device (2026-09-25). They used to be permanently
     visible on touch, because opacity: 0 lived in the fine-pointer block and
     there was no hover to bring them back. A phone reveals them on press
     instead -- see the PRESS STATE block. */
  opacity: 0;
  transition: color var(--dur-base), opacity var(--dur-base);
  pointer-events: none;
}
.project-card::before      { top: 8px;    left: 10px; }
.project-card::after       { top: 8px;    right: 10px; }
.project-card > a::before  { bottom: 8px; left: 10px; }
.project-card > a::after   { bottom: 8px; right: 10px; }

.project-card a {
  /* iOS paints a translucent grey box over the whole link on press, which read
     as an outline flashing on top of the touch glitch. Keyboard :focus-visible
     is unaffected. */
  -webkit-tap-highlight-color: transparent;
  display: flex;
  flex-direction: column;
  flex: 1;
}

.card-img {
  position: relative;
  overflow: hidden;
  aspect-ratio: 16 / 9;
  background: var(--bg-secondary);
}

.card-img img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.card-img--contain img {
  object-fit: contain;
}

/* Breathing room above and below contained art. The frame keeps its
   aspect-ratio height and the image gives up 64px.
   Flex, not the base `height: 100%`: a percentage height inside an
   aspect-ratio box does not reliably resolve against the content box, so it
   overflowed the padding on the hero. Stretch measures the content box. */
.card-img--inset {
  padding: var(--sp-8) 0;
  display: flex;
}

.card-img--inset img {
  height: auto;
  min-height: 0;
  align-self: stretch;
}


/* The anchor is the card's only flex child, so it has to pass the column
   layout through: without this the <a> sizes to its content and the slack a
   grid row hands the card pools underneath it, leaving the strip's hairlines
   stopping short of the card's bottom edge. */
.project-card > a {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
}

.card-body {
  /* Absorbs the row-equalisation slack. .card-img is locked to 16/9 and every
     card in a row is the same width, so the image can't take it -- meaning the
     body is the only place it can go, and putting it here makes every body in
     a row the same height even when one title has wrapped to two lines. The
     title is align-self: center, so it re-centres; the strip is stretch, so
     its dividers reach the card's bottom border. */
  flex: 1 1 auto;
  /* title and data strip share one row, strip pushed to the right edge */
  display: flex;
  align-items: stretch;
  justify-content: space-between;
  /* nowrap, and it matters more than it looks: flexbox assigns items to lines
     using their HYPOTHETICAL (max-content) size and only shrinks within a
     line, so with wrap a title one pixel too wide for the space beside the
     strip pushed the whole strip onto a second line instead of shrinking. That
     made those cards 56-71px taller, and grid rows equalise, so one long title
     grew its entire row. With nowrap the title gives way instead: it shrinks
     and wraps its own text, and the strip can never leave its side.
     Re-enabled at <=560px, where the strip becoming its own full-width row is
     the intended layout. */
  flex-wrap: nowrap;
  /* No vertical padding and none on the right: the children carry their own,
     so the strip's hairlines can run the full height of the bar and the group
     can reach the card's own border, which becomes its right wall. */
  /* minus the card's own 1px left border, so the TEXT lands on the gutter
     line rather than the border doing it 1px late */
  padding: 0 0 0 calc(var(--gutter) - 1px);
}

.card-title {
  /* may wrap or shrink; the strip never does */
  min-width: 0;
  align-self: center;
  /* the right padding is what separates the title from the strip */
  padding: var(--sp-4) var(--sp-5) var(--sp-5) 0;
  font-family: var(--font-display);
  font-size: var(--fs-card-title);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: var(--ls-display);
  line-height: 0.95;
  color: var(--fg);
  transition: color var(--dur-base);
}


/* CARD HOVER STATES ------------------------------------------------------
   Gathered here and gated on a real cursor. iOS applies :hover on a long press,
   so ungated these fired on touch: Jesse saw the image scale up under his
   finger, and a tapped card also stayed in its hover look after navigating
   back. The base rules carry the transitions, so nothing here shifts layout.

   The card border used to light up here too, removed 2026-09-23, and the image
   scaled to 1.04 on hover, removed 2026-09-25. What is left on the image is the
   cursor-tracked glitch alone.

   The corner marks are hidden until the pointer is over the IMAGE, not the
   card, so they arrive with the glitch rather than with the title colour. A
   pseudo-element cannot be selected from a descendant's state, hence :has() --
   the card is the ancestor, so it is the only way to reach up from .card-img.

   They start at opacity: 0 on the base rule, so this block only has to bring
   them back. */
@media (hover: hover) and (pointer: fine) {
  .project-card:has(.card-img:hover)::before,
  .project-card:has(.card-img:hover)::after,
  .project-card:has(.card-img:hover) > a::before,
  .project-card:has(.card-img:hover) > a::after {
    opacity: 1;
    color: var(--accent);
  }

  .project-card:hover .card-title { color: var(--accent); }
}

/* PRESS STATE (touch) ----------------------------------------------------
   The glitch covers the image half of the card; this covers the text half,
   which had nothing at all once iOS's grey tap-highlight was turned off.
   No media query: main.js's cardPress only ever adds .is-pressed on a touch
   device, so the gate lives in one place rather than two. */
.card-body {
  transition: opacity var(--dur-fast) linear;
}

.project-card > a.is-pressed .card-body {
  opacity: 0.35;
}

/* The corner marks come in with the press, the way they come in with hover on a
   mouse. The top pair lives on .project-card and the bottom pair on its anchor,
   so reaching the top two from the anchor's class means :has() going upward.
   They sit outside .card-body, so the 0.35 dim above does not touch them. */
.project-card:has(> a.is-pressed)::before,
.project-card:has(> a.is-pressed)::after,
.project-card > a.is-pressed::before,
.project-card > a.is-pressed::after {
  opacity: 1;
  color: var(--accent);
}

/* Data strip — YEAR and INDEX, inline with the card title, an enclosed
   compartment on the right of the bar: hairlines run the full bar height, the leading one walls
   it off from the title, and the card's own border closes it on the right.
   Cells are the shared INFO ROW component (see the block below the meta bar
   further down this file) -- .card-strip only owns this wide/inline layout
   and its own breakpoint; the narrow-viewport variant rules live with the
   component itself.

   TYPE was the third cell until 2026-09-25. It was the widest of the three
   (125px against 61px) and the strip never shrinks, so on a narrow card it was
   what pushed a long title to three and four lines -- and the project page
   states the same value again in its own meta bar. Dropped from
   partials/project-card.html rather than hidden in CSS; build.py still passes a
   {{type}} token, so restoring it is four lines of markup. */
.card-strip {
  display: flex;
  align-items: stretch;
  /* holds its full width so the title is what gives way, and stays right even
     when it has wrapped onto a line of its own */
  flex: 0 0 auto;
  margin-left: auto;
}

.card-strip .info-cell {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--sp-1);
  /* matches the title's vertical padding, so the bar height is unchanged */
  padding: var(--sp-4) var(--sp-3) var(--sp-5);
  /* on every cell: gives the leading wall and the dividers in one rule, and
     leaves the trailing edge to the card border */
  /* 0.5px, not 1px: on a 2x/3x screen a 1px divider is 2-3 device pixels and
     reads as a rule rather than a hairline. Sub-pixel borders antialias to a
     lighter line on 1x rather than disappearing, which is the same intent. */
  border-left: 0.5px solid var(--border);
}

/* the last value keeps the same optical gutter the title has on the left */
.card-strip .info-cell:last-child { padding-right: var(--sp-5); }

/* The strip used to drop to a full-width row of its own below 560px. Jesse
   asked for the desktop layout at every width (2026-09-25): title left, strip
   walled off on the right, hairlines running the bar's full height however many
   lines the title wraps to. That is just the base rules, so the overrides that
   used to live here are gone -- .card-body's `align-items: stretch` is what
   makes the dividers fill a two- or three-line bar, and its `flex-wrap: nowrap`
   is what makes the title, not the strip, give way. All that is left for narrow
   viewports is buying the title room back out of the strip's side padding. */
@media (max-width: 560px) {
  .card-strip .info-cell {
    padding-left: var(--sp-2);
    padding-right: var(--sp-2);
  }

  .card-strip .info-cell:last-child { padding-right: var(--sp-4); }

  .card-title { padding-right: var(--sp-4); }

  .card-strip.info-row--carousel .info-cell:first-child {
    padding-left: 0;
    border-left: none;
  }

  .card-strip.info-row--carousel {
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }

  .card-strip.info-row--carousel::-webkit-scrollbar { display: none; }

  /* tighter than the desktop cell: on a phone the strip is its own row under
     the title and was carrying more air than the two lines of text need,
     so 6px comes off the top and bottom each (-12px on the row). */
  .card-strip.info-row--carousel .info-cell {
    padding: var(--sp-2) var(--sp-4);
    min-width: min(44%, 160px);
    scroll-snap-align: start;
  }
}

/* ============================================================
   IMAGE CAROUSEL (project body)
   Native scroll + scroll-snap, not a transform track: the browser gets
   momentum, rubber-band and trackpad gestures for free, and with JS off
   the whole thing is still a scrollable set of images. JS only paints the
   counter and wires the two buttons.
   ============================================================ */
.carousel-track {
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  /* the bar below already states position, so the native bar is noise */
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.carousel-track::-webkit-scrollbar { display: none; }

/* a focused scroll container is how keyboard users page this, so it has to
   be visible when focused -- inset, or the outline sits under the slide */
.carousel-track:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.carousel-slide {
  /* basis 100%, not width: a flex item with flex-basis honours the snap
     alignment at every viewport without a JS measure */
  flex: 0 0 100%;
  scroll-snap-align: start;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: var(--bg-secondary);
}

.carousel-slide img {
  width: 100%;
  height: 100%;
  /* block, so the image is not an inline box sitting on a baseline inside a
     frame whose height is already fixed by aspect-ratio */
  display: block;
  /* contain, not cover: these are finished pieces at mixed ratios, and
     cropping somebody's artwork to fit a frame is the wrong default */
  object-fit: contain;
}

.carousel-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-top: 1px solid var(--border-light);
  padding: var(--sp-2) 0;
  font-family: var(--font-mono);
  font-size: var(--fs-data);
  letter-spacing: var(--ls-label);
  color: var(--fg-muted);
}

.carousel-nav {
  display: flex;
  gap: var(--sp-2);
}

.carousel-btn {
  appearance: none;
  background: none;
  border: 0;
  padding: 0 var(--sp-1);
  font: inherit;
  letter-spacing: inherit;
  color: var(--fg-muted);
  cursor: pointer;
  transition: color var(--dur-fast) ease, opacity var(--dur-fast) ease;
}

.carousel-btn:hover { color: var(--accent); }

.carousel-btn[disabled] {
  opacity: 0.3;
  cursor: default;
}

.carousel-btn[disabled]:hover { color: var(--fg-muted); }

/* On touch the swipe IS the affordance; two tiny arrow glyphs next to it
   are a redundant second control, so the counter carries the whole bar. */
@media (hover: none) and (pointer: coarse) {
  .carousel-nav { display: none; }
}

@media (max-width: 720px) {
  /* same reasoning as the hero: 16:9 is a thin strip on a phone */
  .carousel-slide { aspect-ratio: 4 / 3; }
}

@media (prefers-reduced-motion: reduce) {
  .carousel-track { scroll-behavior: auto; }
}

/* ============================================================
   PLACEHOLDER IMAGE (when no real img)
   ============================================================ */
.img-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  /* --bg-secondary, the same media surface the card and hero use, so an
     empty slot and a letterboxed one are the same colour. */
  background: var(--bg-secondary);
  /* the theme's own --fg-dim, not the on-black grey: this surface is now a
     shade of the page, so the palette's de-emphasis colour is the right one
     and it inverts to dark type on yellow and white for free. Floor is
     4.68:1 (white theme); yellow is 5.09:1. */
  color: var(--fg-dim);
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
}

/* ============================================================
   FOOTER
   ============================================================ */
footer {
  /* Locked to the bottom of the viewport rather than the bottom of the
     document (2026-09-25): the palette picker and the ticker are chrome, not
     an end-of-page sign-off, and reaching them used to mean scrolling a
     4000px project page to its end. The page scrolls underneath, so unlike
     the in-flow version this needs its own background -- otherwise content
     shows through the band. */
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  /* level with the sticky nav; the two bands can never overlap */
  z-index: 100;
  height: var(--footer-h);
  background: var(--bg);
  /* a rule above it so the footer reads as a band, not as trailing text */
  border-top: 1px solid var(--border-light);
  padding: 0 var(--gutter);
}

/* --- phones: the footer gets out of the way on the way down ---
   Same gesture and the same gate as the condensed header, so the two bands
   react as one piece of chrome rather than two. main.js sets .footer-hidden
   (see navCondense, which owns both classes).

   TRANSFORM ONLY, for the reason spelled out in the CONDENSED HEADER block:
   the fixed footer keeps its 57px box and body keeps its padding-bottom, so
   sliding it out relayouts nothing. Animating the height or dropping the
   padding would reflow the document on every frame and move the scroll
   position under the reader's thumb.

   Gated on (pointer: coarse) as well as width: a width-only query fires on a
   narrow desktop window, which has the viewport height to spare and no reason
   to hide its chrome. */
@media (max-width: 800px) and (pointer: coarse) {
  /* A shorter band (2026-09-25): 57px was sized for a copyright that was
     allowed to wrap to two lines. It middle-truncates on one line now, so the
     band only has to clear the 24px colour chips. Declared on body rather than
     :root so it inherits to <footer> and to body's own padding-bottom at once,
     the same way --nav-h is handed down in the condensed-header block. */
  body { --footer-h: 44px; }

  /* The transform goes on a pseudo element and on the row's contents, never on
     <footer> itself (fixed 2026-09-25). A transformed ancestor becomes the
     containing block for its position:fixed descendants, and below 720px the
     ticker is exactly that: a child of the footer in the markup that pins
     itself to the TOP of the screen. Transforming <footer> re-parented the
     band's coordinate system to the footer's own box, so it left the top of
     the screen entirely and the page showed through the 28px above the header.
     Same pattern, and the same reason, as nav.site-header::after below. */
  footer {
    background: none;
    border-top: none;
  }

  footer::before {
    content: "";
    position: absolute;
    /* footer's own border is gone, so inset:0 is the full 57px band and the
       pseudo's border-top lands exactly where the real one was */
    inset: 0;
    /* inside the footer's own stacking context: under the copyright and the
       chips, never behind the page */
    z-index: -1;
    background: var(--bg);
    border-top: 1px solid var(--border-light);
    transition: transform var(--dur-base) var(--ease-out);
  }

  .footer-left,
  .footer-right,
  .theme-picker {
    transition: transform var(--dur-base) var(--ease-out);
  }

  /* Same cure as the ticker's iteration flash: a transform promotes an element
     to its own layer for the length of the animation and then drops it again,
     and that round trip re-rasterises the band mid-scroll. Declaring the layer
     up front means it is never created or thrown away. */
  footer::before,
  .footer-left,
  .footer-right,
  .theme-picker {
    will-change: transform;
    backface-visibility: hidden;
  }

  /* --footer-h, not 100%: each of these is a different height, and they have to
     travel as one band */
  body.footer-hidden footer::before,
  body.footer-hidden .footer-left,
  body.footer-hidden .footer-right,
  body.footer-hidden .theme-picker {
    transform: translateY(var(--footer-h));
  }
}

/* 721-800px on a touch screen (a large tablet in portrait) is the one range
   where the footer hides AND the ticker is still in the footer row rather than
   pinned to the top, so there it travels with everything else. */
@media (min-width: 721px) and (max-width: 800px) and (pointer: coarse) {
  .footer-ticker { transition: transform var(--dur-base) var(--ease-out); }
  body.footer-hidden .footer-ticker { transform: translateY(var(--footer-h)); }
}

@media (prefers-reduced-motion: reduce) {
  footer::before,
  .footer-left,
  .footer-right,
  .theme-picker,
  .theme-swatch,
  .theme-swatch::after,
  .footer-ticker { transition: none; }
}

.footer-inner {
  display: flex;
  align-items: center;
  /* Full bleed to the gutters, not capped at the content measure
     (2026-09-25): now that the band is fixed chrome it should behave like the
     nav, whose .nav-inner is also uncapped, so the copyright sits on the left
     page margin and the palette chips on the right. Only bites above ~1448px;
     below that the cap was never reached. The ticker's `flex: 1 1 0` is what
     pushes the two ends apart -- no justify-content needed. */
  width: 100%;
  min-height: 56px;
  padding: var(--sp-4) 0;
}

/* The row has to be re-sized for the 44px band (see --footer-h in the footer
   block above). This override HAS to sit after the rule it overrides: a media
   query adds no specificity, so the first attempt at it -- up there next to the
   --footer-h declaration, a hundred lines earlier in the file -- simply lost to
   the 56px below it. The row stayed 56px tall inside a 44px band, and since it
   centres its own contents, everything sat about 6px low and overflowed the
   bottom edge. */
@media (max-width: 800px) and (pointer: coarse) {
  .footer-inner {
    /* height: 100% and no padding, rather than a smaller padding. With a 40px
       row of padding plus content sitting at the top of a 44px band there were
       4px of slack left underneath it and everything read 2px high. Filling the
       band hands the centring entirely to align-items: center, so the gap above
       and below is identical whatever --footer-h becomes. */
    min-height: 0;
    height: 100%;
    padding: 0;
  }
}

/* --- live ticker ---------------------------------------------------------
   Lives in the footer, right of the copyright, and so is on screen the whole
   time now that the footer is fixed. The track holds two identical groups and slides
   exactly -50%, i.e. one group's width, so the seam never shows regardless
   of how much copy is in content/projects.json.
   ------------------------------------------------------------------------ */
.footer-ticker {
  /* takes the leftover width and is allowed to shrink to nothing, so it can
     never push the copyright or the page link out of the row */
  flex: 1 1 0;
  min-width: 0;
  margin: 0 var(--sp-7);
  overflow: hidden;
  /* the copy has to enter and leave, not pop, so both edges fade out */
  mask-image: linear-gradient(to right, transparent, var(--black) 24px,
              var(--black) calc(100% - 24px), transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, var(--black) 24px,
              var(--black) calc(100% - 24px), transparent);
}

.ticker-track {
  display: flex;
  width: max-content;
  animation: ticker-scroll var(--ticker-duration, 34s) linear infinite;
  /* The groups are pixel-identical and the shift is exact to 0.001px, so the
     flash at the loop boundary was not a gap -- it was this 3557px masked
     element being re-rasterised when the animation iteration restarted.
     Promoting it once keeps the same layer alive across the wrap. */
  will-change: transform;
  backface-visibility: hidden;
}

/* let the eye stop on an item */
.footer-ticker:hover .ticker-track { animation-play-state: paused; }

.ticker-group {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
}

.ticker-item {
  font-family: var(--font-display);
  font-size: var(--fs-lead);
  font-weight: 700;
  letter-spacing: var(--ls-tight);
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--ticker-fg);
}

.ticker-sep {
  padding: 0 var(--sp-4);
  font-family: var(--font-display);
  font-size: var(--fs-lead);
  font-weight: 700;
  /* same colour as the items now, not a translucent step down: at 2:1 there is
     no room left for the separators to recede any further. */
  color: var(--ticker-fg);
}

@keyframes ticker-scroll {
  from { transform: translateX(0); }
  /* one copy's width; build.py sets the percentage from the copy count */
  to   { transform: translateX(var(--ticker-shift, -50%)); }
}

/* constant motion is exactly what this preference is about: park it */
@media (prefers-reduced-motion: reduce) {
  /* longhand on purpose: the `animation` shorthand serialises to `auto` here
     and stops reliably killing the name */
  .ticker-track { animation-name: none; }
}

.footer-left {
  /* The only child allowed to shrink. It used to be `0 0 auto`, which meant a
     narrow phone had to wrap the whole row and the colour chips dropped to
     their own line below the text; min-width: 0 is what actually permits a flex
     item to go below its content width. Grow stays at 0: with `1 1 auto` the
     copyright also absorbed half the leftover width and squeezed the ticker
     into the right-hand side of the row. */
  flex: 0 1 auto;
  min-width: 0;
  /* MIDDLE TRUNCATION (2026-09-25). It used to wrap to two lines instead, which
     is what forced the taller band. build.py splits the string into .fl-head
     (the copyright) and .fl-tail (the page context), and because only the head
     may shrink the ellipsis lands between them: a phone gets
     "(c) 2026 jesse pe... // project_01" rather than losing the page context off
     the end or spending a second line on it. Pure CSS -- the alternative was
     re-measuring the string in JS on every resize. */
  display: flex;
  white-space: nowrap;
  font-size: var(--fs-label);
  /* --ticker-fg, not --fg-dim: one text colour across the whole band, matched
     to the ticker it sits beside. Note this is per palette rather than
     literally darker -- blue, pink and black step down from --fg-dim, while
     yellow and white step UP, because in those two the ticker colour is the
     lighter of the pair. */
  color: var(--ticker-fg);
  letter-spacing: var(--ls-label);
}

.fl-head {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* pre, so the space that separates it from the head is not collapsed away at
   the start of an inline box */
.fl-tail {
  flex: 0 0 auto;
  white-space: pre;
}

.footer-right {
  flex: 0 0 auto;
  font-size: var(--fs-label);
  /* same as .footer-left: the band reads as one colour */
  color: var(--ticker-fg);
  letter-spacing: var(--ls-label);
}

/* The back link inherits the footer's own dim rather than the global link
   colour: at this scale an accent-blue arrow reads as the loudest thing in
   the band. */
.footer-right a { color: inherit; }

/* --- palette picker ------------------------------------------------------
   Rightmost thing in the footer, on every page. Three radios rather than a
   cycling button, so the choice is visible before it is made, and chips with
   no text labels: at footer scale three words of 11px uppercase read as
   content, while three squares read as a control. The name of each palette
   lives in title + aria-label instead.

   Chip colours are inline (--ts) because they are the literal swatch values,
   not tokens of the active theme -- the blue chip has to stay blue while the
   yellow theme is on. */
.theme-picker {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  /* no gap: the 24px hit boxes already leave 14px between the chips */
  gap: 0;
  /* auto, not a fixed margin: below 720px the ticker leaves the footer for
     the top band, so there is no flex child left to push this to the right
     edge. The padding is the minimum gap for when auto resolves to 0. */
  margin-left: auto;
  padding-left: var(--sp-4);
}

.theme-swatch {
  /* a 24px box holding a 10px chip: the chip is the design, the box is the
     WCAG 2.5.8 target size. The boxes sit edge to edge with no gap, so the
     tap areas never overlap even though the chips read as a tight group. */
  width: 24px;
  height: 24px;
  /* containing block for the [data-quip] tooltip below */
  position: relative;
  display: grid;
  place-items: center;
  padding: 0;
  border: none;
  background: none;
}

.theme-swatch::after {
  content: '';
  width: 10px;
  height: 10px;
  background: var(--ts);
  /* a hairline, so the blue chip still has an edge on blue and yellow on yellow */
  box-shadow: 0 0 0 1px var(--border);
  /* transform as well as box-shadow: the desktop collapse below scales the chip
     to nothing instead of clipping it, so the two have to travel together */
  transition: box-shadow var(--dur-fast) linear,
              transform var(--dur-base) var(--ease-out);
}

.theme-swatch:hover::after,
.theme-swatch:focus-visible::after { box-shadow: 0 0 0 1px var(--fg); }
.theme-swatch:focus-visible { outline: none; }

/* the selected chip gets a ring held off the chip by a gap in the page's own
   colour, which reads as selection without a tick mark inside a 10px box */
.theme-swatch[aria-checked="true"]::after {
  box-shadow: 0 0 0 1px var(--bg), 0 0 0 3px var(--fg);
}

/* Easter egg: the white and black chips carry data-quip instead of title, and
   it renders as a real tooltip rather than the browser's. Pointer-gated, so a
   phone never gets a hover-only joke it cannot dismiss -- and because these two
   chips have no title, nothing double-tips. The accessible name still comes
   from aria-label on all five.
   Anchored right, not centred: the black chip is the last thing in the footer,
   so a centred bubble would hang off the page edge. Own colours, not tokens:
   the joke is a black bubble in every palette, with a token hairline so it
   still has an edge on the black theme's own black page. */
@media (hover: hover) and (pointer: fine) {
  .theme-swatch[data-quip]::before {
    content: attr(data-quip);
    position: absolute;
    bottom: calc(100% + var(--sp-2));
    right: 0;
    z-index: 2;
    padding: 6px var(--sp-2);
    border: 1px solid var(--border);
    background: var(--black-abs);
    color: var(--on-black-fg);
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    letter-spacing: var(--ls-label);
    text-transform: none;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--dur-fast) linear;
  }

  .theme-swatch[data-quip]:hover::before,
  .theme-swatch[data-quip]:focus-visible::before { opacity: 1; }
}

/* On a narrow phone the copyright plus the page's back link fill the row. This
   block used to set flex-wrap: wrap, which fixed a 25-80px document overflow
   but did it by dropping the chips onto a second line -- the chips are the
   control, so they stay put and .footer-left wraps instead (see its flex).
   row-gap survives for the case where the copyright itself wraps. */
@media (max-width: 560px) {
  .footer-inner { row-gap: var(--sp-3); }

  .theme-picker { padding-left: var(--sp-2); }
}

/* --- phones: the picker collapses to one chip (2026-09-25) ----------------
   Five chips is 120px of a 390px footer row, spent on a control that is used
   once. Collapsed it shows only the chip that is currently on, and a tap opens
   the other four.

   It grows LEFTWARD with no absolute positioning: .theme-picker is the last
   flex item in the row and carries margin-left: auto, so its right edge is
   already pinned to the gutter and everything it gains has to come out of the
   space on its left.

   width, not display: none, because the open has to be visible as a move
   rather than a pop. The cost is a relayout of the footer's own flex row --
   six nodes in a fixed 57px band, which is nothing like the whole-document
   reflow the header block warns about. overflow: hidden is what actually makes
   a 10px chip disappear inside a 0px box, and it is only safe in here: the
   [data-quip] tooltip hangs outside the same box and would be clipped, but
   that is gated to a fine pointer and can never coexist with this.

   main.js's themePicker owns .is-open -- the first tap only opens, so nothing
   changes theme by accident on the way to the chip you wanted. */
@media (max-width: 800px) and (pointer: coarse) {
  .theme-swatch {
    overflow: hidden;
    transition: width var(--dur-base) var(--ease-out);
  }

  /* The chip that is ON is always the rightmost one, so the corner never changes
     which chip it is holding: opening slides the other four out to its LEFT and
     it stays exactly where your thumb just was. Before this, expanding with blue
     selected threw the ring to the far left, because blue is first in the
     palette's own sequence.
     `order` reorders the flex line only. The DOM, the radiogroup and the arrow
     keys all still see blue/yellow/pink/white/black in the real order, and the
     other four keep their relative order because they stay at the default 0.
     The desktop hover collapse below needs the same pin for the same reason,
     so this is stated in both blocks rather than hoisted: a wide TOUCH screen
     (over 800px, coarse pointer) collapses in neither, and there a reorder with
     no collapse behind it would be a change for nothing. */
  .theme-swatch[aria-checked="true"] { order: 1; }

  .theme-picker:not(.is-open) .theme-swatch:not([aria-checked="true"]) {
    width: 0;
  }

  /* no selection ring while collapsed: with nothing to compare it to, a ring
     around the only chip on screen reads as a state rather than a choice */
  .theme-picker:not(.is-open) .theme-swatch[aria-checked="true"]::after {
    box-shadow: 0 0 0 1px var(--border);
  }
}

/* --- desktop: the same collapse, opened by hover (2026-09-30) --------------
   Same argument as the phone: five chips is a permanent control for a choice
   made once, sitting in the corner of every page. Collapsed it shows only the
   live palette, and pointing at it slides the other four out to the left.

   Differences from the touch block above, all forced:

   1. overflow: hidden is NOT used here. On touch it is what makes a 10px chip
      vanish inside a 0px box, but the [data-quip] tooltip on the white and
      black chips is pointer-gated to exactly this media query and hangs
      ABOVE the swatch -- clipping is the one thing that cannot happen here.
      So the chip scales to nothing instead, which paints nothing outside the
      collapsed box and leaves the tooltip free. (overflow-x: hidden with
      overflow-y: visible is not an option: the pair computes to auto.)
   2. :has(.theme-swatch:focus-visible) as well as :hover, or a keyboard could
      never reach the four collapsed chips -- they would be 0px wide buttons.
      Tab still follows DOM order while `order` moves the live chip last, which
      is the same visual/tab mismatch the touch block already carries.
      It was plain :focus-within first, and that kept the picker open after a
      MOUSE click on a chip: the button stays focused, so moving the pointer
      away changed nothing and it only collapsed when the whole window lost
      focus. :focus-visible is the distinction that matters here -- a browser
      does not set it on a button clicked with a mouse, only on one reached by
      keyboard -- and it needs :has() because there is no :focus-visible-within.
      If :has() were missing the three selectors would be invalid and dropped
      together, which leaves all five chips showing: the behaviour from before
      this block existed.
   3. No .is-open and no JS. On touch the first tap only opens, so nothing
      changes theme on the way to the chip you wanted; a hover has no such
      cost, so the CSS does the whole job. main.js is untouched. */
@media (hover: hover) and (pointer: fine) {
  .theme-swatch { transition: width var(--dur-base) var(--ease-out); }

  /* the live chip is pinned rightmost so the corner never changes which chip it
     is holding -- see the reasoning in the touch block above */
  .theme-swatch[aria-checked="true"] { order: 1; }

  .theme-picker:not(:hover):not(:has(.theme-swatch:focus-visible))
    .theme-swatch:not([aria-checked="true"]) {
    width: 0;
  }

  /* scaled, not clipped -- see note 1 above */
  .theme-picker:not(:hover):not(:has(.theme-swatch:focus-visible))
    .theme-swatch:not([aria-checked="true"])::after {
    transform: scale(0);
  }

  /* and no ring on the one chip left, same as the touch block */
  .theme-picker:not(:hover):not(:has(.theme-swatch:focus-visible))
    .theme-swatch[aria-checked="true"]::after {
    box-shadow: 0 0 0 1px var(--border);
  }
}

/* ============================================================
   ABOUT PAGE
   ============================================================ */
.about-blocks {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: start;
  gap: var(--grid-gap);
}

.about-col {
  display: flex;
  flex-direction: column;
  gap: var(--grid-gap);
}

@media (max-width: 800px) {
  .about-blocks { grid-template-columns: 1fr; }

  /* Stacked, the two columns would run end-to-end: everything on the left
     (about, skills, colophon) and only then the right (experience,
     education, contact). display: contents dissolves both wrappers so all
     six blocks become direct grid items and can be ordered freely, which
     lets the page read as a CV on a phone -- who, then history, then the
     supporting detail -- while the desktop two-column pairing is untouched.
     --reveal-i is re-stated here so the staggered fade still runs top to
     bottom in the NEW order rather than in DOM order. */
  .about-col { display: contents; }

  .blk--about      { order: 1; --reveal-i: 0; }
  .blk--experience { order: 2; --reveal-i: 1; }
  .blk--education  { order: 3; --reveal-i: 2; }
  .blk--skills     { order: 4; --reveal-i: 3; }
  .blk--contact    { order: 5; --reveal-i: 4; }
  .blk--colophon   { order: 6; --reveal-i: 5; }
}

/* --- black section block --- */
.blk {
  /* Foreground tokens for this black surface are set in THEMES, with the
     other black islands. Cooler, bluer greys in there: #C7C7FF read
     violet because R==G with B pushed up; stepping R<G<B reads as slate. */
  background: var(--black);
  /* vertical stays 32px: that is this block's own breathing room, not a
     gutter. Horizontal is the gutter, since on desktop the left column's
     block sits at x=0 and its padding IS the page inset. */
  padding: var(--sp-8) var(--gutter);
}

.blk-title {
  font-family: var(--font-display);
  font-size: var(--fs-h1);
  font-weight: 800;
  line-height: 1;
  color: var(--fg);
  margin-bottom: var(--sp-6);
}

.blk p {
  font-size: var(--fs-body);
  line-height: 1.9;
  color: var(--fg);
  margin-bottom: var(--sp-5);
  /* project-03 quotes a long dotted design token; without this it would
     push past the column on a phone, since browsers won't break at a `.` */
  overflow-wrap: break-word;
}

/* the leading `//` stays muted so it reads as a comment marker, not as copy */
.blk p .p-comment { color: var(--fg-muted); }

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

/* --- portrait --- */
.about-portrait {
  width: 200px;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  background: var(--bg-secondary);
  margin-bottom: var(--sp-8);
  /* The portrait is a glitch frame (GLITCH_FRAMES in main.js), so it has to be
     a containing block for the absolutely positioned .glitch-field. */
  position: relative;
  /* A press here is the effect, not a selection: iOS otherwise shows the
     callout and a text-selection flash over a held image. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}

.about-portrait img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Fixed width, so on a phone it does not shrink with the column: 200px is
   53% of a 375px screen and read as a headshot dominating the page. 160px
   keeps it a portrait sitting beside the copy. */
@media (max-width: 720px) {
  .about-portrait { width: 160px; }
}

/* .about-portrait needs no .img-placeholder override: every declaration
   it used to carry was already identical to the base component. */

/* --- code block (experience / education) ---
   Deliberately NOT real GDScript, unlike .nav-meta in the masthead. A literal
   GDScript Dictionary was built and reverted on 2026-09-24: valid, but it read
   too on-the-nose. This is stylised pseudo-code and should stay that way. */
.code-block {
  font-family: var(--font-mono);
  font-size: var(--fs-body);
  line-height: 1.85;
  color: var(--fg);
}

.code-block .cb-brace { color: var(--fg-a55); }

.cb-entry { margin: 0 0 var(--sp-5); }
.cb-entry:last-of-type { margin-bottom: 0; }

.cb-entry .cb-org  { color: var(--fg); }
.cb-entry .cb-op   { color: var(--fg-a55); }
.cb-entry .cb-role { color: var(--fg); }
.cb-entry .cb-team { color: var(--fg-muted); }
.cb-entry .cb-span { color: var(--fg); }

/* --- contact --- */
.contact-list {
  display: flex;
  flex-direction: column;
  gap: 0;
}

.contact-item {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  padding: var(--sp-3) 0;
  border-bottom: 1px solid var(--border-light);
}

.contact-item:first-child { padding-top: 0; }
.contact-item:last-child { border-bottom: none; padding-bottom: 0; }

.contact-item .kv-val a:hover { text-decoration: underline; }

/* --- colophon ---
   Spec-sheet rows: key hard left, value hard right, hairline between.
   Same language as the project card strip, so the site describes itself
   in the same voice it describes the work. */
.colophon-list {
  display: flex;
  flex-direction: column;
  gap: 0;
}

.colo-item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-4);
  padding: var(--sp-3) 0;
  border-bottom: 1px solid var(--border-light);
}

.colo-item:first-child { padding-top: 0; }
.colo-item:last-child  { border-bottom: none; padding-bottom: 0; }

/* two columns cannot share a narrow line without the value wrapping oddly */
@media (max-width: 520px) {
  .colo-item { flex-direction: column; gap: var(--sp-1); }
  .kv-val--num { text-align: left; }
}

/* --- skills --- */
.skill-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 2px;
}

.skill-item {
  font-size: var(--fs-data);
  color: var(--fg-muted);
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--border-light);
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}

/* ============================================================
   PROJECT DETAIL PAGE
   ============================================================ */
.project-hero {
  max-width: 1400px;
  margin: 0 auto;
}

.project-hero-img {
  width: 100%;
  aspect-ratio: 21 / 9;
  overflow: hidden;
  /* No bottom rule (2026-09-25): the frame is already a different surface from
     the page, and a full-bleed hero supplies its own edge, so the hairline was
     a second boundary on top of one that already read. */
  background: var(--bg-secondary);
}

@media (max-width: 720px) {
  /* Taller frame on mobile -- 21:9 reads as a thin strip on a narrow
     screen, so give the art more vertical room without going portrait. */
  .project-hero-img { aspect-ratio: 4 / 3; }
}

.project-hero-img img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* For art that is squarer than the frame: show it whole, let black letterbox */
.project-hero-img--contain img {
  object-fit: contain;
}

/* see .card-img--inset for why this is flex and not `height: 100%` */
.project-hero-img--inset {
  padding: var(--sp-8) 0;
  display: flex;
}

.project-hero-img--inset img {
  height: auto;
  min-height: 0;
  align-self: stretch;
}

/* Horizontal strip of cells beside the title/descriptor, same cell
   language the bar used when it ran full-width under the hero. Fills
   whatever space is left of the paragraph, out to the hero's right edge,
   and sits flush with the paragraph's baseline (see align-items: flex-end
   on .page-header). */
.project-meta-bar {
  display: flex;
  flex-wrap: nowrap;
  gap: 0;
  /* the cells are `white-space: nowrap` and cannot shrink, so the bar takes
     its natural width and never flexes; .header-copy absorbs the rest.
     Below 1450px it goes full-width on its own row instead. */
  flex: 0 0 auto;
  align-content: flex-start;
  /* 0.5px hairlines, matching the home page's .card-strip: see the comment on
     its .info-cell border-left. */
  border-top: 0.5px solid var(--border);
  border-bottom: 0.5px solid var(--border);
}

@media (max-width: 1450px) {
  .project-meta-bar { width: 100%; flex: none; flex-wrap: wrap; }
}

.project-meta-bar .info-cell {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  padding: var(--sp-4) var(--sp-6);
  border-right: 0.5px solid var(--border-light);
  flex: 0 0 auto;
  white-space: nowrap;
}

.project-meta-bar .info-cell:last-child { border-right: none; }
.project-meta-bar .info-cell:first-child { padding-left: 0; }

/* ============================================================
   KEY / VALUE PAIR
   One small-caps label plus its value. Three containers use it:
   the card strip and project meta bar (.info-cell), the about
   page's contact list (.contact-item) and its colophon
   (.colo-item). Until 2026-09-22 this was three byte-identical
   copies named .info-key / .c-key / .colo-key.
   The key's colour is --fg-dim, which the about page's black
   blocks re-point to --on-black-dim, so the pair inverts itself
   with no extra rule here.
   ============================================================ */
.kv-key {
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--fg-dim);
}

.kv-val {
  font-family: var(--font-mono);
  font-size: var(--fs-data);
  color: var(--fg);
}

/* contact rows read as body copy, not as a data field */
.kv-val--body { font-size: var(--fs-body); }

/* colophon rows: key hard left, value hard right, digits aligned */
.kv-key--fixed { flex: 0 0 auto; }
.kv-val--num {
  flex: 1 1 auto;
  min-width: 0;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* ============================================================
   INFO ROW — reusable key/value strip component
   Shared by this meta bar and the project card's data strip (see
   .card-strip further up). The cells hold the shared .kv-key /
   .kv-val pair; each wrapper still owns its OWN
   wide-viewport (inline, beside-the-title) layout and its own
   breakpoint for when that stops fitting. Past that point every
   consumer picks exactly one of two variants by class:

     .info-row--static     one full-width row of equal cells, each
                            free to wrap; nothing ever scrolls
     .info-row--carousel   horizontal scroll with snap, cells sized
                            so the next one is always cut mid-cell

   Plain CSS can't parameterize a media query's width, so the variant
   rule bodies are repeated once per consumer's own breakpoint -- but
   the declarations inside are kept identical wherever they appear;
   that repetition, not the class names, is the only thing per-usage.

   Settled 2026-09-22: the meta bar uses --carousel and the card
   strip uses --static, each permanently. The bar's own --static
   rules and the ?bar=static switch that compared them are gone. */
/* Below 760px the meta cells stop fitting on one line (they need ~713px of
   viewport), and a wrapped flex row is broken: each new row began at the
   second cell's 24px padding, so rows 2+ sat indented off the title's left
   margin, the vertical rules never lined up column to column, and whichever
   cell ended a row left a hairline pointing at nothing -- hence the variant
   system above. 760px is deliberate: above it the single-row bar fits, and
   is untouched. */
@media (max-width: 760px) {

  .project-meta-bar.info-row--carousel {
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    /* without this, swiping the strip triggers Safari's back-swipe */
    overscroll-behavior-x: contain;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    /* Full-bleed: .page-header gives this a --sp-4 gutter below 800px, and a
       scroller that stops at that gutter reads as a clipped row rather than
       one that continues. Cancel the gutter with negative margins and hand it
       back as the scroller's own padding, so the first cell still lines up
       with the title while cells run off both screen edges.
       scroll-padding keeps snapped cells off the left edge. */
    /* the <=1450px rule pins this to width:100%, which with negative margins
       only slides the bar left instead of widening it; auto lets the column's
       align-items:stretch resolve the width against the negative margins. */
    width: auto;
    margin-inline: calc(var(--gutter) * -1);
    padding-inline: var(--gutter);
    scroll-padding-inline: var(--gutter);
  }

  .project-meta-bar.info-row--carousel::-webkit-scrollbar { display: none; }

  .project-meta-bar.info-row--carousel .info-cell {
    /* rounds DOWN to 12px, not up to 16px: mobile bar heights were
       deliberately trimmed, so the scale must not give the height back */
    padding: var(--sp-3) var(--sp-4);
    /* wide enough that the next cell is always cut through its middle
       rather than at a border: that cut is the only affordance a scroller
       like this gets, so it has to be unmistakable. Capped at 160px so wider
       phones do not inflate every cell and invent extra scroll distance. */
    min-width: min(44%, 160px);
    scroll-snap-align: start;
  }

  .project-meta-bar.info-row--carousel .info-cell:first-child { padding-left: 0; }
}

/* Wraps page-header + hero + body so the back rail's containing block is
   exactly as tall as the whole project page: that is what lets
   .back-link--rail stay sticky all the way down, not just within
   .page-header's own short box. 76px is added on top of the usual 1400px
   so .project-frame-main still renders at the old 1400px once the
   viewport is wide enough to hit the max-width; narrower than that, the
   rail eats into the same space page-header/hero/body already give up at
   that width, same as it always has. */
.project-frame {
  display: grid;
  grid-template-columns: 76px 1fr;
  max-width: 1476px;
  margin: 0 auto;
}

.back-rail {
  grid-row: 1 / -1;
  position: relative;
  display: flex;
  justify-content: flex-start;
  /* flex default is align-items: stretch, which was stretching the sticky
     link to fill this whole (page-height-tall) column instead of sizing
     to its own content -- that is the "back button expands down the
     whole page" bug. */
  align-items: flex-start;
}

.project-frame-main {
  min-width: 0;
}

@media (max-width: 800px) {
  .project-frame { grid-template-columns: 1fr; max-width: 1400px; }
}

.project-body {
  display: grid;
  grid-template-columns: 280px 1fr;
  /* No bottom rule (2026-09-25): it landed directly on the fixed footer's own
     border-top and read as one doubled hairline -- and an inset one against a
     full-bleed one, so the two did not even agree on width. The footer's rule
     is the page's bottom edge now. */
}

@media (max-width: 800px) {
  .project-body { grid-template-columns: 1fr; }
}

.project-sidebar {
  border-right: 1px solid var(--border);
  padding: var(--sp-8) var(--sp-6) var(--sp-8) 0;
  display: flex;
  flex-direction: column;
  gap: var(--sp-8);
}

.project-content {
  /* left gutter clears the sidebar rule; right side runs out to the
     container edge so the prose is as wide as the hero above it */
  padding: var(--sp-12) 0 var(--sp-12) var(--sp-14);
}

/* In-page section index. Sticky on the inner nav, not on .project-sidebar
   itself, so the sidebar's border-right still runs the full column height. */
.project-index {
  position: sticky;
  top: calc(var(--nav-h) + 32px);
}

.pindex-list {
  list-style: none;
}

.pindex-list li {
  border-bottom: 1px solid var(--border-light);
}

/* dividers between rows only, no rule above the first or below the last */
.pindex-list li:last-child { border-bottom: 0; }

.pindex-list a {
  display: flex;
  align-items: baseline;
  gap: var(--sp-3);
  padding: var(--sp-2) 0;
  font-size: var(--fs-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  /* full white, not --fg-muted: at 11px uppercase the lavender tint lost too
     much contrast against the blue to read comfortably, and this list is
     navigation rather than supporting copy. The `+` prefix keeps the dim
     tone, so the label still reads as the emphasis in the row. */
  color: var(--fg);
  text-decoration: none;
  transition: color var(--dur-base);
}

/* No icon set on this site -- the back-arrow SVG is the only hand-drawn
   icon anywhere. This reuses the same "plain glyph" approach already used
   for the hover arrow below, rather than a mismatched one-off icon. */
.pindex-list a::before {
  content: "+";
  flex: none;
  width: 18px;
  font-size: var(--fs-body);
  color: var(--fg-dim);
}

.pindex-list a::after {
  content: "\2193";
  margin-left: auto;
  padding-left: var(--sp-2);
  font-size: var(--fs-h3);
  color: var(--fg);
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity var(--dur-base) ease, transform var(--dur-base) ease;
}

.pindex-list a:hover,
.pindex-list a:focus-visible { color: var(--fg); }

.pindex-list a:hover::after,
.pindex-list a:focus-visible::after {
  opacity: 1;
  transform: translateY(0);
}

/* anchor jumps must clear the sticky nav */
.project-content h2 { scroll-margin-top: calc(var(--nav-h) + 24px); }

@media (max-width: 800px) {
  /* single column: the index sits above the prose, back to a plain
     stacked list -- see .project-index-pinned for the horizontal strip
     that takes over once this one scrolls out of view. */
  .project-index { position: static; }
}

/* ============================================================
   PINNED SECTION INDEX (mobile only)
   Duplicate of .project-index's <ol>, fixed under the header. Hidden by
   default; main.js's IntersectionObserver adds .is-visible once the
   in-flow index above has scrolled past, and removes it when scrolling
   back up re-reveals the original list. Never used above 800px -- the
   real sidebar is already sticky there.
   ============================================================ */
.project-index-pinned { display: none; }

@media (max-width: 800px) {
  .project-index-pinned {
    display: block;
    position: fixed;
    top: var(--nav-h);
    left: 0;
    right: 0;
    z-index: 90;
    background: var(--bg);
    border-bottom: 1px solid var(--border-light);
    transform: translateY(-100%);
    opacity: 0;
    pointer-events: none;
    transition: transform var(--dur-slow) ease, opacity var(--dur-slow) ease;
  }

  .project-index-pinned.is-visible {
    transform: translateY(0);
    opacity: 1;
    pointer-events: auto;
  }

  .project-index-pinned .pindex-list {
    display: flex;
    flex-direction: row;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    padding: 0 var(--gutter);
  }

  .project-index-pinned .pindex-list::-webkit-scrollbar { display: none; }

  .project-index-pinned .pindex-list li {
    flex: none;
    white-space: nowrap;
    border-bottom: 0;
    border-right: 1px solid var(--border-light);
  }

  .project-index-pinned .pindex-list li:last-child { border-right: 0; }

  .project-index-pinned .pindex-list a { padding: var(--sp-3) var(--sp-4); }

  /* the hover down-arrow is meaningless with no hover on touch */
  .project-index-pinned .pindex-list a::after { display: none; }

  /* clear the pinned bar once it's showing, not just the site header --
     harmless when it's hidden since scroll-margin only matters on jump */
  .project-content h2 { scroll-margin-top: calc(var(--nav-h) + 52px); }
}

.project-content h2 {
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  font-weight: 800;
  line-height: 1;
  /* condensed caps need far less tracking than the 9px mono labels did */
  letter-spacing: var(--ls-tight);
  text-transform: uppercase;
  color: var(--fg);
  margin-bottom: var(--sp-4);
  margin-top: var(--sp-12);
}

.project-content h2:first-child { margin-top: 0; }

.project-content p {
  font-family: var(--font-body);
  font-size: var(--fs-h3);
  line-height: 1.6;
  /* full-width prose, but an unconstrained 1300px column is unreadable;
     800px is the widest that still holds a sane line length */
  max-width: 800px;
  color: var(--fg);
  margin-bottom: var(--sp-5);
}

/* Ordered body list — spec-sheet language borrowed from the card data strip:
   a hairline per row, the index hard-left in small mono. Baseline flex
   alignment keeps the 9px number sitting on the 13px text's first baseline
   without hardcoding a line-height. */
.project-content .body-list {
  list-style: none;
  counter-reset: bl;
  margin-bottom: var(--sp-4);
  border-top: 1px solid var(--border-light);
}

.project-content .body-list li {
  counter-increment: bl;
  display: flex;
  align-items: baseline;
  gap: var(--sp-4);
  padding: var(--sp-4) 0;
  border-bottom: 1px solid var(--border-light);
  font-family: var(--font-body);
  font-size: var(--fs-h3);
  line-height: 1.6;
  color: var(--fg);
}

.project-content .body-list li::before {
  content: counter(bl, decimal-leading-zero);
  flex: none;
  width: 28px;
  font-size: var(--fs-lead);
  letter-spacing: var(--ls-label);
  color: var(--fg-dim);
}

.project-content .project-img-block {
  margin: var(--sp-8) 0;
  overflow: hidden;
}

/* DIRECT children only. A carousel also lives in a .project-img-block, and the
   descendant form of this selector outspecified `.carousel-slide img`, so every
   slide got height: auto, rendered at its own intrinsic ratio -- 1064px tall in
   a 599px frame -- and overflow: hidden cut the bottom 44% off somebody's
   artwork. A figure's img is a direct child of the block (inside <picture> when
   it has a mobile variant), a slide's never is. */
.project-content .project-img-block > img,
.project-content .project-img-block > picture img {
  width: 100%;
  /* height: auto is what makes the width/height ATTRIBUTES build.py now writes
     usable here. A figure deliberately has no aspect-ratio -- it keeps whatever
     shape its file has -- so it is the one frame that collapsed to nothing and
     then shoved the page down when the image landed. The attributes give the
     browser the ratio up front, and without `auto` the height attribute would
     instead be taken as a literal pixel height and stretch every figure. */
  height: auto;
  display: block;
}

.project-content .img-caption {
  font-family: var(--font-mono);
  /* Two steps up from --fs-micro and one step brighter than --fg-dim.
     9px uppercase mono at 0.1em tracking was unreadable on a phone, and
     --fg-dim on --bg-secondary is only 5.67:1; --fg-muted is 6.52:1.
     The caption still sits below body copy in the hierarchy, but now by
     size and tracking rather than by being faint. */
  font-size: var(--fs-data);
  /* Sentence case, not caps: these captions are read, not scanned. Tracking
     comes down with the caps -- 0.1em is set for uppercase, and letterspaced
     lowercase pulls words apart. 0.01em is the same tracking the wordmark
     uses. */
  letter-spacing: var(--ls-tight);
  color: var(--fg-muted);
  /* No tint plate and no outer frame: the image's own edge is the boundary,
     and a filled caption strip read as a separate UI chrome element. A single
     hairline carries the separation. */
  padding: var(--sp-2) 0;
  border-top: 1px solid var(--border-light);
}

/* ============================================================
   UTILITIES
   ============================================================ */
/* Present for screen readers and search engines, absent from the layout.
   The index page's only real heading is the card grid, which has to stay
   visually silent -- the masthead already says the name. clip-path rather
   than display:none, which would drop it from the accessibility tree too. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ============================================================
   BACK LINK
   ============================================================ */
/* Shared visual base -- border, colour, hover -- for every place a back
   link appears. Layout (row vs. the sticky rail's column) is added by
   whichever modifier class is paired with this one. */
.back-link {
  display: flex;
  width: fit-content;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  border: 1px dotted var(--fg-a45);
  color: var(--fg-a65);
  /* --bg is the PAGE background, which is wrong inside a black island: the
     contextual variant on about.html sits inside .blk--about, where the island
     rule has already forced the type token to translucent white, so painting
     the page colour behind it gave white-on-yellow (1.15:1) and, once the
     white theme existed, white-on-white (1.0:1). Inside an island the surface
     is already the right colour, so paint nothing. */
  background: var(--bg);
  transition: color var(--dur-base), border-color var(--dur-base);
}

.blk .back-link { background: transparent; }

.back-link:hover {
  color: var(--fg);
  border-color: var(--fg);
}

.back-link svg {
  width: 22px;
  height: 11px;
  flex: none;
}

.back-link .back-label {
  font-family: var(--font-mono);
  font-size: var(--fs-lead);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  line-height: 1;
}

/* Contextual variant on about.html: only shown when main.js confirms (via
   document.referrer) that the visitor arrived from a project page, then
   points this link back at that exact project instead of a generic one.
   Sits in normal flow above the h1, same spot a project page's back link
   used to occupy before it moved into the rail below. */
.back-link--contextual {
  display: none;
  margin-bottom: var(--sp-5);
}

.back-link--contextual.is-visible {
  display: flex;
}

/* Rail variant on project pages: icon on top, "BACK" rotated below it,
   in the sticky left rail (see .project-frame / .back-rail). margin-top
   matches .page-header's padding-top so its resting position -- before
   any sticky offset kicks in -- lines up flush with the h1's top; the
   sticky `top` then matches .project-index's anchor once you scroll past
   that point, so both sticky elements settle at the same height. */
.back-link--rail {
  position: sticky;
  top: calc(var(--nav-h) + 32px);
  margin-top: var(--sp-8);
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-3);
  /* Pinned to the left of the (now 76px) rail rather than centred in it: that
     is what puts 12px more air between the tab and the section index beside
     it while keeping the tab's own distance from the window edge unchanged.
     Centring instead would have pushed the left dotted border off-canvas on
     any window narrower than .project-frame's max-width. */
  margin-left: var(--sp-3);
}

.back-link--rail svg {
  width: 16px;
  height: 8px;
}

/* vertical-rl alone reads as a clockwise-rotated label, no transform
   needed (see how.md) */
.back-link--rail .back-label {
  writing-mode: vertical-rl;
}

@media (max-width: 800px) {
  .back-rail { display: none; }
}

/* ------------------------------------------------------------
   Narrow viewports: the 1400px container is flush to the
   window edge, so restore a gutter for text blocks.
   ------------------------------------------------------------ */
@media (max-width: 800px) {
  .page-header { padding-left: var(--gutter); padding-right: var(--gutter); }
  /* footer already carries --gutter unconditionally */
  .project-sidebar { padding-left: var(--gutter); }
  /* single column here, so there is no sidebar rule to clear -- the 56px
     gutter would eat 29% of a 390px screen */
  .project-content { padding: var(--sp-10) var(--gutter); }
}

/* ============================================================
   CURSOR — '+' registration mark
   A real DOM element, not the native `cursor:` property: engines
   silently ignore custom cursor images in too many situations.
   The native cursor is only hidden once JS has built the follower,
   so with JS off you still get a normal pointer.
   ============================================================ */
.has-plus-cursor .cursor-plus,
.has-plus-cursor .cursor-plus * {
  cursor: none;
}

#plus-cursor {
  position: fixed;
  top: 0;
  left: 0;
  width: 22px;
  height: 22px;
  z-index: 9999;
  pointer-events: none;
  opacity: 0;
  transform: translate3d(-100px, -100px, 0);
  transition: opacity var(--dur-fast) linear;
  will-change: transform;
}

#plus-cursor.is-visible { opacity: 1; }

/* two crisp bars rather than a glyph, so it never sits on a half pixel */
#plus-cursor::before,
#plus-cursor::after {
  content: '';
  position: absolute;
  background: var(--fg);
}

#plus-cursor::before {
  top: 50%;
  left: 0;
  width: 100%;
  height: 2px;
  margin-top: -1px;
}

#plus-cursor::after {
  left: 50%;
  top: 0;
  height: 100%;
  width: 2px;
  margin-left: -1px;
}

@media (prefers-reduced-motion: reduce) {
  #plus-cursor { transition: none; }
}

/* ============================================================
   CURSOR GLITCH (home page card images)
   ------------------------------------------------------------
   A small window that follows the pointer and tears the image
   into offset scanlines. Built by js/main.js on first hover:
   copies of the card image, each clipped to one thin band.
   Held at opacity 0 unless the pointer is actually moving, so a
   still cursor leaves the card completely untouched.
   ============================================================ */
.glitch-field {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--dur-snap) linear;
}

.glitch-band {
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
  will-change: transform;
}

/* The title glitch's own wrapper. It exists so the .glitch-field can be a
   SIBLING of the <h1> rather than a child: the title is masked to cut the window
   out of it, and a mask applies to descendants, so a field inside would be cut
   away along with the glyphs it is there to replace. No padding and no border,
   so the title's margins -- the -0.16em cap lift especially -- collapse straight
   through and the box does not move. main.js builds it on first hover. */
.glitch-wrap {
  position: relative;
  width: fit-content;
}

/* The title variant tears a clone of the <h1> rather than of an <img>. The clone
   is pinned to the band's own origin and loses the real title's margins, so the
   glyphs land exactly on top of the original; JS sets its width so the <br>
   breaks in the same place. */
.glitch-band > h1 {
  position: absolute;
  top: 0;
  left: 0;
  margin: 0;
}

/* transition: none is belt and braces now. .card-img img used to hand down a
   0.6s transform transition for the hover zoom, which made the clones ease
   between frames instead of jumping; the zoom went on 2026-09-25 and that
   transition with it, but these have to move instantly every frame, so the
   guard stays. Same specificity as .card-img img, so it stays below it.

   max-width/height:none is load-bearing. The global `img { max-width: 100% }`
   caps a clone to its BAND's width, not the card's, so the full-size copy
   silently rendered as a ~200px thumbnail and nothing lined up. max-width
   constrains regardless of the inline width, so it has to be turned off. */
.glitch-band img {
  position: absolute;
  top: 0;
  left: 0;
  max-width: none;
  max-height: none;
  transition: none;
}

/* ============================================================
   CONTENT REVEAL — staggered fade-in on page load
   Pure CSS on purpose: the animation is declared straight on the
   target selectors rather than toggled by a JS class, so there is no
   frame where the content paints visible and then jumps to hidden,
   and it still works if main.js never runs.
   `backwards` (not `both`) is deliberate: the from-state applies during
   the delay, but nothing is retained afterwards, so a finished reveal
   never blocks a later hover transform on the same element.
   ============================================================ */
@keyframes content-reveal {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: none; }
}

.project-card,
.about-col .blk,
.header-copy,
.project-meta-bar,
.project-hero,
.project-content {
  animation: content-reveal var(--dur-reveal) var(--ease-out) backwards;
  animation-delay: calc(var(--reveal-i, 0) * 70ms);
}

/* index: cascades down (and across, at the 2-column width) card by card */
.project-card:nth-child(2) { --reveal-i: 1; }
.project-card:nth-child(3) { --reveal-i: 2; }
.project-card:nth-child(4) { --reveal-i: 3; }

/* ============================================================
   LARGE-DISPLAY GRID (exploration 2026-09-25)
   The index grid is full bleed with no measure cap, so two columns kept
   growing with the window: 711px cards at 1440, 951px at 1920, 1271px at
   2560, with a 16:9 image 714px tall. Past about 1600 the page reads as a
   stack of billboards rather than a grid. A third column spends the extra
   width on another project instead of on scale.

   1600 is the line because a 1440 laptop -- the common case -- should stay at
   two, and the tier only wants to fire on an actual external display.

   Placed after the reveal rules above, not up in PROJECT GRID, because
   :nth-child() is (0,3,0) and a media query adds no specificity: if this came
   first, the unconditional rules above would win.
   ============================================================ */
@media (min-width: 1600px) {
  .project-grid { grid-template-columns: repeat(3, 1fr); }

  /* Three columns make each card narrower than the two-column tier ever was
     (527px at 1600 vs 791px at 1599), while .card-strip keeps its full width.
     At the 38px cap a long title no longer fit beside the strip. The strip
     leaving the title's side is prevented in the base .card-body rule
     (flex-wrap: nowrap); what is left for this tier is the type size, because
     at 38px a long title had to shrink so far that it wrapped to two lines on
     most cards. Flat 28px from 1600 to 2105, then growing to 34px at 2556
     where an 847px card has the room for it. */
  /* :root, not html -- a pseudo-class is (0,1,0) and beats html's (0,0,1),
     so the declaration up in the token block would win on specificity no
     matter how late this one came. */
  :root { --fs-card-title: clamp(28px, 1.33vw, 34px); }
  /* The wave runs on the diagonal, so a card's delay is row + column. Three
     columns re-shape the rows, which means card 4 opens row 2 and steps BACK
     to card 2's delay rather than continuing past card 3. Cards 5-7 get a
     delay for the first time -- at two columns they are below the fold on
     every laptop, so they were left at 0. */
  .project-card:nth-child(4) { --reveal-i: 1; }
  .project-card:nth-child(5) { --reveal-i: 2; }
  .project-card:nth-child(6) { --reveal-i: 3; }
  .project-card:nth-child(7) { --reveal-i: 2; }
}

/* about: each column steps down on its own, so the two waves run level.
   Gated to the two-column width on 2026-09-24: below 800px .about-col becomes
   display: contents and the six blocks are re-ordered by class (see ABOUT
   PAGE), each setting its own --reveal-i for the new top-to-bottom order.
   Ungated, these three selectors are (0,3,0) against the class rules' (0,1,0)
   and won on specificity no matter where they sat in the file, so the phone
   stagger was firing in DOM order, not reading order -- education and the
   colophon both got step 1 and faded in together with experience. */
@media (min-width: 801px) {
  .about-col .blk:nth-child(2) { --reveal-i: 1; }
  .about-col .blk:nth-child(3) { --reveal-i: 2; }
  .about-col .blk:nth-child(4) { --reveal-i: 3; }
}

/* project page: title block, then its meta strip, then hero, then body.
   The sidebar and the back rail are deliberately left out -- both hold a
   position: sticky child, and animating an ancestor's transform makes a
   sticky descendant jump. */
.project-meta-bar { --reveal-i: 1; }
.project-hero     { --reveal-i: 2; }
.project-content  { --reveal-i: 3; }

@media (prefers-reduced-motion: reduce) {
  .project-card,
  .about-col .blk,
  .header-copy,
  .project-meta-bar,
  .project-hero,
  .project-content {
    animation: none;
  }
}

/* ============================================================
   PHONE TICKER BAND
   Below 720px the footer row has no leftover width worth reading,
   so the ticker leaves the footer and locks to the top of the
   screen as a band of its own. Chosen 2026-09-22 over two
   alternatives that are now deleted: a black band, and a
   full-width row above the footer's copyright line.
   ============================================================ */
@media (max-width: 720px) {
  /* the band is fixed, so the page has to make room for it */
  body { padding-top: var(--ticker-h); }

  .footer-ticker {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    /* above the sticky nav (100) and the pinned section carousel (90) */
    z-index: 110;
    /* an exact height, not padding plus a line box, because the nav offset and
       the anchor scroll-margins below are all derived from it */
    height: var(--ticker-h);
    display: flex;
    align-items: center;
    margin: 0;
    padding: 0;
    /* the page's own blue, so the band reads as part of the frame rather than
       a second chrome. No rule underneath either: the moving type is edge
       enough, and a hairline there read as a second nav border 28px above the
       real one. */
    background: var(--bg);
    border-bottom: none;
    /* no edge mask here: a mask fades the element's background out with it,
       which left the band's ends translucent over the page. Full bleed means
       the copy just clips at the screen edge instead. */
    mask-image: none;
    -webkit-mask-image: none;
  }

  /* smaller than the desktop 15px: in a 28px band the display face at full
     size fills the whole height and stops reading as a rule */
  .ticker-item,
  .ticker-sep { font-size: var(--fs-data); }
  .ticker-sep { padding: 0 var(--sp-3); }

  /* scrolling down pulls the copy back to a whisper along with the header it
     sits above; scrolling up brings it back. .nav-condensed comes from the
     same scroll handler, so the two moves always read as one gesture.
     On .ticker-track, NOT on .footer-ticker: fading the band itself fades its
     background with it, and since the band is fixed that turned it into a
     window onto whatever was scrolling underneath.
     Briefly removed 2026-09-25 on a contrast argument (0.3 on the dark
     --ticker-fg computes to about 1.2:1) and put straight back: on the device
     it reads as the band going quiet, which is the point, and the copy is
     decorative. Kept at 0.3, the value Jesse approved by eye. */
  .ticker-track { transition: opacity var(--dur-base) ease; }
  body.nav-condensed .ticker-track { opacity: 0.3; }

  /* everything measured from the top of the window moves down by the band, so
     nothing ends up underneath it */
  nav.site-header { top: var(--ticker-h); }

  .project-index-pinned {
    /* --nav-shift is the condensed header's lift */
    top: calc(var(--nav-h) + var(--ticker-h) + var(--nav-shift, 0px));
  }

  .project-content h2 {
    scroll-margin-top: calc(var(--nav-h) + 52px + var(--ticker-h));
  }
}

/* ============================================================
   CONDENSED HEADER (phones)
   Scrolling down shrinks the bar and the type in it; scrolling up
   restores them. main.js adds .nav-condensed -- see navCondense.

   TRANSFORMS ONLY, and that is the whole design. The first cut
   animated the nav's height and the font sizes, which relayouts
   the entire page on every frame; iOS Safari stuttered badly on a
   long project page, and snapping it instead of easing it only
   took the edge off, because one relayout of a 3000px document is
   still expensive mid-scroll. So the layout here never changes at
   all: --nav-h stays 76px, the bar's visible box is a pseudo
   element that scaleY's up out of the way, and the wordmark and
   the buttons scale in place. Transforms are composited, so the
   scroll thread does no work and the easing is free.
   Touch-gated on purpose, same reason as the .nav-back swap up
   top: a width-only query fires on a narrow desktop window.
   ============================================================ */
@media (max-width: 800px) and (pointer: coarse) {
  /* 8px off the bar's underside, as before, and constant from here on:
     nothing in this block may change the bar's layout height. */
  body { --nav-h: 76px; }
  .nav-inner { padding-top: var(--sp-2); }

  /* the bar's paint moves to a pseudo element so it can be scaled. The nav
     itself keeps its full 76px box and stops drawing anything. */
  nav.site-header {
    background: none;
    border-bottom: none;
  }

  nav.site-header::after {
    content: "";
    position: absolute;
    inset: 0;
    /* inside the nav's own stacking context, so this sits under the wordmark
       and the buttons without ever going behind the page */
    z-index: -1;
    background: var(--bg);
    border-bottom: 1px solid var(--border-light);
    /* the bottom edge is what travels; the top edge is pinned */
    transform-origin: top center;
    transition: transform var(--dur-base) ease;
  }

  .nav-logo,
  .nav-back,
  .nav-links {
    transition: transform var(--dur-base) ease;
  }

  /* each group shrinks toward the edge it is aligned to, so neither drifts
     inward off its gutter */
  .nav-logo,
  .nav-back { transform-origin: left center; }
  .nav-links { transform-origin: right center; }

  /* 52/76: the bar reads 52px tall while still occupying 76px */
  body.nav-condensed nav.site-header::after { transform: scaleY(0.684); }

  /* 0.72 takes the wordmark 22px -> 16px and the buttons 21px -> 15px; the
     -16px lifts their centre from 42px (centre of the full bar) to 26px
     (centre of the scaled one). Order matters: the translate is written first
     so it applies in unscaled pixels. */
  body.nav-condensed .nav-logo,
  body.nav-condensed .nav-back,
  body.nav-condensed .nav-links {
    transform: translateY(-16px) scale(0.72);
  }

  /* the section carousel hangs off the bar, so it has to come up with it.
     It is position:fixed, so moving its top lays out only itself. */
  .project-index-pinned {
    top: calc(var(--nav-h) + var(--ticker-h) + var(--nav-shift, 0px));
  }

  body.nav-condensed { --nav-shift: -24px; }
}

/* ------------------------------------------------------------
   Phones: the project title sits closer under the header bar.
   40px of padding was set for the desktop frame, where the title
   has a 64px rail beside it and room to breathe; on a phone the
   bar, the title and the hero are stacked in one narrow column
   and that gap read as a dead band. 12px off the top only -- the
   32px below the header copy is what separates it from the hero
   and stays put. Touch-gated to match the header rules above.
   ------------------------------------------------------------ */
@media (max-width: 800px) and (pointer: coarse) {
  .page-header { padding-top: var(--sp-7); }
}
