/* =============================================================
   SHORELINE — site.css

   Colour tokens keyed off data-scheme / data-phase, which
   src/lib/phases.js's INLINE_THEME_SCRIPT sets on <html> before
   first paint. data-scheme picks the reading-column palette
   (light by day, dark by dusk/night); data-phase picks the accent
   tint for the current hour band, overridable via the nav drawer's
   phase control (localStorage key "shoreline:phase").

   This file also carries the two canvas-chrome components that
   overlay the WebGL scene: the hamburger nav drawer and the
   first-visit welcome modal. A later task wires the templates that
   consume this stylesheet; the token contract below is depended on
   by that task's own tests, so the selectors and variable names are
   load-bearing — do not rename casually.
   ============================================================= */

:root {
  --ink: #1b1710;
  --paper: #f6efe2;
  --rule: #cbbb9c;
  --hot: #b4560c;
  /* Every other --accent below is scoped to a [data-phase], which only
     exists once INLINE_THEME_SCRIPT has read the reader's clock — i.e.
     only when JavaScript runs. Without this default the .hero gradient
     resolves against an undefined custom property, the whole
     background-image declaration is invalid, and the hero renders as a
     blank band on exactly the reader the fallback exists for. Noon is the
     neutral choice: a no-JS reader has no clock for us to read. */
  --accent: #3a92b8;
}

[data-scheme="dark"] {
  --ink: #f4e7cf;
  --paper: #0b0810;
  --rule: #5c4436;
  --hot: #ffb45c;
}

[data-phase="dawn"]   { --accent: #e8a89a; }
[data-phase="noon"]   { --accent: #3a92b8; }
[data-phase="golden"] { --accent: #ef9f5a; }
[data-phase="dusk"]   { --accent: #d0565f; }
[data-phase="night"]  { --accent: #64798e; }

/* -------------------------------------------------------------
   Canvas chrome tokens.

   The hamburger drawer and welcome modal float over the WebGL
   scene, not the reading column, so — like the existing #gear/#ar
   HUD chips they match — they keep one dark, amber-accented look
   regardless of data-scheme/data-phase rather than following the
   reading palette above.
   ------------------------------------------------------------- */
:root {
  /* --chrome-bg / --chrome-panel are gone: every panel that used them is
     now one surface, --glass-tint below, so that the chrome cannot drift
     into two different translucencies again. */
  --chrome-border: #5c4436;
  --chrome-text: #f4e7cf;
  /* Quiet text ON the glass. Lightened from #a08a6e when the front page
     became a full-page scene: measured off the built page, a panel over
     the noon sea composites to about rgb(46,56,65), and #a08a6e against
     that is 3.6:1 — under WCAG AA for normal text, i.e. a label that reads
     at night and fails at noon, which is the exact failure this page is
     defined against. #cdb597 is 6.1:1 there, and 4.7:1 against a pure
     WHITE backdrop, a case the beach cannot actually produce. It stays
     1.6:1 quieter than --chrome-text, so it still reads as secondary. The
     drawer and the HUD use this token too and get the same fix. */
  --chrome-muted: #cdb597;
  --chrome-accent: #ffb45c;
  --chrome-accent-ink: #241105;
  --chrome-shadow: 0 0 0 2px rgba(0, 0, 0, .55);
}

/* -------------------------------------------------------------
   Quantised liquid glass — the chrome's one surface.

   Liquid glass and a PlayStation are a contradiction: soft blur and
   smooth translucency are precisely what that hardware could not do.
   Rendered literally, a frosted panel reads as a modern OS window
   pasted onto a 1997 game. So the glass here is REAL — an actual
   backdrop-filter blur, the scene genuinely bending behind the
   panel — and then quantised, the way the machine behind it would
   have had to:

     --glass-bands   four hard-stop steps repeating every 8px. Not a
                     gradient: a gradient with no interpolation, which
                     is what posterising a smooth ramp looks like. The
                     translucency reads as banded rather than
                     continuous.
     --glass-dither  a 1px checkerboard at 2px pitch — the ordered
                     dither the scene's own post-processing already
                     applies to its 9-bit colour (see engine/postfx.js).
                     The chrome dithers because the picture behind it
                     dithers.

   Everything else stays hard: 2px borders, square corners, the
   established Courier New / uppercase / letter-spaced idiom, and the
   amber accent unchanged.

   CONTRAST NEVER DEPENDS ON THE BLUR. --glass-tint is nearly opaque by
   default, and only thins to the translucency the design wants inside
   the @supports below, once the browser has confirmed it will actually
   blur what is behind. A browser without backdrop-filter — or a reader
   who has turned it off — gets a solid dark panel, not unreadable text
   over a bright noon sea. The blur also carries a brightness() step for
   the same reason: it darkens the backdrop BEFORE the tint composites
   over it, so a panel that is legible at night is still legible at
   noon, over the pale sand and the bright water, rather than only over
   the dusk and night phases it was eyeballed against.
   ------------------------------------------------------------- */
:root {
  --glass-tint: rgba(18, 13, 17, .90);
  --glass-blur: none;
  --glass-bands: repeating-linear-gradient(
    180deg,
    rgba(255, 255, 255, .055) 0, rgba(255, 255, 255, .055) 2px,
    rgba(255, 255, 255, .018) 2px, rgba(255, 255, 255, .018) 4px,
    rgba(0, 0, 0, .045) 4px, rgba(0, 0, 0, .045) 6px,
    rgba(0, 0, 0, .085) 6px, rgba(0, 0, 0, .085) 8px);
  --glass-dither: repeating-conic-gradient(
    rgba(255, 255, 255, .05) 0% 25%,
    rgba(0, 0, 0, .06) 0% 50%);
}

@supports ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  :root {
    --glass-tint: rgba(18, 13, 17, .66);
    --glass-blur: blur(9px) saturate(1.25) brightness(.72);
  }
  /* The wordmark keeps the same surface and re-tunes the ONE number that
     decides its contrast, rather than declaring a second surface of its
     own — the bands, the dither, the blur and the shape all still come
     from the shared rule below.

     It gets its own value because of where it is. Every other glass
     element floats over the sea and the sky; the wordmark is pinned to the
     bottom-left of the frame, which is SAND, and noon sand is the
     brightest thing this scene can produce — measured off the built page,
     rgb(255, 255, 182), against which cream text is 1.2:1, i.e. gone. At
     .66 the plate carried it to 6.9:1 (5.6:1 in the lightest band of the
     quantisation); at .78 the worst band is 8.5:1 and all five phases sit
     between 8.5:1 and 11.5:1, so the wordmark does not have a weakest time
     of day. It is still glass: the beach still moves behind it.

     Inside the @supports, deliberately. Outside it the tint is .90 with no
     blur and no brightness() step, which is already stricter than this;
     overriding it there would make the fallback THINNER, which is the
     wrong direction for the reader who has no blur at all. */
  .wordmark { --glass-tint: rgba(18, 13, 17, .78); }
}

/* -------------------------------------------------------------
   Silkscreen — the wordmark's face, and the only webfont on this
   site.

   SELF-HOSTED, NOT A CDN. The URL below is a placeholder that
   build.js's copyCss() replaces with the real content-hashed path
   under /assets/ (see FONT_URL_PLACEHOLDER there); the file itself
   is vendor/silkscreen/Silkscreen-Regular.woff2, committed to this
   repo with its licence — SIL Open Font License 1.1,
   vendor/silkscreen/OFL.txt — beside it. It is the Google Fonts
   latin subset, 3.5KB, which covers every character the wordmark
   needs and nothing else. Nothing on this site is fetched from
   another origin, and the CSP says so: font-src is 'self' and
   there is no third-party source in any directive.

   font-display: swap, not block. A pixel face that fails to arrive
   should degrade to the monospace fallback below, visibly, rather
   than leave the reader looking at a blank corner for three
   seconds — the wordmark is a link, and an invisible link is the
   failure mode this site keeps having.
   ------------------------------------------------------------- */
@font-face {
  font-family: "Silkscreen";
  src: url("/assets/silkscreen.17bb6d80ee.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

.chip,
.panel,
.nav-drawer,
.welcome,
.wordmark,
#hud,
#teaser,
#toast {
  background-color: var(--glass-tint);
  background-image: var(--glass-bands), var(--glass-dither);
  background-size: 100% 8px, 2px 2px;
  background-repeat: repeat, repeat;
  -webkit-backdrop-filter: var(--glass-blur);
  backdrop-filter: var(--glass-blur);
  border-radius: 0;
}

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

/* Shared chip idiom: dark translucent panel, 2px border, hard black
   outer shadow, uppercase letter-spaced monospace label. Matches the
   established #gear/#ar/.chip look. */
.chip {
  font: inherit;
  font-family: "Courier New", ui-monospace, monospace;
  cursor: pointer;
  color: var(--chrome-text);
  text-transform: uppercase;
  letter-spacing: .16em;
  font-size: 11px;
  border: 2px solid var(--chrome-border);
  padding: 9px 13px;
  box-shadow: var(--chrome-shadow);
}
.chip:hover,
.chip:focus-visible { border-color: var(--chrome-accent); }
.chip.on { color: var(--chrome-accent); border-color: var(--chrome-accent); }

/* =============================================================
   The wordmark.

   The author's name, small, over the scene, linking home. It is a
   wordmark on the world rather than a page title: no heading
   element, no uppercase, no .16em chip tracking — the face carries
   the character instead.

   WHERE IT SITS, and why it is the only corner left. The chrome
   already owns three of them: the Display gear top-left on
   /beach/, the hamburger top-right on every page, AR look
   bottom-right on /beach/ (and, everywhere else, the focused state
   of a.hero-travel, which expands into that same corner). Bottom-
   left is free on every route. It is positioned INSIDE the hero —
   `.hero { position: relative }` — rather than fixed to the
   viewport, and that is the difference between a wordmark on the
   scene and an overlay on somebody's prose:

     /beach/     .hero--full is `position: fixed; inset: 0`, so an
                 absolutely positioned child lands in the viewport's
                 own bottom-left corner and stays there. #hud opens
                 from the top-left downward (top: 60px, max-height:
                 66vh) and stops well short of it; #toast and
                 #teaser are centred at 88px and 110px up.
     vignette    /archive/, /about/, /contact/ and every article: the
                 bottom-left of the 46vh scene band, which scrolls
                 away with the picture it belongs to. A fixed
                 wordmark would be cream-on-cream over the reading
                 column the moment the reader scrolled, and sitting
                 on their text besides.
     /projects/  the exception: .wordmark--fixed, a sibling of the
                 backdrop rather than a child of it. See home.html's
                 comment above the element for the three separate
                 reasons an anchor cannot live inside .hero--backdrop.

   CONTRAST DOES NOT DEPEND ON THE PALETTE. It takes the same
   quantised glass surface as the drawer, the HUD and the panels
   (the shared rule above), which exists precisely so that light
   text stays legible over a live beach: the tint is near-opaque
   until the browser confirms it will blur, and the blur darkens the
   backdrop before the tint composites over it. --chrome-text on
   that surface measures over 7:1 in all five time-of-day phases —
   dawn, noon, golden, dusk, night — where the same colour laid
   straight onto the noon sand is under 2:1. A wordmark that reads
   at night and vanishes at noon is exactly the failure this site
   has already had to fix once.

   CRISPNESS. Silkscreen is drawn on a pixel grid, so it is set at
   16px — a whole multiple of the grid, where every stem lands on a
   device pixel instead of straddling two — with antialiasing and
   hinting-driven smoothing turned off, kerning and ligatures off,
   and optimizeSpeed rasterisation. Set it at 15px or 17px and the
   engine resamples the bitmap into grey mush, which is the usual
   way a pixel font ends up looking like a blurry small serif.
   ============================================================= */
.wordmark {
  position: absolute;
  z-index: 10;
  left: calc(12px + env(safe-area-inset-left));
  bottom: calc(12px + env(safe-area-inset-bottom));
  display: inline-block;
  padding: 6px 9px 5px;
  font-family: "Silkscreen", "Courier New", ui-monospace, monospace;
  font-size: 16px;
  line-height: 1;
  letter-spacing: 0;
  text-transform: none;
  text-decoration: none;
  white-space: nowrap;
  color: var(--chrome-text);
  box-shadow: var(--chrome-shadow);
  -webkit-font-smoothing: none;
  font-smooth: never;
  font-kerning: none;
  font-variant-ligatures: none;
  text-rendering: optimizeSpeed;
}
.wordmark:hover,
.wordmark:focus-visible { color: var(--chrome-accent); }
.wordmark:focus-visible { outline: 2px solid var(--chrome-accent); outline-offset: 2px; }

/* The front page only. z-index 2 rather than 10: it has to clear
   .home-page's own z-index 1 lift, and nothing else on that page is
   between them. */
/* The fixed wordmark: TOP RIGHT, staying there through the whole scroll.
 
   ONE ROUTE USES THIS, and it is /projects/ — not the front page, which
   is what it was written for. The front page now opens with a business
   card whose h1 is the same name at 24px, so a wordmark there was the
   name twice on one screen and overlapped it outright on a narrow window;
   build.js passes that template no wordmark token at all. /projects/ has
   no card, and a full-page fixed backdrop it cannot sit inside, so it
   keeps the treatment.
 
   Two things about the position are load-bearing.
 
   IT SITS LEFT OF THE HAMBURGER, NOT UNDER IT. .nav-toggle is fixed at
   right:12px and is at least 44px wide, so `right: 12px` here would put
   the wordmark exactly underneath the one control on the page that must
   never be covered. The offset is written as 12 + 44 + 12 rather than as
   68 so that resizing the hamburger does not make someone work backwards
   from a magic number.
 
   Z-INDEX 20 puts it above the panels (1), below the hamburger (30) and
   below the drawer backdrop (40). A wordmark that outranked the drawer
   would sit on top of an open menu.
 
   There is deliberately NO narrow-screen rule any more. One existed to
   stop it colliding with the front page's card, and that card is not on
   this route — a breakpoint guarding against an element that cannot be
   there is worse than none, because the next person reads it as a
   description of the page. */
.wordmark--fixed {
  position: fixed;
  z-index: 20;
  left: auto;
  bottom: auto;
  top: calc(12px + env(safe-area-inset-top));
  right: calc(12px + 44px + 12px + env(safe-area-inset-right));
}

/* =============================================================
   Hamburger nav drawer
   ============================================================= */

.nav-toggle {
  position: fixed;
  z-index: 30;
  right: 12px;
  top: 12px;
  min-width: 44px;
  min-height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}
.nav-toggle-icon { font-size: 15px; line-height: 1; }

/* Dimmed backdrop over the scene. Closed by default: opacity 0 AND
   pointer-events none, on top of the `hidden` attribute JS toggles,
   so it never intercepts a click/tap meant for the canvas — even
   mid-transition, before `hidden` is reapplied. */
.nav-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
  /* Lighter than it looks it should be, on purpose: the drawer above it
     is glass, and it blurs whatever this leaves behind. Dim the page as
     hard as a modal backdrop and the drawer stops reading as glass and
     starts reading as a solid panel with a gradient on it. */
  background: rgba(6, 4, 8, .40);
  opacity: 0;
  pointer-events: none;
  transition: opacity .25s ease;
}
.nav-backdrop.open { opacity: 1; pointer-events: auto; }

/* Slides in from the right edge. Same closed-state guarantee as the
   backdrop: translated fully off-canvas AND pointer-events none. */
.nav-drawer {
  position: fixed;
  z-index: 41;
  top: 0;
  right: 0;
  bottom: 0;
  width: 280px;
  max-width: 86vw;
  display: flex;
  flex-direction: column;
  gap: 20px;
  padding: 56px 20px 20px;
  border-left: 2px solid var(--chrome-border);
  box-shadow: -6px 0 0 2px rgba(0, 0, 0, .55);
  font-family: "Courier New", ui-monospace, monospace;
  color: var(--chrome-text);
  transform: translateX(100%);
  pointer-events: none;
  transition: transform .3s ease;
  overflow-y: auto;
}
.nav-drawer.open { transform: translateX(0); pointer-events: auto; }
/* The rule that makes the drawer's own `hidden` attribute mean anything.
   The UA stylesheet's [hidden] { display: none } is beaten by ANY author
   display declaration, and .nav-drawer above declares display: flex — so
   the closed drawer was display: flex the whole time, merely translated
   off-canvas. It could not be seen or clicked (transform + pointer-events
   above), but it was still in the tab order: Tab from the hamburger on any
   page walked a keyboard reader through the close button, all four nav
   links and all six phase pills of an invisible panel before reaching
   anything on the page — including, now, the hero's own travel control.
   The drawer script already sets and clears `hidden` at exactly the right
   moments (after the closing transition, before the opening reflow); this
   is only the missing half of that contract. */
.nav-drawer[hidden] { display: none; }

.nav-close {
  position: absolute;
  top: 14px;
  right: 16px;
  background: transparent;
  border: 1px solid var(--chrome-border);
  color: var(--chrome-text);
  font-size: 16px;
  line-height: 1;
  width: 30px;
  height: 30px;
  border-radius: 0;
  cursor: pointer;
}
.nav-close:hover,
.nav-close:focus-visible { border-color: var(--chrome-accent); color: var(--chrome-accent); }

.nav-links {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.nav-links a {
  display: block;
  padding: 10px 4px;
  font-size: 12px;
  letter-spacing: .16em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--chrome-text);
  border-bottom: 1px solid var(--chrome-border);
}
.nav-links a:hover,
.nav-links a:focus-visible { color: var(--chrome-accent); }

.nav-phase-label {
  display: block;
  margin-bottom: 8px;
  font-size: 11px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--chrome-muted);
}

/* Phase option pills — matches the existing HUD .opts idiom. */
.opts { display: flex; gap: 5px; flex-wrap: wrap; }
.opts button {
  font: inherit;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 11px;
  letter-spacing: .06em;
  text-transform: uppercase;
  background: transparent;
  color: var(--chrome-text);
  border: 1px solid var(--chrome-border);
  padding: 5px 7px;
  border-radius: 0;
  min-width: 34px;
  cursor: pointer;
}
.opts button:hover,
.opts button:focus-visible { border-color: var(--chrome-accent); }
.opts button.on {
  background: var(--chrome-accent);
  border-color: var(--chrome-accent);
  color: var(--chrome-accent-ink);
  font-weight: 700;
}

/* Hover-to-open: the drawer's slide is the existing .nav-drawer
   transition above, whichever way it was opened. What is new here is the
   ARMED state — the hamburger brightening while the intent delay runs, so
   a drawer that opens without being clicked still announces itself first.

   Scoped twice, and both gates matter. (hover: hover) and (pointer: fine)
   keeps it off touch and off coarse pointers, where "hover" is really a
   tap the reader did not mean. [data-hover-nav="on"] keeps it off the
   walkable homepage, where the reader drags to look around and a drag
   finishing near the hamburger would otherwise arm it mid-turn; build.js
   writes that attribute from the page's scene mode. */
@media (hover: hover) and (pointer: fine) {
  body[data-hover-nav="on"] .nav-toggle.armed {
    border-color: var(--chrome-accent);
    color: var(--chrome-accent);
    box-shadow: var(--chrome-shadow), 0 0 0 4px rgba(255, 180, 92, .20);
  }
}

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

/* =============================================================
   Welcome modal — first-visit only, dismissal remembered under
   localStorage key "shoreline:welcomed". Replaces the old #tip
   corner overlay.
   ============================================================= */

.welcome-backdrop {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: rgba(6, 4, 8, .65);
  opacity: 0;
  pointer-events: none;
  transition: opacity .3s ease;
}
.welcome-backdrop.open { opacity: 1; pointer-events: auto; }

.welcome {
  position: fixed;
  z-index: 51;
  left: 50%;
  top: 50%;
  width: min(420px, 88vw);
  max-height: 82vh;
  overflow-y: auto;
  transform: translate(-50%, calc(-50% + 10px));
  opacity: 0;
  pointer-events: none;
  transition: opacity .3s ease, transform .3s ease;
  border: 2px solid var(--chrome-border);
  box-shadow: var(--chrome-shadow);
  padding: 22px 24px 24px;
  font-family: "Courier New", ui-monospace, monospace;
  color: var(--chrome-text);
}
.welcome.open {
  opacity: 1;
  transform: translate(-50%, -50%);
  pointer-events: auto;
}

.welcome h2 {
  margin: 0 0 16px;
  font-size: 14px;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--chrome-accent);
}
.welcome-controls {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
  margin: 0 0 20px;
}
.welcome-controls h3 {
  margin: 0 0 8px;
  font-size: 11px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--chrome-muted);
}
.welcome-controls ul {
  margin: 0;
  padding: 0 0 0 16px;
  font-size: 12px;
  line-height: 1.7;
}
.welcome kbd {
  font-family: inherit;
  font-size: 11px;
  border: 1px solid var(--chrome-border);
  border-radius: 0;
  padding: 1px 5px;
}
.welcome-dismiss { display: block; margin: 4px auto 0; }

/* -------------------------------------------------------------
   Teaser card.

   src/engine/markers.js fills these nodes and drops the `hidden`
   attribute when the reader walks within LAYOUT.TEASER_RADIUS of a
   set, then adds `.on`. It creates the element itself if the page
   did not ship one, so these rules have to hold for markup this
   stylesheet never sees.
   ------------------------------------------------------------- */
#teaser {
  position: fixed;
  z-index: 7;
  left: 50%;
  transform: translateX(-50%);
  bottom: 110px;
  width: min(340px, 80vw);
  border: 2px solid var(--chrome-accent);
  padding: 12px 14px;
  box-shadow: 0 0 0 2px rgba(0, 0, 0, .6);
  color: var(--chrome-text);
  opacity: 0;
  transition: opacity .3s ease;
}
#teaser[hidden] { display: none; }
/* markers.js drops `hidden` and adds `.on` in the same breath (with a
   forced reflow between the two, see ensureCard/showCard), so this is the
   rule that actually turns the class into a visible fade rather than an
   instant, invisible opacity jump on an element still `display: none`. */
#teaser.on { opacity: 1; }
#teaser .teaser-title {
  margin: 0 0 4px;
  font-size: 13px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--chrome-accent);
}
#teaser .teaser-meta {
  margin: 0 0 8px;
  font-size: 10px;
  letter-spacing: .12em;
  color: var(--chrome-muted);
}
#teaser .teaser-line {
  margin: 0 0 10px;
  font-size: 11px;
  line-height: 1.6;
}
#teaser .teaser-link {
  font-size: 11px;
  letter-spacing: .14em;
  color: var(--chrome-accent);
  text-transform: uppercase;
  text-decoration: none;
  border-bottom: 1px solid var(--chrome-accent);
}

@media (max-width: 420px) {
  .welcome-controls { grid-template-columns: 1fr; }
}

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

/* =============================================================
   Page chrome — added by Task 11: the reading column, the HUD
   panel/gear/AR/stick/toast/boot ids ps1_beach.html originally
   drew at file scope (engine/index.js and input.js still look
   these ids up by getElementById), and the WebGL hero itself.

   The HUD/gear/ar/stick/knob/toast/boot block below is kept
   value-for-value equivalent to ps1_beach.html's original numbers,
   just re-pointed at the --chrome-* tokens above instead of its own
   local --panel/--edge/--dim custom properties, so nothing needs to
   drift to add it here. The one deliberate exception is #boot's
   `position` — see its own comment below for why it must not be
   `fixed` on a page that has a document under the hero.
   ------------------------------------------------------------- */

* { box-sizing: border-box; }

html, body { margin: 0; }

body {
  background: var(--paper);
  color: var(--ink);
  font-family: Georgia, "Times New Roman", serif;
}

button {
  font: inherit;
  cursor: pointer;
  text-transform: uppercase;
}
button:focus-visible { outline: 2px solid var(--chrome-accent); outline-offset: 2px; }

a { color: var(--hot); }

/* -------------------------------------------------------------
   The WebGL hero. `.hero` is the fallback that shows through
   whenever the canvas cannot: no WebGL, JS disabled, or the scene
   failing to boot for any other reason (engine/index.js's fail()
   sets [data-scene="unavailable"] on <html> in that case). The
   gradient reads as the current time of day even with the canvas
   gone, because it keys off the same --paper/--accent tokens the
   rest of the page's theming uses.
   ------------------------------------------------------------- */
.hero {
  position: relative;
  overflow: hidden;
  background: linear-gradient(to bottom, var(--paper), var(--accent));
}
/* The canvas starts invisible (not merely covered by #boot) and only fades
   in once engine/index.js has actually rendered a frame and marked
   [data-scene="ready"]. A WebGL context created with the default `alpha:
   false` paints opaque black, not transparent, the instant it is sized —
   so without this, a cmd-click-opened article (a backgrounded tab, where
   requestAnimationFrame never runs until it gains focus) shows a bare
   black band instead of the gradient below, for as long as it stays
   backgrounded. Hiding the canvas until a real frame exists keeps the
   gradient visible the entire time instead. */
.hero canvas {
  display: block;
  width: 100%;
  height: 100%;
  touch-action: none;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  opacity: 0;
  transition: opacity .2s ease;
}
[data-scene="ready"] .hero canvas { opacity: 1; }
[data-scene="unavailable"] .hero canvas { display: none; }
@media (prefers-reduced-motion: reduce) {
  .hero canvas { transition: none; }
}

/* -------------------------------------------------------------
   Hero travel.

   The hero on an article or a vista page is a picture of a real place on
   the walkable beach, so it is also a way of going there. `.hero-travel`
   is the control: a real anchor, in the markup, with the destination
   already in its href — which is what makes this work with JavaScript
   disabled (it is simply a link), and what makes it reachable and
   announced for a reader who never uses a mouse. base.html's hero-travel
   script then widens the target to the whole hero and adds the expand.

   It sits above #boot (z-index 9) deliberately: while the scene is still
   loading, or on a reader whose scene will never load, the way out of the
   hero must not be underneath the LOADING card.
   ------------------------------------------------------------- */
/* Clipped, not removed. The visible chip is gone from every hero — that
   was the ask — but a control that only a mouse can reach is a
   regression, so the anchor stays exactly where it was in the markup and
   in the tab order, hidden the way a skip link is.

   It comes back on focus, and that is not a hedge: hiding a focusable
   control with no focused state is a WCAG 2.4.7 (Focus Visible) failure,
   and a sighted keyboard reader has to be able to see where focus landed.
   A control revealed only while focused is not the persistent chip that
   was removed.

   `clip` (deprecated but universally honoured) and `clip-path` are both
   set: between them every browser that can run this scene clips the
   anchor, and neither takes it out of the accessibility tree the way
   display: none or visibility: hidden would. Width and height stay at 1px
   rather than 0 because a zero-sized element is skipped for focus in some
   engines. .chip supplies the type, the glass and the border; what is
   below is only the difference between hidden and shown. */
.hero-travel {
  position: absolute;
  z-index: 10;
  right: calc(12px + env(safe-area-inset-right));
  bottom: calc(12px + env(safe-area-inset-bottom));
  width: 1px;
  height: 1px;
  padding: 0;
  border-width: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  clip-path: inset(50%);
  white-space: nowrap;
  text-decoration: none;
  color: var(--chrome-text);
}
/* Deliberately :focus and not :focus-visible, in its own rule rather than
   a selector list: :focus is understood everywhere, and a 1px clipped
   anchor cannot be reached by a pointer, so every focus it ever gets is a
   keyboard focus anyway. In a selector list an unsupported :focus-visible
   would invalidate the whole rule and leave the control invisible while
   focused — the exact failure this rule exists to prevent. */
.hero-travel:focus {
  width: auto;
  height: auto;
  padding: 9px 13px;
  border-width: 2px;
  border-color: var(--chrome-accent);
  color: var(--chrome-accent);
  overflow: visible;
  clip: auto;
  clip-path: none;
  outline: 2px solid var(--chrome-accent);
  outline-offset: 2px;
}

/* With the chip gone, the cursor is the only thing left telling a mouse
   reader the hero is a control at all — so it matters more than it did,
   not less. Scoped to a fine pointer because a touch reader has no cursor
   to inform, and to the presence of the anchor because the beach's own
   hero is not a control. */
@media (hover: hover) and (pointer: fine) {
  .hero:has(> .hero-travel) { cursor: pointer; }
}

/* The expand. Transforming the hero — rather than resizing it — means no
   other element on the page moves, so the prose underneath does not jump
   as the beach opens up over it; and the low-resolution canvas scales up
   chunky rather than resampling, which is the right look for this scene.
   The `transform` is written by the script (it depends on where the hero
   happens to be on screen); the timing and the layer promotion are here. */
.hero--travelling {
  z-index: 60;
  transform-origin: top left;
  will-change: transform;
  transition: transform .45s cubic-bezier(.2, .7, .2, 1);
  pointer-events: none;
}
.is-travelling,
.is-travelling body { overflow: hidden; }

@media (prefers-reduced-motion: reduce) {
  /* The script navigates immediately under reduced motion and never adds
     the class; this is the belt to that braces. */
  .hero--travelling { transition: none; }
}

/* The homepage hero is the whole page: full-bleed, no document
   scroll, touch drag reserved for looking around rather than
   pull-to-refresh. An article's vignette hero (`.hero--vignette`)
   is a normal block in the flow instead — the page under it is
   prose, and prose has to scroll.

   Scoped with :has() off the hero itself rather than a body class,
   because base.html is one shared wrapper for every page and its
   token contract does not carry a per-page body class — home.html
   is simply the one template that includes a `.hero--full`. */
body:has(.hero--full) {
  height: 100%;
  overflow: hidden;
  touch-action: none;
  overscroll-behavior: none;
  -webkit-user-select: none;
  user-select: none;
}
.hero--full {
  position: fixed;
  inset: 0;
}

.hero--vignette {
  height: 46vh;
  min-height: 260px;
  max-height: 520px;
}

/* -------------------------------------------------------------
   HUD chrome: the Display panel, AR-look toggle, walk stick and
   toast, plus the #boot loading placeholder. Only present on pages
   that actually boot an interactive (non-vignette) scene, i.e.
   home.html.
   ------------------------------------------------------------- */
#gear {
  position: fixed;
  z-index: 6;
  left: calc(12px + env(safe-area-inset-left));
  top: calc(12px + env(safe-area-inset-top));
}
#ar {
  position: fixed;
  z-index: 6;
  right: calc(12px + env(safe-area-inset-right));
  bottom: calc(14px + env(safe-area-inset-bottom));
}

#hud {
  position: fixed;
  z-index: 6;
  width: 244px;
  max-height: 66vh;
  overflow-y: auto;
  left: calc(12px + env(safe-area-inset-left));
  top: calc(60px + env(safe-area-inset-top));
  border: 2px solid var(--chrome-border);
  padding: 11px 13px 12px;
  box-shadow: var(--chrome-shadow);
  font-family: "Courier New", ui-monospace, monospace;
  color: var(--chrome-text);
}
#hud[hidden] { display: none; }
#hud h1 {
  font-size: 11px;
  letter-spacing: .28em;
  margin: 0 0 9px;
  font-weight: 700;
  color: var(--chrome-accent);
  text-transform: uppercase;
}
.row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: 11px;
  letter-spacing: .08em;
  padding: 3px 0;
  line-height: 1.5;
}
.row span { color: var(--chrome-muted); text-transform: uppercase; }

/* left-thumb walk stick: spawns wherever the touch starts */
#stick {
  position: fixed;
  z-index: 6;
  width: 116px;
  height: 116px;
  margin: -58px 0 0 -58px;
  border: 2px solid rgba(244, 231, 207, .30);
  border-radius: 50%;
  background: rgba(20, 14, 18, .25);
  display: none;
  pointer-events: none;
}
#stick.live { display: block; }
#knob {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 50px;
  height: 50px;
  margin: -25px 0 0 -25px;
  border-radius: 50%;
  border: 2px solid var(--chrome-accent);
  background: rgba(255, 180, 92, .45);
}

#toast {
  position: fixed;
  z-index: 7;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(88px + env(safe-area-inset-bottom));
  font-size: 11px;
  letter-spacing: .18em;
  text-transform: uppercase;
  font-family: "Courier New", ui-monospace, monospace;
  color: var(--chrome-text);
  border: 1px solid var(--chrome-border);
  padding: 8px 13px;
  opacity: 0;
  pointer-events: none;
  transition: opacity .35s ease;
}

/* #tip was removed in favour of the welcome modal; deliberately not
   restored here (see welcome.html's header comment). */
/* #boot is scoped to its own hero, NEVER to the viewport.
   ps1_beach.html could afford `position: fixed; inset: 0` because that
   file is nothing but a scene — there was no document underneath to
   cover. Here every hero sits on a page with prose (or, on the
   homepage, a <noscript> list that is the only no-JS route off it), and
   a fixed full-viewport #boot covers all of it whenever the scene does
   not boot: JS disabled, WebGL missing, or the three.js tag blocked.
   `position: absolute` inside `.hero { position: relative }` (above)
   keeps the loading card inside the band it is loading, so the worst
   case is a hero that reads LOADING forever, not a page that cannot be
   read or scrolled at all. Do not restore `fixed` here. */
#boot {
  position: absolute;
  inset: 0;
  z-index: 9;
  display: grid;
  place-items: center;
  background: #0b0810;
  color: var(--chrome-accent);
  font-size: 12px;
  letter-spacing: .4em;
  font-family: "Courier New", ui-monospace, monospace;
  text-transform: uppercase;
  transition: opacity .6s ease;
}
/* Once engine/index.js's fail() has decided the scene is never coming,
   the loading card is a lie that also hides the gradient hero the same
   flag reveals ([data-scene="unavailable"] .hero canvas, above). A
   successful boot removes #boot from the DOM instead; this rule is the
   other half of that contract, for the boot that never succeeds. */
[data-scene="unavailable"] #boot { display: none; }

@media (max-width: 540px) {
  #hud {
    width: calc(100vw - 24px - env(safe-area-inset-left) - env(safe-area-inset-right));
  }
  .row { padding: 5px 0; }
  .opts button { padding: 7px 9px; min-width: 38px; }
}
@media (prefers-reduced-motion: reduce) {
  #boot, #toast { transition: none; }
}

/* -------------------------------------------------------------
   Reading column: article prose, the archive list and plain pages
   (about, contact). Has to be legible with JS disabled and WebGL
   unavailable, since that is the only thing a no-JS visitor or a
   crawler ever sees.
   ------------------------------------------------------------- */
.prose {
  max-width: 640px;
  margin: 0 auto;
  padding: 40px 20px 100px;
  line-height: 1.7;
}
.prose h1 {
  font-size: 28px;
  line-height: 1.3;
  margin: 0 0 6px;
}
.prose .meta {
  color: var(--rule);
  font-size: 13px;
  letter-spacing: .08em;
  text-transform: uppercase;
  margin: 0 0 32px;
}
.prose p, .prose li { margin: 0 0 20px; }
.prose a { text-decoration-color: var(--rule); }
.prose a:hover, .prose a:focus-visible { text-decoration-color: var(--hot); }

/* A placeholder warning inside ordinary prose — currently just the front
   page's introduction, content/pages/home.md's own blockquote — gets the
   same loud treatment .resume blockquote (further down) gives the
   résumé's: a left rule and a tinted background, so invented filler never
   quietly reads as real copy. .resume blockquote is defined later in this
   file and wins the cascade on a resume page, same specificity, later
   rule, so this is purely the "everywhere else" case. */
.prose blockquote {
  margin: 0 0 24px;
  padding: 12px 14px;
  border-left: 4px solid var(--hot);
  background: rgba(180, 86, 12, .09);
  font-size: 15px;
}
.prose blockquote p { margin: 0 0 8px; }
.prose blockquote p:last-child { margin: 0; }

.archive-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.archive-list li {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 14px 0;
  border-bottom: 1px solid var(--rule);
}
.archive-list a { color: var(--ink); text-decoration: none; }
.archive-list a:hover, .archive-list a:focus-visible { color: var(--hot); }
.archive-list time { color: var(--rule); font-size: 13px; white-space: nowrap; }

/* The no-JavaScript navigation (base.html). It replaces a drawer the
   reader cannot open, so it has to be plainly visible rather than tucked
   away — a band of real links across the top of every page, in the reading
   palette, since without JavaScript there is no scene for it to float
   over. */
.noscript-nav {
  /* Lifted out of the flow's default stacking, and this is load-bearing on
     exactly one route. The front page's backdrop (.hero--backdrop) is
     `position: fixed; z-index: 0`, and a positioned element at z-index 0
     paints ABOVE every non-positioned in-flow element — so without this the
     whole no-JS nav rendered behind the beach: still in the DOM, still
     hit-testable (the backdrop is pointer-events: none), and completely
     invisible. That is precisely the shape of no-JS breakage this site has
     had twice before, and it was found by screenshotting the built page
     with scripting disabled, not by reading the stylesheet. z-index 1
     matches .home-page's own lift, so the nav and the panels share one
     layer over the scene. */
  position: relative;
  z-index: 1;
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  padding: 14px 20px;
  border-bottom: 1px solid var(--rule);
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 12px;
  letter-spacing: .16em;
  text-transform: uppercase;
}
.noscript-nav a {
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid var(--rule);
  padding-bottom: 2px;
}
.noscript-nav a:hover,
.noscript-nav a:focus-visible { color: var(--hot); border-color: var(--hot); }

/* =============================================================
   The front page (home.html).

   Not a reading column with a picture on top: the vista is a
   FULL-PAGE FIXED BACKDROP and the content is a stack of
   quantised glass panels scrolling over it. So — unlike
   /about/, /archive/ and every article, which keep
   --ink/--paper/--rule — everything below takes the CHROME
   palette (--chrome-text/--chrome-border/--chrome-accent) and
   the same --glass-* surface the drawer, the HUD and the
   welcome modal already use.

   That is the readability argument, and it is the whole reason
   this page borrows a surface rather than inventing one. The
   glass was built for text over a live beach: --glass-tint is
   near-opaque (.90) by default and only thins to .66 inside the
   @supports that confirms the browser will actually blur, and
   the blur carries brightness(.72) so it DARKENS the backdrop
   before the tint composites over it. That is what keeps a
   panel legible over the pale sand and bright water of noon and
   golden, not only over dusk and night. Nothing on this page may
   depend on which of the five palettes happens to be running.
   ============================================================= */

/* The backdrop. Fixed to the viewport, behind everything, and never a
   click target: a full-viewport hero that were also a link would turn
   every click on empty page space into a navigation away. It is
   aria-hidden in the markup for the same reason — it is scenery.

   z-index 0 rather than -1, and .home-page z-index 1 over it: a negative
   z-index would drop the canvas behind body's own opaque --paper
   background and the scene would simply never be seen. This is also why
   the page's panels have to carry their own surface — there is no paper
   left underneath them.

   NOT .hero--full. That class means the beach at /beach/, and it locks
   body scroll (`body:has(.hero--full)`) because there the scene IS the
   page. Here the page scrolls over the scene, which is the opposite
   requirement, so it must not pick up that lock — nor the <noscript>
   release valve in base.html's head that exists to undo it. */
.hero--backdrop {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}
.home-page {
  position: relative;
  z-index: 1;
  max-width: 760px;
  /* A lead-in of open beach before the first panel: the scene is the
     thing the page opens on. Capped in px so a tall desktop window does
     not push the masthead most of a screen down. */
  padding: min(28vh, 200px) 20px 120px;
}

/* The panel. .chip's shared glass rule (near the top of this file)
   already gives .panel its tint, banding, dither and blur; what is here
   is only the shape — hard 2px border, square corners, hard black outer
   shadow, the established chrome type colour. */
.panel {
  margin: 0 0 28px;
  padding: 26px 24px;
  border: 2px solid var(--chrome-border);
  box-shadow: var(--chrome-shadow);
  color: var(--chrome-text);
}
.home-page .panel:last-child { margin-bottom: 0; }

/* The reading column's link and rule colours are --hot/--rule against
   --paper; on glass they are the chrome accent against the tint. Scoped
   to .home-page so no other route's prose is touched. */
.home-page a { color: var(--chrome-accent); }
.home-page .prose a,
.home-page .panel a { text-decoration-color: var(--chrome-border); }
.home-page .panel a:hover,
.home-page .panel a:focus-visible { text-decoration-color: var(--chrome-accent); }

.home-page h1 {
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 30px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--chrome-accent);
  margin: 0 0 14px;
}

/* The placeholder warning from content/pages/home.md. .prose blockquote
   tints it with --hot over --paper, which on a dark panel is a barely
   visible smear — and this block is the one thing on the page that must
   not be missable. Same shape, chrome palette, and the accent as the
   left rule. */
.home-page .prose blockquote,
.home-page blockquote {
  border-left: 4px solid var(--chrome-accent);
  background: rgba(255, 180, 92, .12);
  color: var(--chrome-text);
}

.home-intro {
  font-size: 18px;
  line-height: 1.65;
}
.home-cta { margin: 0 0 4px; }
.home-cta-link {
  display: inline-block;
  padding: 12px 18px;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 13px;
  letter-spacing: .16em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--chrome-accent);
  border: 2px solid var(--chrome-border);
}
.home-cta-link:hover,
.home-cta-link:focus-visible { border-color: var(--chrome-accent); }

/* The coordinates the backdrop is looking at, as a visible link. Every
   other parked-hero route hides this inside a clipped a.hero-travel; this
   page cannot (see home.html's header comment) and is better for it. */
/* .home-spot / .home-spot-link were here.
   They styled "Or start exactly where this view is looking", a visible
   link that round-tripped the front page's parked camera coordinates
   through /beach/?x=&z=&yaw=. That promise is only keepable by a PARKED
   camera: the front page's camera is live now, so a coordinate baked in
   at build time is stale by the first frame. Deleted with the markup
   rather than left to rot — dead rules are worse than absent ones,
   because the next person reads them as a description of the page. */

.home-heading {
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 13px;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--chrome-accent);
  margin: 0 0 14px;
}
.home-list {
  list-style: none;
  margin: 0 0 16px;
  padding: 0;
}
.home-list li,
.noscript-list li {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 12px 0;
  border-bottom: 1px solid var(--chrome-border);
}
.home-list a,
.noscript-list a { color: var(--chrome-text); text-decoration: none; }
.home-list a:hover, .home-list a:focus-visible,
.noscript-list a:hover, .noscript-list a:focus-visible { color: var(--chrome-accent); }
.home-list time { color: var(--chrome-muted); font-size: 13px; white-space: nowrap; }
.home-more { font-size: 13px; margin: 0; }

/* The no-JS complete index. It is the same list markup as .home-list, in
   the same panel, and it is the ONLY route to a post past the walkable
   twenty-fifth for a reader with scripting off — so it takes the same
   styling rather than falling back to a UA-default bullet list. */
.noscript-list { list-style: none; margin: 0; padding: 0; }

.home-beach p,
.home-about p { margin: 0 0 20px; }

/* The developer profile. Content-driven (src/build/home.js reads it from
   content/pages/home.md), so every value here is either a real string
   from front matter or an obvious "replace me" default — nothing in this
   stylesheet invents the look of a real name or a real link, only the
   shape a placeholder one renders in. */
/* The placeholder note the profile and the portfolio both carry. Loud
   rather than muted, which is a reversal: it used to be small grey
   italics in the reading column. On a designed landing page a portfolio
   of invented projects is exactly the thing a reader takes at face value,
   so it gets the accent and a rule of its own. */
.profile-note {
  margin: 0 0 18px;
  padding: 8px 12px;
  border-left: 3px solid var(--chrome-accent);
  background: rgba(255, 180, 92, .12);
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 12px;
  letter-spacing: .04em;
  color: var(--chrome-text);
}
.profile-note code,
.portfolio-broken code { color: var(--chrome-accent); }
.profile-name {
  margin: 0 0 4px;
  font-size: 20px;
  font-weight: 700;
}
.profile-role {
  margin: 0 0 20px;
  color: var(--chrome-muted);
}
.profile-subheading {
  margin: 20px 0 10px;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 12px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--chrome-muted);
}
.profile-stack {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0 0 8px;
  padding: 0;
  list-style: none;
}
.profile-stack li {
  margin: 0;
  padding: 4px 10px;
  border: 1px solid var(--chrome-border);
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 12px;
  color: var(--chrome-text);
}
.profile-links {
  margin: 0 0 8px;
  padding: 0;
  list-style: none;
}
.profile-links li { margin: 0 0 8px; }
.profile-resume { margin: 20px 0 0; }
/* Matches .home-cta-link's button, not .chip: this section already sits
   ON a glass panel, and .chip carries a glass surface of its own — glass
   on glass reads as a rendering mistake. So it is the panel's own border
   and the chrome accent, exactly like the beach and About buttons. */
.profile-resume-link {
  display: inline-block;
  padding: 10px 16px;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 12px;
  letter-spacing: .14em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--chrome-accent);
  border: 2px solid var(--chrome-border);
}
.profile-resume-link:hover,
.profile-resume-link:focus-visible { border-color: var(--chrome-accent); }

/* -------------------------------------------------------------
   Portfolio cards.

   Structure is real — title, one-line description, link, optional
   tag — and every value in them is placeholder, marked as such by
   the .profile-note above the list. src/build/home.js reads them
   from content/pages/home.md; nothing here invents one.

   Chunky and stepped, like the rest: hard 2px borders, square
   corners, no rounding, and the hover/focus state is a border
   colour change rather than a shadow lift or a scale. Nothing
   smooth.
   ------------------------------------------------------------- */
.portfolio-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 14px;
}
.portfolio-card {
  position: relative;
  margin: 0;
  padding: 16px 16px 14px;
  border: 2px solid var(--chrome-border);
  display: flex;
  flex-direction: column;
  gap: 8px;
}
/* Hover/focus lives on the CARD, driven by the anchor inside it, so the
   whole rectangle responds rather than just the title. :focus-within
   carries the keyboard half. */
.portfolio-card:hover,
.portfolio-card:focus-within { border-color: var(--chrome-accent); }
.portfolio-title {
  margin: 0;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 14px;
  letter-spacing: .06em;
  text-transform: uppercase;
  line-height: 1.4;
}
.portfolio-link {
  color: var(--chrome-accent);
  text-decoration: none;
}
/* Stretches the anchor's hit area over the whole card without putting the
   description inside the link's accessible name — a link announced as a
   title plus a whole sentence is a worse control, not a bigger one. */
.portfolio-link::after {
  content: "";
  position: absolute;
  inset: 0;
}
.portfolio-desc {
  margin: 0;
  font-size: 14px;
  line-height: 1.5;
  color: var(--chrome-text);
}
.portfolio-tag {
  margin: 0;
  align-self: flex-start;
  padding: 2px 8px;
  border: 1px solid var(--chrome-border);
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--chrome-muted);
}
/* A malformed or missing entry is shown, not swallowed: a typo in
   home.md belongs on the page where the author will see it, rather than
   silently shipping a card that links nowhere. */
.portfolio-card--broken { border-style: dashed; }
.portfolio-broken {
  margin: 0;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 12px;
  line-height: 1.5;
  color: var(--chrome-muted);
}

/* =============================================================
   /resume/ — a sheet of paper, not a web page.

   Every other route on this site is a scrollable column under a
   band of PlayStation-1 beach. This one is a DOCUMENT: it gets
   read by somebody deciding whether to employ a person, usually
   as a PDF and often on actual paper, and a floating sea over the
   top of it undercuts precisely the page that has to look
   credible. resume.html carries no hero at all (which also means
   build.js ships it no three.js — see pageHasHero there).

   WHAT YOU SEE IS WHAT PRINTS. The sheet's on-screen width and
   height are the locale's real paper size (--sheet-w/--sheet-h,
   set by the .resume-doc--a4 / --letter class build.js writes from
   the locale table), and its padding is the same 18mm 16mm the
   @page rule prints at. So the screen view is a preview with a
   scale factor, not a lookalike.

   THE SHEET STAYS LIGHT IN THE DARK SCHEME, and that is a
   decision rather than an oversight. Everything else on the site
   follows --paper/--ink, which follow the reader's clock; a
   resume that goes cream-on-near-black after dusk is no longer a
   preview of anything, since it will print black-on-white
   regardless. So the DESK darkens with the scheme and the sheet
   stays paper — the same compromise every document editor makes,
   softened at night (--sheet is warm off-white rather than pure
   #fff) so it is not a torch in a dark room.
   ============================================================= */
:root {
  --desk: #ded6c6;
  /* Ink for the chrome that sits ON the desk rather than on the sheet.
     It cannot be --rule: that token is a pale sand chosen to sit against
     --paper, and against the desk it is very nearly the same colour —
     the switcher's "Language" label was legible only if you knew it was
     there. It cannot be --sheet-muted either, because the desk inverts
     with the scheme and the sheet does not. */
  --desk-ink: #443c30;
  --sheet: #ffffff;
  --sheet-ink: #16130e;
  --sheet-rule: #b0a184;
  --sheet-muted: #4c4437;
}
[data-scheme="dark"] {
  --desk: #100d0a;
  --desk-ink: #a3947c;
  --sheet: #f4f0e7;
}

/* Paper. The two sizes this site knows about, as the real
   millimetre dimensions of each — A4 for the French and
   Traditional Chinese resumes, US Letter for the English one.
   build.js puts the matching size into an @page rule on the same
   page, so these two numbers and the printer's two numbers can
   never disagree. */
.resume-doc--a4 { --sheet-w: 210mm; --sheet-h: 297mm; }
.resume-doc--letter { --sheet-w: 215.9mm; --sheet-h: 279.4mm; }

body:has(.resume-doc) { background: var(--desk); }

.resume-doc {
  min-height: 100vh;
  padding: 28px 16px 72px;
}

/* The page chrome — language switcher and print control — sits
   ON the desk, above the sheet, aligned to the sheet's own edges.
   None of it prints. */
.resume-controls {
  max-width: var(--sheet-w);
  margin: 0 auto 18px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px 20px;
}

.resume-langs {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 12px;
  letter-spacing: .12em;
  text-transform: uppercase;
}
.resume-langs-label { color: var(--desk-ink); }
.resume-langs ul {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  list-style: none;
  margin: 0;
  padding: 0;
}
.resume-langs a {
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid var(--desk-ink);
  padding-bottom: 2px;
  /* A language's own name is written in its own script; uppercasing
     and letter-spacing "繁體中文" is meaningless at best. */
  text-transform: none;
  letter-spacing: normal;
  font-size: 13px;
}
.resume-langs a:hover,
.resume-langs a:focus-visible { color: var(--hot); border-color: var(--hot); }
/* The page you are on is still a link — still focusable, still in the
   list — so the switcher always shows three options rather than two
   and a gap. It is marked, not removed. */
.resume-langs a[aria-current="page"] {
  color: var(--hot);
  border-bottom-width: 2px;
  border-color: var(--hot);
}

.resume-actions { margin: 0; }
.resume-print,
.resume-download {
  color: var(--ink);
  text-decoration: none;
  border-color: var(--rule);
}
.resume-print:hover, .resume-print:focus-visible,
.resume-download:hover, .resume-download:focus-visible {
  border-color: var(--hot);
  color: var(--hot);
}

/* -------------------------------------------------------------
   THE DOCUMENT — ONE RULESET, SCREEN AND PAPER.

   Everything from here down to the language sections is the only
   place the resume's typography is written. There is no second
   copy of it inside @media print: that block changes the handful
   of things that are physically different about paper and nothing
   else, so "the PDF looks exactly like the resume on the webpage"
   holds by construction instead of by two rulesets being kept in
   step by hand.

   They were not kept in step. The screen sheet was set at
   15px/1.55 and the printer got 10pt/1.35 — a 12.5% difference in
   type size on a page whose whole claim is that it is a preview of
   itself — with different heading sizes, different paragraph
   spacing and different colours underneath that. Both stylesheets
   looked entirely correct. The same tie between .prose and .resume
   had already cost one real bug: the document printing at article
   spacing and running to two pages.

   THE UNITS ARE PRINT UNITS, and that is what makes one ruleset
   possible at all. The sheet on screen is already its real
   physical size (.resume-doc--letter is 215.9mm × 279.4mm, laid
   out at 96 CSS px to the inch), so 10pt on screen is the same
   10pt the printer sets. Writing this block in px would mean
   converting it for print, and a conversion is how two rulesets
   start.

   THE SHEET NO LONGER CARRIES .prose, and that is deliberate.
   `.prose p` and `.resume p` tie exactly on specificity, so which
   one applied depended on nothing but which was written last —
   a standing trap that has already sprung once. The two things
   .prose was genuinely providing on this page (printed link URLs,
   and the muted-colour darkening) are stated for .resume itself
   now, so nothing here depends on source order any more.
   ------------------------------------------------------------- */
.resume-sheet {
  width: 100%;
  max-width: var(--sheet-w);
  min-height: var(--sheet-h);
  margin: 0 auto;
  padding: 18mm 16mm;
  background: var(--sheet);
  color: var(--sheet-ink);
  box-shadow: 0 1px 0 rgba(0, 0, 0, .16), 0 16px 38px rgba(0, 0, 0, .26);
  font-size: 10pt;
  line-height: 1.35;
}
.resume-sheet a { color: var(--sheet-ink); text-decoration-color: var(--sheet-rule); }
.resume-sheet a:hover, .resume-sheet a:focus-visible { text-decoration-color: var(--hot); }

/* Name, then how to reach the person, then a rule. The document is
   headed by WHOSE it is — not by the word "Resume", which the
   reader can see for themselves. */
.resume-head {
  margin: 0 0 9pt;
  padding: 0 0 6pt;
  border-bottom: 1.5pt solid var(--sheet-ink);
}
.resume-name {
  font-size: 20pt;
  line-height: 1.15;
  margin: 0 0 4pt;
}
.resume-contact {
  margin: 0;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 9pt;
  letter-spacing: .02em;
  color: var(--sheet-muted);
}

/* The placeholder banner marked.js renders from the blockquote at
   the top of each resume source. It is loud on purpose and it
   PRINTS: a resume full of invented history that does not say so
   is the failure mode that matters here.

   It is also the element that quietly proves whether the two
   renderings are really one. Its tint is a background, and browsers
   drop background colours from print by default — so on paper this
   banner used to lose its wash and its amber rule went black, while
   the stylesheet looked right and the screen looked right. The
   print block's print-color-adjust: exact is what keeps the paper
   honest; nothing about the banner is restated there. */
.resume blockquote {
  margin: 0 0 10pt;
  padding: 3pt 7pt;
  border-left: 3pt solid var(--hot);
  background: rgba(180, 86, 12, .09);
  font-size: 9pt;
}
.resume blockquote p { margin: 0 0 6pt; }
.resume blockquote p:last-child { margin: 0; }

.resume h2 {
  font-size: 9.75pt;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--sheet-ink);
  border-bottom: .75pt solid var(--sheet-rule);
  padding-bottom: 3pt;
  margin: 8pt 0 4pt;
}
.resume h3 { font-size: 11.5pt; margin: 0 0 1pt; }
.resume h3 + p em { color: var(--sheet-muted); font-style: normal; letter-spacing: .03em; }
.resume p { margin: 0 0 4pt; }
.resume ul { margin: 0 0 2pt; padding-left: 15pt; }
.resume li { margin: 0 0 2.5pt; }

/* No entry split across a page break, and no heading stranded at
   the foot of one. Written here rather than in the print block for
   the same reason as everything else on this page: they are
   properties of the document, they are inert on screen, and a copy
   inside @media print would be a second ruleset again. `break-*`
   is the standard property and `page-break-*` the legacy alias
   every current engine still honours; print is exactly where the
   older spelling still matters. */
.resume-entry,
.resume ul,
.resume li,
.resume blockquote {
  break-inside: avoid;
  page-break-inside: avoid;
}
.resume h2,
.resume h3 {
  break-after: avoid;
  page-break-after: avoid;
  break-inside: avoid;
  page-break-inside: avoid;
}
.resume p, .resume li { orphans: 3; widows: 3; }

/* One role, its dates, its one-line context and its bullets, as a
   single box. src/build/resume.js wraps them at build time
   (groupResumeEntries) because a flat heading/paragraph stream
   cannot express either of the two things a resume entry needs:
   the dates on the same line as the role, right-aligned; and
   break-inside: avoid over the WHOLE entry, so a page break can
   never separate a role from the evidence for it. */
.resume-entry {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  column-gap: 15pt;
  margin: 0 0 6pt;
}
.resume-entry > * { grid-column: 1 / -1; }
.resume-entry > h3 { grid-column: 1; grid-row: 1; }
.resume-entry > .resume-entry-meta {
  grid-column: 2;
  grid-row: 1;
  margin: 0;
  text-align: right;
  align-self: baseline;
  white-space: nowrap;
}

.resume-updated {
  margin: 12pt 0 0;
  font-size: 8.5pt;
  color: var(--sheet-muted);
  font-family: "Courier New", ui-monospace, monospace;
}

/* -------------------------------------------------------------
   Traditional Chinese (Taiwan).

   Georgia has no Han glyphs, so without a stack of its own the
   whole sheet falls back to whatever the browser picks — which on
   several platforms is a SIMPLIFIED face, rendering Traditional
   text in shapes a Taiwanese reader reads as foreign. The order
   below is Taiwan-first throughout: PingFang TC (macOS/iOS),
   Noto Sans TC, then Microsoft JhengHei — the Windows Traditional
   face. Microsoft YaHei is deliberately absent: it is the
   Simplified-market face and listing it invites exactly the
   substitution this stack exists to prevent.

   LINE BREAKING. `line-break: strict` applies full kinsoku: a
   line may not begin with 、。，」』 or end with 「『, which is
   the difference between typeset Chinese and text that has merely
   been wrapped. `word-break: normal` is load-bearing by absence
   of the alternative — `break-all` would also chop Latin words
   and URLs mid-character, and is the usual way this gets done
   wrong. Line height goes up because Han glyphs fill their em box
   in a way Latin lowercase does not.
   ------------------------------------------------------------- */
.resume-sheet:lang(zh-TW),
.resume-sheet:lang(zh-TW) .resume-name,
.resume-langs a:lang(zh-TW) {
  font-family: "PingFang TC", "Noto Sans TC", "Source Han Sans TC",
    "Microsoft JhengHei", "Heiti TC", "LiHei Pro", sans-serif;
}
.resume-sheet:lang(zh-TW) {
  line-height: 1.8;
  line-break: strict;
  word-break: normal;
  overflow-wrap: break-word;
  text-align: left;
}
/* Uppercasing does nothing to Han and the wide Latin tracking
   above reads as broken spacing between characters, so the
   section headings drop both. */
.resume-sheet:lang(zh-TW) h2 {
  text-transform: none;
  letter-spacing: .1em;
  font-size: 11.25pt;
}
.resume-sheet:lang(zh-TW) .resume-contact,
.resume-sheet:lang(zh-TW) .resume-updated { letter-spacing: normal; }
/* The chip idiom (uppercase, .16em tracking) is Latin styling. Uppercase
   is a no-op on Han and the tracking reads as a gap between every
   character, so the print control drops most of it when the page is in
   Chinese. */
.resume-print:lang(zh-TW),
.resume-download:lang(zh-TW),
.resume-langs-label:lang(zh-TW) { letter-spacing: .04em; }
/* The dates column carries CJK ("2024年1月 – 至今 · 城市，或遠端
   工作") and is long enough to need wrapping on a narrow screen;
   nowrap would push it off the sheet. */
.resume-sheet:lang(zh-TW) .resume-entry-meta { white-space: normal; }

/* -------------------------------------------------------------
   French.

   The typography that matters here is IN THE TEXT, not in this
   stylesheet: narrow no-break spaces (U+202F) before ; : ! ? and
   inside « guillemets », written into content/pages/resume.fr.md
   by the translator. They are non-breaking by definition, so no
   rule is needed to keep them from wrapping — and no rule here
   could add them, which is why they live in the source.

   What CSS owes French is not hyphenating a CV (a hyphenated
   employer name across two lines looks like a typo on a document
   this short) and letting the accented capitals — É, À — have the
   room they need.
   ------------------------------------------------------------- */
.resume-sheet:lang(fr) {
  hyphens: manual;
  line-height: 1.6;
}

.noscript-list {
  list-style: none;
  margin: 0;
  padding: 24px 20px;
}
.noscript-list li { padding: 8px 0; }
.noscript-list a { color: var(--ink); }

/* =============================================================
   Print.

   Applied SITE-WIDE, not only to /resume/. Everything this block
   removes is chrome for driving a WebGL scene — a canvas that prints
   as a dead grey band, a drawer that is off-canvas anyway, a
   hamburger that opens nothing on paper. None of it was ever wanted
   in print on any page, and scoping the rules to the resume would
   have left every article printing with a scene band at the top and
   a hamburger over it.

   The resume is what forced the issue (its whole point is to come
   out of a printer), so it is the page these rules are tuned
   against; the rest of the site gets the same treatment for free.

   Colours are forced to black on white rather than inherited: the
   reading palette follows data-scheme, which follows the reader's
   clock, and a resume printed at 9pm would otherwise come out cream
   on near-black and empty an ink cartridge.
   ============================================================= */
@media print {
  /* Margin only. The SIZE is written per page by build.js, from the
     locale's own paper standard — A4 for /resume/fr/ and
     /resume/zh-tw/, US Letter for /resume/ — because @page is not a
     selector and cannot be scoped to a class or a :lang(). The
     inline rule those pages carry repeats this margin, so the two
     never disagree; every other page on the site takes the printer's
     own default size with these margins. */
  @page {
    margin: 18mm 16mm;
  }

  html, body {
    background: #fff !important;
    color: #000 !important;
  }

  /* The scene and every control that only exists to drive it. */
  .hero,
  #boot,
  #gear,
  #ar,
  #hud,
  #stick,
  #toast,
  #teaser,
  #hamburger,
  #navDrawer,
  #navBackdrop,
  #welcome,
  #welcomeBackdrop,
  .noscript-nav,
  .hero-travel,
  .wordmark,
  .resume-actions,
  .resume-langs,
  .resume-controls,
  .home-cta {
    display: none !important;
  }

  .prose {
    max-width: none;
    margin: 0;
    padding: 0;
    font-size: 11pt;
    line-height: 1.45;
  }

  .prose h1 { font-size: 20pt; margin: 0 0 4pt; }
  .prose p, .prose li { margin: 0 0 8pt; }
  .prose a { color: #000 !important; text-decoration: none; }

  /* --- the front page's glass panels, on paper.

     Every one of them is light cream text (--chrome-text) on a dark
     translucent tint, which is exactly right on screen over a live beach
     and catastrophic on white paper: browsers drop background colours by
     default, so the tint vanishes and the text prints cream on white,
     i.e. blank. Forcing html/body's colour above does not reach these —
     they set their own, and so do the accent-coloured headings, links,
     tags and the placeholder note. So every colour the front page
     declares is forced back to black-on-white here, and the glass surface
     (tint, banding, dither, blur, shadow) is removed rather than left to
     print as a grey slab.

     The placeholder note keeps a rule down its left edge, for the same
     reason .resume blockquote does: a printed page of invented projects
     that does not say it is invented is the failure that matters. */
  .home-page { max-width: none; padding: 0; }
  .panel {
    background: none !important;
    background-color: transparent !important;
    background-image: none !important;
    -webkit-backdrop-filter: none !important;
    backdrop-filter: none !important;
    box-shadow: none !important;
    border: 0;
    border-bottom: 0.5pt solid #999;
    margin: 0 0 10pt;
    padding: 0 0 8pt;
  }
  .home-page,
  .home-page h1,
  .home-page a,
  .home-heading,
  .home-intro,
  .home-cta-link,
  .profile-name,
  .profile-resume-link,
  .portfolio-title,
  .portfolio-link,
  .portfolio-desc,
  /* The NOW block. It was missing from this list, so "Now / Shipping /
     Open to" and all three of their values printed pale cream on white —
     legible on screen, invisible on paper, and the kind of gap nobody
     finds because nobody prints the front page. It is the same class of
     omission the resume's print rules already carry a long comment
     about: a colour that only works against the screen's dark ground is
     no colour at all once the ground is gone. */
  .home-now dt,
  .home-now dd { color: #000 !important; }
  .home-page h1 { font-size: 20pt; }
  .profile-role,
  .profile-subheading,
  .portfolio-tag,
  .portfolio-broken { color: #333 !important; }
  .portfolio-list { display: block; }
  .portfolio-card,
  .profile-note {
    background: none !important;
    break-inside: avoid;
    page-break-inside: avoid;
  }
  .portfolio-card { border: 0.5pt solid #999; padding: 6pt; margin: 0 0 6pt; }
  .profile-note {
    border-left: 3pt solid #000;
    padding: 3pt 0 3pt 9pt;
  }

  /* Every muted colour in the READING COLUMN is --rule (#cbbb9c), a pale
     sand that works against --paper on a screen and all but vanishes on
     white paper. Forcing body colour does not reach these: they set their
     own. Caught by looking at the rendered PDF, where the stylesheet looked
     entirely correct. The resume's own muted token (--sheet-muted,
     #4c4437) is not in this list and must not be: it is ink-dark already,
     it is what the screen shows, and overriding it here would be the
     screen and the paper disagreeing again. */
  .prose .meta,
  .home-list time,
  .archive-list time { color: #333 !important; }

  /* Links are useless on paper unless the URL comes with them — but only
     real ones. A fragment or a relative path prints as noise. This is a
     genuine screen/paper difference (on screen the link is clickable, so
     printing its URL beside it would be noise there), which is why it is
     one of the few things stated here rather than shared. */
  .prose a[href^="http"]::after,
  .resume a[href^="http"]::after {
    content: " (" attr(href) ")";
    font-size: 8pt;
    word-break: break-all;
  }

  /* No heading stranded at the foot of a page, no list item split down the
     middle of one. The resume states its own copies of these next to the
     type they belong to (see "ONE RULESET, SCREEN AND PAPER" above); what
     is left here is the rest of the site. `break-*` is the standard
     property and `page-break-*` the legacy alias every current engine
     still honours; print is where the older spelling still matters. */
  h1, h2, h3, h4 {
    break-after: avoid;
    page-break-after: avoid;
    break-inside: avoid;
    page-break-inside: avoid;
  }
  .prose blockquote,
  .archive-list li,
  .home-list li {
    break-inside: avoid;
    page-break-inside: avoid;
  }
  p, li { orphans: 3; widows: 3; }

  /* =============================================================
     --- THE RESUME ON PAPER.

     This is the whole of it. There is no second copy of the
     document's typography here: not a font size, not a margin, not
     a colour. The sheet above already IS the printed page —
     written in print units, at physical paper size — so what
     follows is only the four things that are physically different
     about paper, each of which would be wrong to share.

     1. PAPER IS WHITE, and the two tokens that follow the reader's
        clock have to stop following it. --sheet goes warm
        off-white after dusk so the preview is not a torch in a
        dark room; printed, that off-white is a page of pale grey
        ink. Both tokens are pinned to white for the printer, and
        the [data-scheme="dark"] selector is named explicitly so
        this cannot lose the tie to it.

     2. BACKGROUNDS DO NOT PRINT unless asked. This is the one that
        silently breaks "exactly the same", because it breaks
        nothing on screen and nothing in the stylesheet: browsers
        drop background colours from print by default, so the
        placeholder banner's wash disappears and the sheet's own
        paper colour disappears with it. print-color-adjust: exact
        over the whole document subtree — not element by element,
        which is how one gets missed — is what makes the PDF carry
        the colours the screen shows. Everything coloured is
        covered by it: the banner's tint and its amber rule, the
        section rules, the head rule, the link underlines, the
        muted dates and the sheet itself.

     3. THE SHEET STOPS BEING A SHEET. On screen it is a piece of
        paper lying on a desk: fixed paper dimensions, a drop
        shadow, and 18mm/16mm of its own padding standing in for
        the printer's margin. On paper the @page rule provides the
        margin, and doubling the two would inset the text by 34mm.
        So the paper simulation comes off and the document inside
        it is untouched.

     4. THE DESK IS NOT PART OF THE DOCUMENT. It is the surface the
        preview lies on; there is no surface under a printed page.
     ============================================================= */
  :root,
  [data-scheme="dark"] {
    --sheet: #ffffff;
    --desk: #ffffff;
  }
  .resume-doc,
  .resume-doc * {
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
  .resume-doc {
    min-height: 0;
    padding: 0;
  }
  .resume-sheet {
    width: auto;
    max-width: none;
    min-height: 0;
    margin: 0;
    padding: 0;
    box-shadow: none;
  }
}

/* =============================================================
   THE GUIDE — the WeatherSTAR 4000 register.

   Everything below this line is new and self-contained: the
   bottom-pinned <nav class="guide"> described in
   docs/superpowers/specs/2026-09-10-guide-and-autopilot-design.md,
   plus its five phase re-tints. It adds selectors; it changes none.
   Nothing above is touched — not the reading-column tokens
   (--ink/--paper/--rule/--hot), not the glass surface, not one
   existing rule.

   The guide is a listings grid floating over a live beach, which is
   the same problem the WeatherSTAR 4000 had: type composited over an
   analogue video feed that could be any colour at any moment. Four
   traits carry the borrowed look — flat two-stop gradients, a bright
   title bar, a cyan selection band, and a hard black offset shadow —
   and the last of them is the one doing engineering rather than
   nostalgia. See "The look" in the spec.
   ============================================================= */

/* -------------------------------------------------------------
   WeatherSTAR tokens.

   Sits with the canvas-chrome tokens conceptually — this is chrome
   over the scene, not reading-column colour — but it is a separate
   block because the guide is the one piece of chrome that does NOT
   keep one look regardless of data-phase. The drawer, the modal and
   the HUD chips deliberately ignore the clock; the guide deliberately
   obeys it (see "The guide follows the clock" below).

   The base values here are the authentic night register, so a
   document that never gets a data-phase — JavaScript off, so
   INLINE_THEME_SCRIPT never ran — still renders a complete, coherent
   guide rather than a set of unresolved custom properties and an
   invalid gradient. That is the same failure --accent's default at the
   top of this file exists to prevent, in the same place: the reader
   with no clock for us to read.
   ------------------------------------------------------------- */
:root {
  /* Ground and title bar are two-stop vertical ramps, both ends
     declared, because the flat "gradient" of that hardware was a
     handful of interpolated scanline steps and reads as a solid panel
     with a lit top edge — not as a modern soft wash. */
  --ws-ground-a: #3434c8;
  --ws-ground-b: #0c0c4e;
  --ws-title-a: #5a5ae4;
  --ws-title-b: #1a1a8e;
  /* The 2px rule between every band. Bright enough to survive being
     drawn over the brightest thing the scene produces. */
  --ws-edge: #7d7dff;
  --ws-head: #ffe14d;
  --ws-body: #ffffff;
  --ws-secondary: #5fe6f0;
  --ws-sel-a: #00c2d4;
  --ws-sel-b: #00727f;
  --ws-crawl: #08083a;
  --ws-live: #ff3b30;
  /* THE SHADOW IS THE LEGIBILITY FLOOR, not a decoration. Hard, black,
     down-right, zero blur. The original had it because it composited
     text over live video on an analogue signal and had to stay
     readable over anything the camera happened to be pointed at; this
     guide sits over a beach that repaints every frame and re-tints
     five times a day. Every text colour in this section is chosen to
     read WITH this behind it, so it must never be softened to a blur
     or dropped "because the panel is dark enough here" — the panel is
     only dark enough at some hours.

     --ws-shadow-sm is the same idea at 2px, for 8px type where a 3px
     offset would be wider than the glyph stems it is supposed to be
     backing. */
  --ws-shadow: 3px 3px 0 #000;
  --ws-shadow-sm: 2px 2px 0 #000;
}

/* -------------------------------------------------------------
   The guide follows the clock.

   The site's defining rule is that the palette comes from the
   reader's local hour: the scene, the accent and the prose theme are
   all consequences of data-phase. A fixed-navy guide would be the
   first thing on this site to ignore that rule — and it would ignore
   it while occupying the bottom half of the front page, which is the
   worst possible place to put the one element that does not belong to
   the time of day.

   So: STRUCTURE CONSTANT, HUE MOVES. These five blocks re-declare
   nothing but colour. No spacing, no track sizes, no font size, and
   above all no shadow — the shadow is the same 3px black at every
   hour precisely because the backdrop it is protecting text from is
   different at every hour.

   Each phase is built out from the accent that phase already uses
   (--accent, declared at the top of this file: dawn #e8a89a, noon
   #3a92b8, golden #ef9f5a, dusk #d0565f, night #64798e), taken as the
   edge colour and darkened in two steps for the ground and the title
   bar. Night keeps the authentic WeatherSTAR navy from the base block
   above and therefore appears here only as a comment.

   Two colours deliberately do NOT simply follow the accent:

     --ws-head       stays a yellow at every phase, because it is the
                     column-heading colour and yellow is the one hue
                     that holds its own over all five grounds. It is
                     only lifted paler where the ground is warm, since
                     #ffe14d on a mid-orange is a smaller step than
                     #ffe14d on navy.
     --ws-secondary  is pushed toward the phase's COMPLEMENT, not its
                     accent. A cyan secondary on a navy ground (night)
                     separates; the same cyan on the noon blue would
                     be a second shade of the ground. Cool phases get
                     mint and cool blue, warm phases get cyan, dawn
                     gets lilac.

   These are separate rule blocks from the [data-phase] --accent
   declarations near the top of the file rather than additions to
   them, because that block is load-bearing for other tests and this
   section's rule is that it adds and never edits.

   CONTRAST IS MEASURED, NOT EYEBALLED — and it is NOT measured yet.
   No ratio is quoted anywhere in this section, because none has been
   taken. The numbers have to come off the BUILT page in a browser, in
   the two worst cases the scene can actually produce: noon sand,
   which is the brightest (the wordmark's note above records it as
   rgb(255, 255, 182)), and the night sea, which is the darkest. That
   is the same discipline and the same reason as the wordmark's
   contrast note in README.md, whose per-phase figures were taken that
   way; inventing figures here would be worse than having none,
   because it would look like the work had been done.
   ------------------------------------------------------------- */
[data-phase="dawn"] {
  --ws-ground-a: #7a3f5c;
  --ws-ground-b: #2a1020;
  --ws-title-a: #a05a78;
  --ws-title-b: #43202f;
  --ws-edge: #e8a89a;
  --ws-head: #ffd9a0;
  --ws-secondary: #c9a6e6;
  --ws-sel-a: #c0607f;
  --ws-sel-b: #5e2438;
  --ws-crawl: #1e0a16;
}

[data-phase="noon"] {
  --ws-ground-a: #2e7fa8;
  --ws-ground-b: #06283a;
  --ws-title-a: #4a9cc4;
  --ws-title-b: #0b3a52;
  --ws-edge: #7ed3f0;
  --ws-head: #ffe14d;
  --ws-secondary: #9fefc8;
  --ws-sel-a: #3fd0e8;
  --ws-sel-b: #0a6076;
  --ws-crawl: #04182a;
}

[data-phase="golden"] {
  --ws-ground-a: #a85a1e;
  --ws-ground-b: #3a1606;
  --ws-title-a: #c87232;
  --ws-title-b: #52220a;
  --ws-edge: #ef9f5a;
  --ws-head: #ffe9a8;
  --ws-secondary: #7fe0e8;
  --ws-sel-a: #e08a2e;
  --ws-sel-b: #6b3208;
  --ws-crawl: #2a0f04;
}

[data-phase="dusk"] {
  --ws-ground-a: #8a2a3e;
  --ws-ground-b: #2a0812;
  --ws-title-a: #a83a50;
  --ws-title-b: #40101e;
  --ws-edge: #d0565f;
  --ws-head: #ffd98a;
  --ws-secondary: #8fd7f0;
  --ws-sel-a: #c4485e;
  --ws-sel-b: #5a1626;
  --ws-crawl: #1c0610;
}

/* [data-phase="night"] declares nothing. Night IS the base register in
   :root above — the authentic navy — and restating it here would give
   the site two places to change one colour. */

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

   Silkscreen, the same self-hosted face and the same discipline as
   the wordmark: whole multiples of 8px, smoothing off, kerning off,
   ligatures off, optimizeSpeed rasterisation. The reason is the
   reason given for the wordmark above and in README.md — the face is
   drawn on a pixel grid, so at 15px or 17px every stem straddles two
   device pixels and the engine resamples the bitmap into grey mush,
   which is the usual way a pixel font ends up looking like a blurry
   small serif. A guide made of grey mush over a moving beach is
   illegible twice over.

   So the whole register is 8 / 16 / 24: 16px is the working size,
   8px is the sub-label and 24px is FULL's row type. There is no 14px
   and no `em` anywhere in this section, because an em is how a
   fractional pixel size gets in by accident.

   letter-spacing stays 0. The .16em tracking that the .chip idiom
   uses is a Courier New correction; Silkscreen is already drawn with
   its sidebearings on the grid, and adding tracking to it puts
   fractional gaps between glyphs that land on the same subpixel
   boundary the font-size rule exists to avoid.
   ------------------------------------------------------------- */

/* -------------------------------------------------------------
   Layout.

   A fixed column pinned to the bottom edge. Height comes from --gh,
   written inline on the element by the resize handler (and by the
   template, from localStorage, so there is no first-paint jump);
   42% is the HALF default and is repeated as the var() fallback so
   that a guide rendered with no inline style at all — JavaScript off
   — is still a guide and not a zero-height strip.

   It is a flex COLUMN and the rows are the only thing that flexes.
   That is the whole answer to "MINI hides the headers and the info
   panel and rows collapse to one line — not scrolled or clipped":
   .g-rows is `flex: 1 1 auto; min-height: 0` and each row is
   `flex: 1 1 0; min-height: 0`, so five rows always divide whatever
   vertical space the fixed bands leave them. min-height:0 on both is
   load-bearing: a flex item's default min-height is auto, i.e. its
   content, so without it the rows refuse to shrink below their text,
   the column overflows its own height, and the crawl is pushed off
   the bottom of the screen — the failure looks like "the ticker
   disappeared" and has nothing to do with the ticker.

   Rows never scroll. A cable box's listings grid did not scroll; it
   showed you what fitted and the rest was the next screen. Scrolling
   here would also put a scroll container inside a panel the reader is
   resizing, which is two ways to move the same content.

   z-index 12: above the wordmark (10, and 2 in its .wordmark--fixed
   front-page form) because the guide owns the bottom band the
   wordmark sits in, and below the hamburger (30) and the drawer
   backdrop (40), which must always be able to cover it.
   ------------------------------------------------------------- */
.guide {
  position: fixed;
  z-index: 12;
  left: 0;
  right: 0;
  bottom: 0;
  height: var(--gh, 42%);
  /* Belt to the resize handler's braces. The grip's own
     aria-valuemin/max are 16 and 76; if a bad stored value or a
     mid-drag rounding error ever gets past the clamp in JS, the guide
     still cannot swallow the whole screen or vanish to a hairline. */
  min-height: 16%;
  max-height: 76%;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background-image: linear-gradient(180deg, var(--ws-ground-a), var(--ws-ground-b));
  border-top: 2px solid var(--ws-edge);
  color: var(--ws-body);
  font-family: "Silkscreen", "Courier New", ui-monospace, monospace;
  font-size: 16px;
  line-height: 1;
  letter-spacing: 0;
  text-transform: uppercase;
  text-shadow: var(--ws-shadow);
  -webkit-font-smoothing: none;
  font-smooth: never;
  font-kerning: none;
  font-variant-ligatures: none;
  text-rendering: optimizeSpeed;
}

/* Every band except the rows is fixed height. Stated once, here,
   rather than five times below, so that adding a band cannot
   accidentally introduce a second thing competing with the rows for
   the leftover space. */
.g-grip,
.g-title,
.g-head,
.g-info,
.crawl {
  flex: 0 0 auto;
}

/* -------------------------------------------------------------
   The grip.

   A real window splitter (role="separator", tabindex="0"), so it has
   to LOOK grabbable and it has to look focused.

   Ribbed, because a flat bar is indistinguishable from the title
   strip's top edge and nobody drags an edge they cannot see. The
   three <span>s are the ribs; they carry the same hard shadow as the
   type, for the same reason — they are drawn over the scene at the
   very top of the panel where the guide is thinnest.

   cursor: ns-resize is the mouse affordance and touch-action: none is
   the touch one: without it the browser claims the vertical drag for
   page scrolling and pointermove events stop arriving mid-gesture, so
   the guide follows the finger for about 100ms and then sticks. That
   is a bug that only ever reproduces on a touchscreen.

   :focus-visible IS NOT OPTIONAL. This control is operable with the
   arrow keys, Home and End; a keyboard user who cannot see where the
   focus went cannot use it, and an invisible focus stop is a WCAG
   2.4.7 (Focus Visible) failure outright. The outline is inset
   (offset -3px) because .guide is overflow:hidden and an outset
   outline on the topmost child would be clipped to nothing — which
   is the same "the focus ring is there, you just cannot see it" bug
   in a different costume. The band also fills with the selection
   ramp, so the state survives a forced-colours mode that drops the
   outline colour.
   ------------------------------------------------------------- */
.g-grip {
  height: 16px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  cursor: ns-resize;
  touch-action: none;
  background-image: linear-gradient(180deg, var(--ws-title-a), var(--ws-title-b));
  border-bottom: 2px solid var(--ws-edge);
}
.g-grip span {
  display: block;
  width: 48px;
  height: 2px;
  background: var(--ws-edge);
  box-shadow: var(--ws-shadow-sm);
}
.g-grip:hover span { background: var(--ws-head); }
.g-grip:focus-visible {
  outline: 3px solid var(--ws-head);
  outline-offset: -3px;
  background-image: linear-gradient(180deg, var(--ws-sel-a), var(--ws-sel-b));
}
.g-grip:focus-visible span { background: var(--ws-head); }

/* -------------------------------------------------------------
   Title strip and column headers.
   ------------------------------------------------------------- */
.g-title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 8px;
  background-image: linear-gradient(180deg, var(--ws-title-a), var(--ws-title-b));
  border-bottom: 2px solid var(--ws-edge);
  white-space: nowrap;
  overflow: hidden;
}
.g-title .l { color: var(--ws-head); }
/* The clock, the phase and the channel number. It is the half that
   changes every minute, so it is the half allowed to be clipped on a
   narrow screen — the name is the one that must never be. */
.g-title .r {
  color: var(--ws-secondary);
  overflow: hidden;
  text-overflow: clip;
}

/* The row grid, declared once and used by both the header band and
   the rows so the two cannot drift apart.

   The spec's HALF geometry is "52px 1fr 1fr" — those are the three
   HEADED columns, CH / NOW / NEXT. The row markup carries a fourth
   cell between the first two, .nm, the channel name, which has no
   header of its own: its label is the channel. So the track list is
   52px + name + NOW + NEXT, and .g-head places its three labels into
   tracks 1, 3 and 4 explicitly rather than letting them auto-flow
   into 1, 2, 3 and sit one column left of the data they describe.
   minmax(0, …) on every flexible track, because the default min
   width of a 1fr track is auto — the widest unbreakable word in it —
   and one long NOW line would otherwise push the grid wider than the
   guide and take the NEXT column off the screen. */
.guide {
  --ws-cols: 52px minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr);
}
.g-head {
  display: grid;
  grid-template-columns: var(--ws-cols);
  gap: 8px;
  align-items: center;
  padding: 8px;
  color: var(--ws-head);
  border-bottom: 2px solid var(--ws-edge);
  white-space: nowrap;
}
.g-head span:nth-child(2) { grid-column: 3; }
.g-head span:nth-child(3) { grid-column: 4; }

/* -------------------------------------------------------------
   The rows.

   Real anchors, so this navigates with scripting off. The only thing
   that flexes in the column.

   NO TRANSITION, ANYWHERE. Not on the selection band, not on hover,
   not on the row background. A cable box had no easing curve, and the
   spec is explicit that the highlight snaps while only the camera
   eases: adding a curve here is the single fastest way to make the
   whole thing read as modern pastiche of the machine instead of the
   machine. It also means this section has nothing for
   prefers-reduced-motion to turn off except the crawl.
   ------------------------------------------------------------- */
.g-rows {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.g-row {
  flex: 1 1 0;
  min-height: 0;
  display: grid;
  grid-template-columns: var(--ws-cols);
  gap: 8px;
  align-items: center;
  padding: 0 8px;
  overflow: hidden;
  color: var(--ws-body);
  text-decoration: none;
  border-top: 2px solid rgba(0, 0, 0, .35);
}
.g-row:first-child { border-top: 0; }
.g-row > span {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: clip;
}
.g-row .ch { color: var(--ws-head); }
.g-row .nm { color: var(--ws-body); }
/* The second line of the name cell. 8px, so it takes the 2px shadow:
   at 8px a 3px offset is wider than the stems it is backing and the
   glyph reads as a smear rather than a letter with a shadow. */
.g-row .sub2 {
  display: block;
  margin-top: 4px;
  font-size: 8px;
  color: var(--ws-secondary);
  text-shadow: var(--ws-shadow-sm);
}
.g-row .now { color: var(--ws-body); }
.g-row .next { color: var(--ws-secondary); }

/* The live dot. A square, not a circle, and it does not blink: a
   blinking indicator is motion that prefers-reduced-motion would have
   to turn off, and an indicator whose OFF state means nothing is an
   indicator that is wrong half the time. */
.g-row.-live .now::before {
  content: "";
  display: inline-block;
  width: 8px;
  height: 8px;
  margin-right: 8px;
  vertical-align: baseline;
  background: var(--ws-live);
  box-shadow: var(--ws-shadow-sm);
}

/* Selection. Both the class and the ARIA state, because the class is
   what the JS toggles and aria-current is what the markup ships with
   — with scripting off the server-rendered current row must still be
   highlighted, and with scripting on the two are set together. */
.g-row.-sel,
.g-row[aria-current="true"] {
  background-image: linear-gradient(180deg, var(--ws-sel-a), var(--ws-sel-b));
  color: var(--ws-body);
}
.g-row.-sel .next,
.g-row[aria-current="true"] .next,
.g-row.-sel .sub2,
.g-row[aria-current="true"] .sub2 { color: var(--ws-body); }

.g-row:hover { background-image: linear-gradient(180deg, var(--ws-title-a), var(--ws-title-b)); }
/* Inset for the same reason as the grip's: .g-rows is overflow:hidden
   and an outset ring on the first or last row would be clipped. */
.g-row:focus-visible {
  outline: 3px solid var(--ws-head);
  outline-offset: -3px;
}

/* -------------------------------------------------------------
   Info panel and crawl.
   ------------------------------------------------------------- */
.g-info {
  padding: 8px;
  border-top: 2px solid var(--ws-edge);
  color: var(--ws-body);
  /* 24px, a whole multiple of the grid, stated in px rather than as a
     1.5 multiplier: a unitless line-height against a font-size that a
     media query may step down is how a fractional line box gets in. */
  line-height: 24px;
  max-height: 25%;
  overflow: hidden;
}

.crawl {
  display: flex;
  align-items: center;
  height: 32px;
  /* The bottom-most band on the page, so it is the one that owes the
     home indicator its clearance. Content-box height plus padding, so
     the ticker's own 32px is not eaten by the inset. */
  padding-bottom: env(safe-area-inset-bottom, 0px);
  box-sizing: content-box;
  overflow: hidden;
  background: var(--ws-crawl);
  border-top: 2px solid var(--ws-edge);
  color: var(--ws-body);
  white-space: nowrap;
}

/* The marquee. padding-left: 100% starts the track just off the right
   edge of the crawl; translating it by -100% of its OWN width (the
   padding included) carries the last character off the left. */
.crawl .track {
  display: inline-block;
  padding-left: 100%;
  will-change: transform;
  animation: ws-crawl 38s linear infinite;
}
@keyframes ws-crawl {
  from { transform: translateX(0); }
  to { transform: translateX(-100%); }
}

/* -------------------------------------------------------------
   Size states.

   MINI · HALF · FULL, written by the resize handler as data-size. The
   HALF geometry is the base above, so a guide with no data-size at
   all — again: JavaScript off — is a HALF guide, which is the default
   stop anyway.

   MINI is a genuinely different layout, not a squashed one. The
   column headers and the info panel are gone, the name's second line
   and the NEXT column are gone, and what is left is two columns and
   one line per row. The row still shows what is on NOW, because a
   listings grid that has stopped listing anything is just a menu:
   .now moves into the name's column, right-aligned, and both cells
   are capped at half the track so they can meet but never overlap.

   FULL is the same grid at 24px with more room around it. It is the
   reading state — the one the autopilot slows to a stop for — so the
   type is the size someone scanning a list actually wants.
   ------------------------------------------------------------- */
.guide[data-size="mini"] .g-head,
.guide[data-size="mini"] .g-info,
.guide[data-size="mini"] .sub2,
.guide[data-size="mini"] .next {
  display: none;
}
.guide[data-size="mini"] .g-row {
  grid-template-columns: 40px minmax(0, 1fr);
}
.guide[data-size="mini"] .nm {
  grid-column: 2;
  max-width: 50%;
}
.guide[data-size="mini"] .now {
  grid-column: 2;
  grid-row: 1;
  justify-self: end;
  max-width: 50%;
  color: var(--ws-secondary);
}

.guide[data-size="full"] {
  font-size: 24px;
}
.guide[data-size="full"] .g-row {
  padding: 0 16px;
  gap: 16px;
}
.guide[data-size="full"] .g-head,
.guide[data-size="full"] .g-title,
.guide[data-size="full"] .g-info {
  padding: 16px;
}
.guide[data-size="full"] .sub2 { font-size: 16px; }
.guide[data-size="full"] .g-info { line-height: 32px; }
.guide[data-size="full"] .crawl { height: 40px; }

/* A phone in portrait is narrower than the title strip's two halves
   plus FULL's 24px type. Stepping the type down one whole grid
   multiple is the only correction: 8, 16 and 24 stay the only sizes
   in this section, because 12px or 20px would put every stem back on
   a half pixel. */
@media (max-width: 480px) {
  .g-title,
  .g-head { font-size: 8px; text-shadow: var(--ws-shadow-sm); }
  .guide[data-size="full"] { font-size: 16px; }
  .guide[data-size="full"] .sub2 { font-size: 8px; }
  .guide[data-size="full"] .g-info { line-height: 24px; }
}

/* -------------------------------------------------------------
   Reduced motion.

   THE CRAWL BECOMES A STATIC LINE, NOT A SLOWER ONE. This is not a
   taste call and it is not "less distracting is better": moving text
   that starts automatically, lasts more than five seconds and has no
   pause control is a WCAG 2.2.2 (Pause, Stop, Hide) failure, and an
   infinite marquee never ends, so slowing it down fails exactly as
   hard as leaving it. The animation is removed, the 100% start offset
   with it, and what is left is the first line of the ticker sitting
   still and clipped at the edge of the band — the same words, read at
   the reader's pace instead of ours.

   Nothing else in this section is listed here because nothing else in
   it moves: there is no transition on the selection band, the hover
   state or the resize, by design (see "The rows" above). The camera
   is what eases, and the camera is not CSS.
   ------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  .crawl .track {
    display: block;
    max-width: 100%;
    padding-left: 0;
    animation: none;
    transform: none;
    will-change: auto;
    overflow: hidden;
  }
}

/* On paper the guide is a fixed overlay of navigation, which is
   exactly what the site-wide print block above already strips: a
   nav that covers the bottom half of every printed sheet, over the
   prose, in white-on-white the moment the browser drops its
   background. It is stated here rather than added to that block's
   selector list for the same reason as everything else in this
   section — it adds, it does not edit. */
@media print {
  .guide { display: none !important; }
}

/* -------------------------------------------------------------
   The guide is a MOBILE surface.

   It is hidden only where the reader has a real mouse AND a window
   wide enough to want the picture instead: `(hover: hover) and
   (pointer: fine) and (min-width: 861px)`. The first two are the same
   test the hover-nav uses, so a touchscreen laptop or a stylus counts
   as touch, which is what those readers actually want.

   THE WIDTH CLAUSE IS NOT DECORATION. Without it the rule keys on
   pointer type alone, and a desktop window dragged narrow still
   reports a fine pointer — so the guide vanishes at exactly the size
   where it is most useful and where anyone testing "does this work on
   mobile" will look for it. A narrow window is a mobile LAYOUT even
   when it is not a mobile DEVICE. src/engine/frontpage.js repeats this
   query verbatim; the two must stay identical or the guide is wired
   where it cannot be seen, or visible with nothing listening.

   Why this way round: on a phone the guide is the best navigation
   the site has — a thumb-sized list of real links over a scene that
   is too small to explore anyway. On a desktop it covers 42% of a
   picture that is the whole reason the page exists, and the
   hamburger drawer is already there, one press away, doing the same
   job without occluding anything.

   TWO CONSEQUENCES, both deliberate, both worth knowing:

   1. A desktop reader with JavaScript off loses the guide too —
      display:none does not care why the page is not scripting. That
      is covered: base.html ships a <noscript> flat nav on every
      route, which is the no-JS path the site has always had and
      which the guide supplemented rather than replaced.

   2. The markup still SHIPS on desktop. It is hidden, not omitted,
      so the build stays one page rather than two, and every test
      that reads the guide out of the built HTML keeps working.
      src/engine/frontpage.js reads the SAME media query and simply
      does not wire the guide up when it matches, so nothing is
      listening to an element nobody can see and the autopilot never
      has its speed scaled by a control that is not on screen.
   ------------------------------------------------------------- */
@media (hover: hover) and (pointer: fine) and (min-width: 861px) {
  .guide {
    display: none;
  }
}

/* TEMPORARY, and the switch is build.js's GUIDE_ENABLED.
   
   Hidden on EVERY device, over the top of the media query above, which is
   left intact so that turning the guide back on restores the behaviour it
   was designed with rather than whatever a hurried revert leaves behind.
   
   `display: none` and not `visibility: hidden`: the latter would leave
   five links and a draggable separator in the tab order as invisible
   focus stops, which is a WCAG 2.4.7 failure and the trap this stylesheet
   already documents twice. */
body[data-guide="off"] .guide {
  display: none;
}

/* TEMPORARY, and the switch is build.js's NAV_ENABLED.

   The hamburger, the drawer and the backdrop it dims, hidden together on
   every device. They have to go as a set: hiding the button alone would
   leave a drawer that hover-to-open could still pull out on a desktop,
   and hiding the drawer alone would leave a button that does nothing
   visible when pressed.

   `display: none` and not `visibility: hidden` or `opacity: 0`, for the
   reason this stylesheet now gives three times: the latter two leave the
   close button, seven links and six phase buttons in the tab order as
   invisible focus stops, which is a WCAG 2.4.7 failure. A reader tabbing
   through the page would fall into fourteen controls they cannot see.

   The <noscript> band is deliberately NOT touched. It is already inert
   whenever scripting is on, and it is the only navigation a reader
   without JavaScript gets — turning the drawer off must not take that
   away too. */
body[data-nav="off"] .nav-toggle,
body[data-nav="off"] .nav-drawer,
body[data-nav="off"] .nav-backdrop {
  display: none;
}

/* =============================================================
   THE BUSINESS CARD — a 3D object on the front page.

   Everything below this line is new and self-contained. It adds
   selectors; it edits none. Nothing above is touched — not the
   reading-column tokens, not the glass surface, not the guide's
   --ws-* register, not one existing rule.

   Built by src/build/card.js into <section class="panel card-panel">
   on the front page; tilted by src/engine/card.js; unlocked by
   src/engine/contact-gate.js.

   THE ONE RULE THIS SECTION EXISTS TO KEEP OFF THE FLOOR:
   NOTHING HERE MAY SET `transform` ON #card ON SCREEN. engine/card.js
   writes `perspective(700px) rotateX(Ndeg) rotateY(Ndeg)` to that
   element's inline style on every animation frame; a stylesheet
   declaration for the same property is either ignored (the inline
   style wins) or, with !important, fights it sixty times a second and
   pins the card flat. So this section never names #card at all — it
   styles .pcard — and the only transform: none it declares is inside
   @media print, where there is no rAF loop and a card rotated in 3D
   comes out of a printer cropped and skewed. That exception is
   deliberate and it is the only one.

   WHY THE CARD IS OPAQUE, when every other surface on this page is
   glass. The front page's panels are quantised liquid glass because
   they are WINDOWS onto the beach — the scene is meant to move behind
   them. A business card is not a window. It is an object lying on the
   scene, and objects are opaque.

   That is a look argument and it is also the whole contrast argument.
   Glass text has to be re-reasoned every time the palette moves,
   which is what the --glass-tint / brightness() machinery above
   exists to do. An opaque face makes the card's text contrast a
   property of two hex values instead of a composite: it is the same
   at noon over the sand and at midnight over the sea, and it can be
   computed rather than sampled. See the measurement note below for
   which claims are arithmetic and which still need a browser.
   ============================================================= */

/* -------------------------------------------------------------
   Card tokens.

   Base values are the NIGHT register, for the same reason --accent
   and the guide's --ws-* base are: a document that never gets a
   data-phase — JavaScript off, so INLINE_THEME_SCRIPT never ran — has
   to render a complete card rather than a set of unresolved custom
   properties and an invalid gradient.

   Split deliberately into what moves with the clock and what does
   not:

     MOVES   the face ramp, the two bevel edges. These are the card's
             hue, and the card follows the clock the way the guide
             does — structure and spacing stay constant, only colour
             is re-declared under [data-phase].

     FIXED   the ink, the key colour, the focus ring, the status
             pair, the shadow. A focus ring that changed hue five
             times a day would be five rings to verify instead of
             one, and "red means wrong" is semantics, not decor.
             --pc-key is the chrome family's amber (--chrome-accent's
             value), which is what ties this object to the drawer,
             the HUD and the panels rather than to the guide.
   ------------------------------------------------------------- */
:root {
  /* The face. A two-stop vertical ramp with BOTH ends declared — the
     same flat, lit-slab idiom as --ws-ground-a/b, not a soft wash.
     Both stops are dark at every phase: the card carries small
     Courier rows, which have none of the 16px Silkscreen + 3px black
     shadow armour the guide's type has. */
  --pc-face-a: #171b26;
  --pc-face-b: #08090f;
  /* The bevel. --pc-edge is the lit top/left edge, --pc-edge-lo the
     shaded bottom/right one and the colour of the card's own
     underside slab (.pcard::before). Light from the top-left, which
     is where every other hard shadow on this site says it comes
     from. */
  --pc-edge: #64798e;
  --pc-edge-lo: #2b3442;

  --pc-ink: #f4e7cf;
  --pc-ink-quiet: #cdb597;
  --pc-key: #ffb45c;
  --pc-focus: #ffb45c;
  --pc-ok: #9fefc8;
  --pc-err: #ff8a80;
  /* The unlocked value's little screen: darker than the face, so a
     revealed address reads as something displayed ON the card rather
     than printed on it. Same role --ws-crawl plays in the guide. */
  --pc-crt-bg: #07060b;

  /* THE SHADOW IS THE SILHOUETTE, not a decoration — the same
     argument --ws-shadow carries, and the measurement below is what
     makes it an argument rather than a preference. Hard, black,
     down-right, ZERO BLUR: a blurred shadow is the one thing that
     would make this read as a modern card, and it is also the one
     thing that would stop working over a bright scene. */
  --pc-shadow: 12px 12px 0 #000;
  --pc-shadow-sm: 8px 8px 0 #000;

  /* Two textures, both borrowed rather than invented. The scanline is
     the CRT idiom #boot and engine/postfx.js already establish; the
     hatch is what a covered field looks like and is used for exactly
     two states, locked and broken. The ordered dither is not
     redeclared here at all — the card uses var(--glass-dither) from
     the top of this file, because the chrome dithers because the
     picture behind it dithers, and a second dither would be a second
     place to change one idea. */
  --pc-scan: repeating-linear-gradient(
    180deg,
    rgba(255, 255, 255, .045) 0, rgba(255, 255, 255, .045) 1px,
    rgba(0, 0, 0, .10) 1px, rgba(0, 0, 0, .10) 4px);
  --pc-hatch: repeating-linear-gradient(
    135deg,
    rgba(255, 255, 255, .11) 0, rgba(255, 255, 255, .11) 2px,
    rgba(0, 0, 0, 0) 2px, rgba(0, 0, 0, 0) 5px);
}

/* Hue only. No spacing, no track sizes, no font size, no shadow —
   the shadow is the same black at every hour precisely because the
   backdrop it is separating the card from is different at every hour.
   Each phase is built out from the accent that phase already uses
   (--accent at the top of this file), taken as the lit edge and
   darkened in steps for the shaded edge and the two face stops.
   [data-phase="night"] declares nothing: night IS the base register
   in :root above, and restating it would give the site two places to
   change one colour. */
[data-phase="dawn"] {
  --pc-face-a: #2b1a26;
  --pc-face-b: #140b12;
  --pc-edge: #e8a89a;
  --pc-edge-lo: #55333f;
}
[data-phase="noon"] {
  --pc-face-a: #12303f;
  --pc-face-b: #071820;
  --pc-edge: #7ed3f0;
  --pc-edge-lo: #1d4a5e;
}
[data-phase="golden"] {
  --pc-face-a: #33210f;
  --pc-face-b: #180d06;
  --pc-edge: #ef9f5a;
  --pc-edge-lo: #5a3a18;
}
[data-phase="dusk"] {
  --pc-face-a: #2c1018;
  --pc-face-b: #14060c;
  --pc-edge: #d0565f;
  --pc-edge-lo: #4c1f28;
}

/* -------------------------------------------------------------
   CONTRAST: WHAT IS COMPUTED AND WHAT STILL NEEDS A BROWSER.

   The honesty rule this file already keeps (see the guide's note
   above, and README.md's wordmark figures) is that a ratio is either
   measured or it is not quoted. Two kinds of claim live here and they
   are not the same kind.

   COMPUTED — exact, from the two hex values, because the card face is
   OPAQUE and there is no compositing to sample. These were produced
   by running the WCAG relative-luminance formula over the token pairs
   declared above, at both ends of every phase's face ramp; the figure
   given is the WORST end (--pc-face-a, the lighter, top stop):

     --pc-ink        on the face   11.30:1 (noon) … 14.38:1 (dusk)
     --pc-ink-quiet  on the face    7.01:1 (noon) …  8.92:1 (dusk)
     --pc-key        on the face    7.85:1 (noon) …  9.99:1 (dusk)
     --pc-ok         on the face   10.30:1 (noon) … 13.11:1 (dusk)
     --pc-err        on the face    6.05:1 (noon) …  7.70:1 (dusk)
     --pc-edge       on the face    3.82:1 (night) …  8.21:1 (dawn)
     every ink       on --pc-crt-bg  8.85:1 … 16.52:1

   So the worst text on the worst phase is 6.05:1, comfortably past AA
   for normal text, and the bevel clears 3:1 as a non-text boundary at
   every phase. None of that moves with the scene, which is the point
   of an opaque card.

   ALSO COMPUTED, and it is the reason the black shadow is not
   negotiable. The brightest thing this scene produces is noon sand,
   recorded further up this file as rgb(255, 255, 182) measured off
   the built page. Against it:

     --pc-shadow (#000)   20.23:1   the silhouette holds
     the face itself      13.31:1 (noon) … 16.94:1 (dusk)
     --pc-edge            1.62:1 (noon) … 4.33:1 (night)

   Read that last line properly: THE CARD'S BRIGHT BORDER DISAPPEARS
   INTO NOON SAND. It is a bevel, not the silhouette. What separates
   this object from the brightest backdrop the site can draw is its
   opaque dark body at 13:1 and the hard black shadow at 20:1 — which
   is why neither may be softened, thinned or made translucent later
   "because the border already outlines it".

   COMPUTED IN THIS PASS, same method, for the three things that are
   new since the figures above were taken — the column divider, the
   static bar and the dialog:

     --pc-edge-lo on the face   1.28 (dusk) … 1.51 (dawn)
     #808080, the bar's base    3.50 (noon) … 4.45 (dusk)
     white, which contrast(9)
       clips the bright half
       of the snow to           13.81 (noon) … 17.58 (dusk)
     black, the other half      1.19 (dusk) …  1.52 (noon)

   Read the first line the way the --pc-edge line above is read. THE
   SHADED BEVEL IS NOT A BOUNDARY ANYWHERE ON THIS CARD: at 1.3:1 the
   rule down the middle of the face, and the border round every
   product chip and contact row, are decoration and nothing more. The
   two columns are already separated by 20px of gap and by being made
   of different things, and every control they contain is a line of
   text that is 7:1 or better — nothing on this card asks a reader to
   SEE that border in order to use it. If something ever does, it
   needs --pc-edge, not --pc-edge-lo.

   The last two lines are why the bar reads as grain and not as a
   tile: its bright pixels are the highest-contrast thing on the card
   and its dark pixels vanish into the face.

   THE DIALOG ADDS NO NEW ARITHMETIC. Its surface is the same
   --pc-face-a / --pc-face-b ramp and its text the same four inks, so
   every figure in the first table applies to it unchanged. That is
   the reason it was given the card's material rather than a surface
   of its own.

   STILL NEEDS MEASURING OFF THE BUILT PAGE, in a browser, and NOT
   guessed at here:

     1. The card's outer rim (.pcard::before, --pc-edge-lo) against
        the live scene at all five phases. The figures above use the
        single brightest sand value; the scene is dithered and
        repaints every frame, so the sand is a RANGE and the rim sits
        on whatever part of it the card happens to land on.
     2. The same rim and shadow against the noon SEA and the golden
        sky, which are bright in a different channel from the sand and
        are not covered by the sand figure at all.
     3. --pc-focus over the scene where a focus ring on an edge
        control overhangs the card. It does not overhang today (every
        ring in this section is inset), so this is a check to repeat
        if that ever changes.
     4. The dialog's ::backdrop over the live scene. It is a scrim
        with no text on it, so no WCAG rule applies to it, but "how
        dark is noon sand through .68 of black" is still a number
        nobody here has taken.
     5. WHAT FRACTION OF THE STATIC BAR LANDS WHERE. The two ends
        above are exact because contrast() clips to them; the
        distribution between them depends on how the browser composites
        four translucent gradients and then applies a filter, which is
        a composite of the kind this section refuses to quote. The bar
        is decorative and aria-hidden, and the row's accessible name
        carries the meaning, so this is a curiosity rather than a gap.

   Nobody has run 1-5. They are listed as work, not reported as done.
   ------------------------------------------------------------- */

/* -------------------------------------------------------------
   The panel around the card is NOT a panel.

   card.js emits <section class="panel card-panel">, so the shared
   quantised-glass rule at the top of this file has already given this
   element a tint, a band, a dither, a blur, a 2px border and a hard
   shadow. Left alone that is a glass panel with a card inside it —
   glass on glass, which this file has already called a rendering
   mistake once (see .profile-resume-link) and which would also give
   the reader two nested rectangles where the design has one object.

   So .card-panel gives all of it back and becomes nothing but a box
   with room in it: the card is the only surface. The class is kept
   rather than removed from the markup because @media print's .panel
   rules and the tests that read .panel out of the built page both
   still want it there.

   The padding on the right and bottom is the card's own shadow and
   rim, held inside the column so the object cannot hang off the edge
   of a narrow viewport.
   ------------------------------------------------------------- */
/* -------------------------------------------------------------
   THE LEAD-IN ABOVE THE CARD, SHORTENED — the one rule in this
   section that reaches outside it, and the reason is arithmetic.

   .home-page above opens with `padding: min(28vh, 200px) 20px 120px`,
   a deliberate run of open beach before the first panel. The card has
   a requirement that lead-in cannot also satisfy: it must sit in the
   UPPER HALF of the first screen with the beach still showing below
   it. In an 800px window the halfway line is 400px, and 200px of it
   is spent before the card has started. 200 + any card at all is past
   the middle, every time.

   So the page that carries a #who takes a shorter lead-in — enough
   that the card is lying ON the beach rather than nailed to the top
   edge of the window, little enough that the card, its 6px underside
   rim and its 12px shadow all finish above the halfway line. Measured
   at 1200x800: the card's shadow bottom lands at 393px of 400. Every
   other thing .home-page declares is untouched, and no other route
   has a #who for this to match.

   :has() rather than a negative margin on #who, because the number
   being changed IS the lead-in and it belongs where the lead-in is
   declared. body:has(.hero--full) above already sets the precedent. A
   browser too old for :has() keeps the 200px and gets the card lower
   down the first screen — the wrong half, but nothing overlapping,
   nothing clipped and nothing unreadable.
   ------------------------------------------------------------- */
.home-page:has(#who) { padding-top: min(4vh, 32px); }

.card-panel {
  background: none;
  background-color: transparent;
  background-image: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  border: 0;
  box-shadow: none;
  margin: 0 0 28px;
  padding: 0 12px 12px 0;
}

/* -------------------------------------------------------------
   The card.

   THICKNESS, and why it is built the way it is. The tilt is real 3D,
   so the depth cues have to be real 3D too or the card reads as a
   flat rectangle being rotated — which is worse than no tilt at all,
   because the eye is told "object" and then shown "sheet".

   Four cues, in the order they do work:

     1. .pcard::before — the card's UNDERSIDE. A slab of --pc-edge-lo
        sitting 6px behind the face and 6px larger on every side, so a
        rim of the card's own dark edge shows all round. It is the one
        cue that RESPONDS to the tilt: rotate the card and the rim
        widens on the side turning away and narrows on the side
        turning toward you, which is what thickness looks like.
     2. The hard black shadow, on that slab, so the shadow belongs to
        the bottom of the object rather than to its front face.
     3. The bevel: --pc-edge on the top and left borders, --pc-edge-lo
        on the bottom and right. Lit from the top-left.
     4. .pcard-face at translateZ(6px) — the content plane floats
        proud of the card plane, so under tilt the type parallaxes
        against the border instead of being painted onto it.

   AT REST all of that flattens to something static and correct: with
   no transform on #card there is no perspective, translateZ has no
   projected effect, and what is left is a bevelled slab with a 6px
   dark rim and a hard shadow. That is also exactly what a
   reduced-motion reader gets, permanently, because engine/card.js
   binds nothing at all in that case — the card is never mid-tilt for
   them, so there is no half-state to design.

   THE SHAPE IS A BUSINESS CARD, and it is sized from the VIEWPORT'S
   HEIGHT rather than from its width. Two requirements, one box:

     PROPORTION. A card is 3.5 x 2 inches — 1.75:1, landscape. It is
     why the markup is two columns: name, role, what is being built
     and the products on the left, the four ways to reach and the
     resume link on the right. One stacked column at that ratio would
     be a strip of text with half the card empty beside it.

     The ratio is built out of a width and a floor rather than
     declared with `aspect-ratio`, and that is a decision with a
     measurement behind it. aspect-ratio pins the height absolutely:
     with one set, `min-height: min-content` does not override it and
     neither does the content — the box keeps the ratio and the type
     goes out through the bottom border. Tested, both ways round, on a
     grid box of exactly this shape. Since the card MUST be able to
     grow when the addresses arrive (below), the ratio is instead
     `width = height * 7 / 4` with the height as a floor, which gives
     exactly 7:4 whenever the content fits and yields when it does
     not.

     HALF THE SCREEN, NO MORE. The brief is that the card takes
     roughly the upper half of the first screen and leaves the beach
     showing under it. A width-only constraint cannot promise that: on
     a short window a card sized from the column width grows straight
     down through the halfway line. So the HEIGHT is the constrained
     dimension — --pc-card-h — and the width is derived from it:

       --pc-card-h  clamp(310px, 45vh - 36px, 380px)
       width        min(100%, --pc-card-h * 7 / 4)

     THE MIDDLE TERM IS THE WHOLE RULE and the 36px in it is not a
     round number: it is everything between the top of the window and
     the bottom of the object that is not the card's own box. 19px
     before the document starts, plus the 6px underside rim and the
     12px hard shadow that hang below it. Add the lead-in above
     (min(4vh, 32px)) and the card's shadow lands at 49vh + 1px, which
     is a pixel under the halfway line at every window height the term
     governs. Measured on the built page, halfway line first:

       1200 x 800   card 567 x 324, shadow bottom 393 of 400
       1200 x 900   card 646 x 369, shadow bottom 438 of 450
       1200 x 760   card 543 x 310, shadow bottom 377 of 380

     THE 380px CAP is the tall-monitor end: past about 1013px of
     window the term would go on growing and a business card is not a
     billboard. THE 310px FLOOR is the other end, and it is the one
     number here that is not a preference. This card cannot be scaled:
     both its faces are set at whole 8px multiples because a pixel
     font at 15px or 17px resamples into mush (see Type above), so
     shrinking the card does not shrink what is in it. Measured, the
     contents need 533 x 300 — a name whose longest balanced line is
     210px, a role line of 212px that must not be made to wrap, a
     product chip 170px wide, four contact rows whose widest is 224px,
     and 44px of padding. Twice the widest of those, plus the 20px gap
     and the 48px of padding and border, is 536; 533 / 1.75 is 305 and
     the floor is 310, which is that plus five pixels of air. Below it the card would not be
     small, it would be broken.

     So: from about 755px of window height upwards the card finishes
     above the halfway line, every time, and the vh term is what does
     it. Below 755px the floor holds and the card starts to cross:
     at 600px of window its shadow is 371px down a 300px half. That is
     the phone exception below, on the other axis — a card you can
     read that runs past the line beats a proportioned one you
     cannot — and it is written down here rather than left to be
     discovered.

     `width: min(100%, ...)` and not a bare width: the card is never
     allowed to be wider than the reading column it sits in, whatever
     the height says. What that costs on one narrow band of windows is
     set out under the floor below.

   THE CARD GROWS RATHER THAN SPILLS, and this pair of declarations is
   what makes that true:

     min-height: var(--pc-card-h)  on the card — a FLOOR, not a size
     grid-template-rows:           minmax(min-content, 1fr)
                                   on the card and on the face

   A floor with an auto height is a box that is exactly --pc-card-h
   tall while its contents fit and taller the moment they do not,
   which is the whole behaviour in one line. The row track is not
   decoration on that: `minmax(0, 1fr)` has a min-content contribution
   of ZERO, so a grid row written that way never reports that its
   contents need room and the box never grows — the type simply lies
   across the bevel. That was written, shipped, and found by
   measuring. `minmax(min-content, 1fr)` fixes it, and the 1fr half is
   still what stops the row growing past the free space and eating the
   card's bottom padding when there IS slack.

   THE FLOOR IS ALSO WHY THE RATIO IS NOT EXACT ON ONE RANGE OF
   WINDOWS. Between 601px and about 730px of viewport width the
   reading column is narrower than height * 7/4, so `min(100%, ...)`
   takes the column and the height stays on its floor: the card comes
   out a little squarer than 7:4 — 1.69 measured at 601x800, and as
   low as 1.44 on the rare tall-and-narrow window where the 380px cap
   is also in play. It is a few percent of shape on a window size
   nobody has, traded for a card that can grow. aspect-ratio would
   have held the shape there and lost the growth everywhere.

   IT ACTUALLY OPENS, ONCE, AND ON PURPOSE: when the reader answers
   the gate. Four revealed addresses are a great deal more text than
   four bars of static — the longest of the four addresses on this card
   is 26 characters where the bar was 72px — so the right column needs more height than
   the locked card has, and the card grows to give it. Measured at
   1200x800: 567 x 324 locked, 567 x 406 unlocked, ratio 1.75 to 1.39.
   The card's bottom edge crosses the halfway line when it does, which
   is correct: the reader asked for the rest of the card.
   That is the right trade. The alternative is a card that stays a
   perfect 7:4 and hides the addresses the reader has just proved they
   are allowed to see.

   display: grid so the face stretches to the card's full height
   without a percentage. A `height: 100%` child would work on the days
   the card is exactly its floor and fail on the day it has grown past
   it; a stretch item is right on both.

   overflow STAYS VISIBLE and that is load-bearing, not an oversight.
   Any overflow other than visible forces flattening of a
   transform-style: preserve-3d subtree, which would silently delete
   cues 1 and 4 above and leave the card looking exactly like the flat
   rectangle this whole rule exists to avoid. Every focus ring in this
   section is inset anyway (see the focus block below), so nothing
   depends on clipping.
   ------------------------------------------------------------- */
.pcard {
  --pc-card-h: clamp(310px, 45vh - 36px, 380px);
  position: relative;
  transform-style: preserve-3d;
  overflow: visible;
  display: grid;
  width: min(100%, calc(var(--pc-card-h) * 7 / 4));
  max-width: 100%;
  min-height: var(--pc-card-h);
  /* The face is the only child and it takes the card's content box
     EXACTLY. Without this the single implicit row is auto-sized, which
     means it grows past the free space if its own min-content
     contribution is larger — and a grid row that has grown does not
     know about the card's bottom padding, so the first symptom is
     type sitting 20px into a margin nobody can see. minmax(0, 1fr)
     pins the row to the space that is actually there — and the
     min-content floor on it is what keeps the escape valve above
     from being a no-op. */
  grid-template-rows: minmax(min-content, 1fr);
  /* Left-aligned, not centred: the card's left edge lines up with the
     left edge of every panel below it, so the page has one margin
     rather than a card floating in its own. */
  margin-right: auto;
  padding: 20px 22px;
  border: 2px solid var(--pc-edge-lo);
  border-top-color: var(--pc-edge);
  border-left-color: var(--pc-edge);
  border-radius: 0;
  color: var(--pc-ink);
  font-family: "Courier New", ui-monospace, monospace;
  /* .prose sets line-height 1.7 on the reading column this card sits
     in, and every element in here that does not declare its own
     inherits it. On 20px prose that is leading; on a 12px chip beside
     a 16px icon it is a 20px line box inside a 32px control, which is
     4px of nothing per chip and 16px off the height budget. */
  line-height: 1.4;
  /* Opaque. The colour is under the textures, so the scanline and the
     dither modulate the face rather than letting the beach through
     it. */
  background-color: var(--pc-face-b);
  background-image:
    var(--pc-scan),
    var(--glass-dither),
    linear-gradient(180deg, var(--pc-face-a), var(--pc-face-b));
  background-size: 100% 4px, 2px 2px, 100% 100%;
  background-repeat: repeat, repeat, no-repeat;
}

/* The underside. pointer-events: none because it is 6px of scenery
   sticking out past the card and must not swallow a click aimed at
   whatever is behind it. */
.pcard::before {
  content: "";
  position: absolute;
  inset: -6px;
  z-index: -1;
  transform: translateZ(-6px);
  background-color: var(--pc-edge-lo);
  box-shadow: var(--pc-shadow);
  pointer-events: none;
}

/* -------------------------------------------------------------
   The face, and the two halves of the card.

   The face is the content plane (translateZ(6px), see above) AND the
   two-column grid. Two equal halves, `minmax(0, 1fr)` twice, which is
   what a card split down the middle is — but the two numbers that
   make it safe were measured rather than assumed:

     The RIGHT half has to hold a contact row, and a contact row is
     three fixed widths in a trench coat: a 16px icon, a Silkscreen
     label up to 96px ("WhatsApp"), and the 72px static bar, plus
     gaps, padding and border. 224px, measured. The LEFT half has to
     hold the role line, set in Courier with .14em of tracking, at
     212px, and the name's longest balanced line at 210px. So the card cannot be narrower than 2 x 224 + the gap and
     the padding — which is where the 310px height floor above comes
     from, and it is checked at the floor, not just at 1200x800.

     minmax(0, 1fr) and NOT `auto`. auto sizes a track to its
     max-content, and the max-content of that right column changes the
     moment the reader unlocks: a revealed email address is 203px of
     Courier on one line and the column would take it, eating the left
     half of the card and squeezing the product chips out of it. A
     fraction cannot be widened by its own contents. What happens
     instead is that the addresses wrap and the card grows a little
     downward, which is the behaviour described above.

     The two halves are also why min-width: 0 is on .pcard-col: a grid
     item's automatic minimum is its content, and an email address is
     one unbreakable token — without it the "fraction" would be
     overridden by the very thing this rule exists to contain.

   The divider is a real 2px rule in the card's shaded bevel colour,
   the same border every control on this card uses, because that is
   what the printed version of this object has down its middle.

   Each column is a flex column so the halves can PIN THEIR LAST ITEM
   TO THE BOTTOM (`margin-top: auto` on .pcard-products and on
   .pcard-resume). A fixed-height card with its content ragged in the
   middle looks like a box that failed to fill; with the products
   sitting on the bottom-left and the resume link on the bottom-right
   the card has four corners doing work, which is how a card is laid
   out. It is also why the two columns stretch rather than start.
   ------------------------------------------------------------- */
.pcard-face {
  transform: translateZ(6px);
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  /* One row, exactly the height it is given — the same fix, and the
     same reason, as the card's own grid-template-rows above. */
  grid-template-rows: minmax(min-content, 1fr);
  align-items: stretch;
  gap: 0 20px;
}
.pcard-col {
  display: flex;
  flex-direction: column;
  gap: 8px;
  /* A grid item's automatic minimum size is its content; an email
     address and a 24px pixel name are both single unbreakable tokens
     wider than half a small card, and without this they would push
     the column — and the card — wider than the ratio allows. */
  min-width: 0;
}
.pcard-col--reach {
  border-left: 2px solid var(--pc-edge-lo);
  padding-left: 20px;
}

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

   TWO FACES, ONE RULE FOR WHICH: Silkscreen is the card's FIXED
   VOCABULARY — the name, the field labels, the two button words. The
   variable half — the role, what is being built, the product titles,
   a revealed address, the gate's answer to the reader — is Courier
   New. A pixel face is a piece of signage; a typewriter face is a
   readout. Reading the card, that split tells you what is printed on
   it and what is filled in.

   Silkscreen is set with the same discipline as .wordmark and the
   guide: WHOLE MULTIPLES OF 8PX (24 and 16 here, and 16 on a narrow
   screen), line-height 1, smoothing off, kerning and ligatures off,
   optimizeSpeed rasterisation, letter-spacing 0. The reason is given
   in full at .wordmark above — at 15px or 17px every stem straddles
   two device pixels, the engine resamples the bitmap, and the face
   ends up looking like a blurry small serif. letter-spacing stays 0
   because the .16em chip tracking is a Courier correction and adding
   it to a grid-drawn face puts fractional gaps between glyphs, which
   is the same fault by another route.

   TWO CLASSES ON THE NAME, everywhere it appears below. .pcard-name is
   an <h1>, and the front-page section above declares
   `.home-page h1 { font-family: "Courier New"; font-size: 30px;
   letter-spacing: .1em; text-transform: uppercase; }` at specificity
   (0,1,1) — which beats a bare .pcard-name (0,1,0) on every one of
   those properties. A single-class rule here does not lose the pixel
   face loudly; it loses it silently, and the card comes out in
   uppercase Courier looking like a deliberate choice. So the name is
   written .pcard .pcard-name (0,2,0) in the group below, in its own
   rule, in the narrow-screen block and in the print block.
   ------------------------------------------------------------- */
.pcard .pcard-name,
.pcard-gate-label,
.pcard-gate-go,
.pcard-contact-name,
.pcard-resume,
.gate-modal-title,
.gate-modal-close {
  font-family: "Silkscreen", "Courier New", ui-monospace, monospace;
  font-size: 16px;
  line-height: 1;
  letter-spacing: 0;
  text-transform: none;
  -webkit-font-smoothing: none;
  font-smooth: never;
  font-kerning: none;
  font-variant-ligatures: none;
  text-rendering: optimizeSpeed;
}

.pcard .pcard-name {
  margin: 0;
  font-size: 24px;
  color: var(--pc-key);
  /* A pixel face has no hyphenation and a long name has no spaces to
     break at. Wrapping mid-word is ugly; a name running off a 360px
     card is broken. */
  overflow-wrap: break-word;
  /* text-wrap: balance because greedy line breaking gets this name
     WRONG. "Zach Phillips-Gary" is 291px set at 24px Silkscreen and
     the column is 246, so it takes two lines either way — and greedy
     fills the first line as far as it can, which means breaking after
     the hyphen: "ZACH PHILLIPS-" over "GARY". Balancing minimises the
     longest line instead and breaks at the space, "ZACH" over
     "PHILLIPS-GARY", which is where a person would break it. A
     browser without it falls back to the greedy break: uglier, not
     broken. */
  text-wrap: balance;
}

/* -------------------------------------------------------------
   THE PROSE MARGINS, TAKEN BACK OFF. A bug that shipped, and the
   exact one the name's note above describes — found by measuring the
   built card rather than by reading the file.

   This card and its dialog are inside <main class="prose home-page">,
   and the reading column declares `.prose p, .prose li { margin: 0 0
   20px }` at (0,1,1). Every single-class rule in this section that
   says `margin: 0` on a <p> or an <li> is (0,1,0) and LOSES: the
   role, the line about what is being built, every product chip, every
   contact row and the gate's status line have all been carrying 20px
   of reading-column margin underneath them. On a page-length panel
   that is invisible. On a card 320px tall it is 40px down the left
   column and 80px down the right — the difference between content
   that fits the card and content that hangs 20px out of the bottom of
   it, which is exactly what it was doing.

   One rule, two classes each, all of them together, so there is a
   single place to look when it happens again. The 8px flex gaps in
   the columns and in the gate form are the spacing now, which is what
   those gaps were declared for.
   ------------------------------------------------------------- */
.pcard .pcard-role,
.pcard .pcard-building,
.pcard .pcard-product,
.pcard .pcard-contact-item,
.pcard .pcard-gate-status,
.gate-modal .gate-modal-why,
.gate-modal .pcard-gate-status { margin: 0; }

.pcard-role {
  margin: 0;
  font-size: 13px;
  line-height: 1.4;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--pc-ink-quiet);
}
.pcard-building {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--pc-ink-quiet);
}
/* Two classes, so this beats `.home-page a` (0,1,1) at the top of the
   front-page section, which would otherwise repaint every link in the
   card --chrome-accent regardless of the state it is in. Every
   coloured link rule below is written the same way and for the same
   reason. */
.pcard .pcard-building a {
  color: var(--pc-key);
  text-decoration: none;
  border-bottom: 2px solid var(--pc-edge-lo);
}
.pcard .pcard-building a:hover,
.pcard .pcard-building a:focus-visible { border-bottom-color: var(--pc-key); }

/* -------------------------------------------------------------
   Products.

   Wraps by default and STACKS below 480px rather than squeezing:
   these are domain names, they do not truncate into anything a reader
   can use, and four of them side by side on a 360px screen is four
   unreadable columns. See the narrow-screen block at the foot of this
   section.

   `margin-top: auto` puts them on the bottom edge of the left column —
   the card's bottom-left corner — and the 6px padding and gap are the
   height budget: four chips at 8px padding and an 8px gap cost 32px
   more than four at 6px, and 32px is a tenth of the card.
   ------------------------------------------------------------- */
.pcard-products {
  margin: auto 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.pcard .pcard-product > a {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  /* The icon is 16px and drawn on an 8-unit grid; matching the text's
     line box to it keeps the chip 32px tall instead of 36. */
  line-height: 16px;
  border: 2px solid var(--pc-edge-lo);
  font-size: 12px;
  letter-spacing: .1em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--pc-ink);
}
/* Border colour only. No shadow lift, no scale, NO TRANSITION — the
   same reason the guide's rows have none: a cable box and a
   PlayStation both changed state on a frame boundary, and an easing
   curve is the fastest way to make the whole thing read as a modern
   pastiche of the machine rather than the machine. It also means
   there is nothing here for prefers-reduced-motion to switch off. */
.pcard .pcard-product > a:hover { border-color: var(--pc-edge); color: var(--pc-key); }

/* -------------------------------------------------------------
   BROKEN, and it has to LOOK broken.

   .portfolio-card--broken above makes the same choice for the same
   reason — a malformed entry in content/pages/home.md belongs on the
   page where the author will see it, rather than silently shipping a
   row that links nowhere — and this is that intent carried through
   properly. Dashed, error-coloured, hatched, and full width so it
   breaks the product row's rhythm instead of sitting in it like a
   fifth product. It must never be mistakable for a style.

   The attribute selector catches any future --broken variant inside
   the card, so a new one cannot ship looking fine.
   ------------------------------------------------------------- */
.pcard-product--broken,
.pcard [class*="--broken"] {
  flex: 1 1 100%;
  padding: 8px 10px;
  border: 2px dashed var(--pc-err);
  background-color: rgba(255, 138, 128, .07);
  background-image: var(--pc-hatch);
  font-size: 12px;
  line-height: 1.5;
  letter-spacing: 0;
  text-transform: none;
  color: var(--pc-err);
}

/* -------------------------------------------------------------
   Contacts.
   ------------------------------------------------------------- */
/* The right column's contents. No top margin any more: this block is
   the TOP of its own column rather than the next thing after the
   products, and 8px of it would push the four rows out of line with
   the name opposite them. */
.pcard-contacts {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* The gate FORM. It lives in the dialog now rather than on the card,
   and it keeps these rules unchanged because they were never scoped to
   .pcard — see the dialog block below for the three that were, and
   what that cost. */
.pcard-gate {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
/* The rule that makes the form's own `hidden` attribute mean
   anything. contact-gate.js's open() sets `hidden` on the gate the
   moment the answer lands, and the UA stylesheet's
   [hidden] { display: none } is beaten by ANY author display
   declaration — so without this the answered gate would stay on the
   card, visible and in the tab order, asking a question the reader
   has already answered. Exactly the bug .nav-drawer[hidden] above
   exists to fix, in the same costume. */
.pcard-gate[hidden] { display: none; }

.pcard-gate-label {
  color: var(--pc-ink);
  /* line-height 1 is right for a one-line pixel label and wrong for a
     question that wraps on a phone. 16px — still a whole grid
     multiple, so the stems stay on device pixels — is the smallest
     value that leaves the rows apart. */
  line-height: 16px;
}

.pcard-gate-row {
  display: flex;
  gap: 8px;
  align-items: stretch;
}
.pcard-gate-input {
  flex: 1 1 auto;
  min-width: 0;
  padding: 9px 10px;
  border: 2px solid var(--pc-edge-lo);
  border-radius: 0;
  background-color: var(--pc-crt-bg);
  color: var(--pc-ink);
  font-family: "Courier New", ui-monospace, monospace;
  /* 16px is not a taste call: iOS Safari zooms the whole page in when
     a focused input's text is smaller than 16px, and a card that
     jumps to 200% the moment somebody taps the answer box is a card
     they then have to pinch their way back out of. */
  font-size: 16px;
  line-height: 1.25;
}
.pcard-gate-input::placeholder { color: var(--pc-ink-quiet); }
.pcard-gate-input:hover { border-color: var(--pc-edge); }

.pcard-gate-go {
  flex: 0 0 auto;
  padding: 9px 16px;
  border: 2px solid var(--pc-edge);
  border-radius: 0;
  background-color: transparent;
  color: var(--pc-key);
  cursor: pointer;
}
.pcard-gate-go:hover { background-color: var(--pc-edge-lo); color: var(--pc-ink); }

/* The status line.

   COLOUR IS NOT THE ONLY CHANNEL (WCAG 1.4.1): each state also gets a
   4px rule down its left edge, so the difference survives a reader
   who cannot separate the red from the mint. The channel is a BORDER
   rather than a ::before glyph on purpose — this element carries
   role="status", it is a live region, and generated content inside
   one is announced by some screen readers and not others, which turns
   "Not quite — try again." into "exclamation exclamation Not quite"
   for an unlucky subset.

   The base state is deliberately blank: with no data-state the
   element is empty, and an empty box with a rule down it is a reader
   wondering what they missed. */
.pcard-gate-status {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--pc-ink-quiet);
}
.pcard-gate-status[data-state="error"] {
  padding-left: 8px;
  border-left: 4px solid var(--pc-err);
  color: var(--pc-err);
}
.pcard-gate-status[data-state="ok"] {
  padding-left: 8px;
  border-left: 4px solid var(--pc-ok);
  color: var(--pc-ok);
}

.pcard-contact-list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* The row. A grid rather than a flex line so the icon column, the
   label column and the value column line up down the list — four
   rows whose labels start at four different x positions read as four
   unrelated things. minmax(0, …) on the value track because an email
   address is one unbreakable word and a 1fr track's default minimum
   is its content, which would push the row wider than the card. */
.pcard .pcard-contact-link {
  display: grid;
  grid-template-columns: 16px auto minmax(0, 1fr);
  gap: 8px;
  align-items: center;
  padding: 8px 10px;
  border: 2px solid var(--pc-edge-lo);
  text-decoration: none;
  color: var(--pc-ink);
}
.pcard-contact-name { color: var(--pc-ink); }
.pcard-contact-value {
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 13px;
  line-height: 1.3;
  /* An address is a single long unbreakable token; on a 360px card it
     either wraps mid-word or leaves the row. */
  overflow-wrap: anywhere;
}

/* -------------------------------------------------------------
   LOCKED — unavailable, but not dead.

   .pcard-contact-link[aria-disabled="true"] is a REAL ANCHOR pointing
   at #contactGate. Clicking it does something: it takes the reader to
   the question that opens it. So it must not be styled as a corpse —
   no opacity fade, no grey-out, no not-allowed cursor, all three of
   which say "this control is finished with you".

   What it says instead: the label is present and readable, the VALUE
   is a bar of television snow — a channel with nothing tuned in — and
   the whole row lights its border on hover and focus like every other
   control on this card, because it is one.

   THE VALUE CELL ITSELF IS NOW BARE. It used to carry the hatch and
   the uppercase tracking because it used to contain the word
   "Locked"; the markup no longer puts any text in it at all, only
   .pcard-fuzz and a screen-reader line. A hatched box behind a bar of
   static is two coverings over one absence, so the cell hands the
   whole job to the bar and keeps only `justify-self: start`, which
   stops the empty cell stretching across the row's 1fr track.

   No opacity anywhere in this section, incidentally, and not only for
   the contrast reason: opacity below 1 is one of the properties that
   forces a preserve-3d subtree to flatten, so an opacity fade here
   would quietly cost the card its thickness.
   ------------------------------------------------------------- */
.pcard .pcard-contact-link[aria-disabled="true"] .pcard-contact-name {
  color: var(--pc-ink-quiet);
}
.pcard .pcard-contact-link[aria-disabled="true"] .pcard-contact-value {
  justify-self: start;
  padding: 0;
  background: none;
  letter-spacing: 0;
  text-transform: none;
}

/* -------------------------------------------------------------
   THE STATIC. .pcard-fuzz is an empty aria-hidden <span> and this
   rule is the whole of what it is: a bar of television snow standing
   where a handle, an address or a number will appear.

   THE WIDTH IS FIXED AND THAT IS THE POINT. 72px for every row —
   email, Telegram, WhatsApp, Line — whatever is behind it. A bar
   measured to its value would look better and would publish the
   LENGTH of a hidden address to anyone who cares to count pixels,
   which is a real signal about a real string; test/contact-gate.js
   asserts the markup carries no inline width or length hint for the
   same reason, and this is that promise kept on the CSS side. The
   height is 15px: the row's own line box, so a locked row and an
   unlocked one are the same height and the list does not jump when
   four values arrive at once.

   HOW THE SNOW IS MADE, and what it is not. It is not an SVG
   turbulence data URI: this site's CSP is `img-src 'self'` (see
   src/build/security-headers.js), a `data:` URL is a different source
   under that directive, and a background-image the browser refuses to
   fetch is a bar of flat colour with a console error behind it. So
   the noise is built from the two textures this file already
   declares, and nothing new is invented:

     --glass-dither  the ordered 1px checkerboard, laid FOUR times at
                     2x2, 3x5, 5x3 and 7x7 pitches, each nudged by a
                     different offset. The four periods are co-prime,
                     so the composite does not come back round to
                     itself for 210px in x and 210px in y — three
                     times the width of the bar and fourteen times its
                     height, which is why it reads as grain rather
                     than as a pattern. Three layers at 2, 3 and 5 was
                     the first attempt and it tiled every 30px: on
                     screen it looked like argyle.
     --pc-scan       the CRT scanline, under them, so the bar is snow
                     on a television rather than noise on paper.

     filter: contrast(9) IS THE AMPLIFIER. Both sources are deliberately
     faint — ±5% and ±10% — because everywhere else they are modulating
     a surface, not being looked at. Over a base of exactly #808080,
     i.e. 0.5 in the space CSS filters work in, contrast(9) multiplies
     each layer's distance from mid-grey by nine, and four stacked
     layers of it clip to black and to white in different places: that
     is the snow. No third texture exists anywhere to keep in step
     with the other two.

     The filter is safe HERE and would not be one rule up. Filters
     force `transform-style: flat` on the element that carries them —
     which is why there is no opacity anywhere in this section — but
     they force it on THAT element only. .pcard-fuzz is a leaf: it has
     no children, declares no preserve-3d, and sits inside .pcard-face,
     which is already flat. The card's thickness is unaffected, and
     the built page confirms .pcard still computes preserve-3d.
   ------------------------------------------------------------- */
.pcard-fuzz {
  display: inline-block;
  width: 72px;
  /* Fixed, but never wider than the cell it is in. In every layout
     measured the track is wider than 72px and this does nothing; it
     is here because a bar that overflows its row draws straight
     through the row's right border, which is what a fixed width with
     no ceiling does on the first narrow window nobody tested. All
     four rows share one track, so a bar that ever did shrink would
     shrink by the same amount in every row and still say nothing
     about the value under it. */
  max-width: 100%;
  height: 15px;
  vertical-align: -3px;
  /* Exactly mid-grey, and it has to be: contrast() pivots on 0.5,
     so a base even a few points off centre comes out of the filter as
     a bar that is mostly white or mostly black rather than as snow. */
  background-color: #808080;
  background-image:
    var(--pc-scan),
    var(--glass-dither),
    var(--glass-dither),
    var(--glass-dither),
    var(--glass-dither);
  background-size: 100% 4px, 2px 2px, 3px 5px, 5px 3px, 7px 7px;
  background-position: 0 0, 0 0, 1px 0, 2px 1px, 3px 2px;
  filter: contrast(9);
  /* Four frames, held — steps(1, end) — at about 14 a second. Enough
     that the bar is alive and not enough to drag the eye off the
     labels beside it. Switched off completely under
     prefers-reduced-motion at the foot of this section: a bar that
     shimmers next to text forever is the exact thing that setting is
     for. */
  animation: pcard-snow .28s steps(1, end) infinite;
}

/* The frames. Only the background POSITIONS move: each layer slides
   by a different amount, so the three checkerboards land out of phase
   with each other and the composite is a different field of grain
   every frame rather than one pattern sliding across the bar. The
   scanline moves too, by a whole line, which is the vertical roll of
   an untuned channel. */
/* Four bars in lockstep is one bar drawn four times, which reads as a
   texture applied to a list rather than as four dead channels. A
   negative delay of one frame per row starts each bar at a different
   point in the same four-frame cycle, so at any instant the four are
   showing four different fields of grain. Negative rather than
   positive: a positive delay would leave the lower rows sitting still
   for a fifth of a second after the page loads. */
.pcard-contact-item:nth-child(2) .pcard-fuzz { animation-delay: -.07s; }
.pcard-contact-item:nth-child(3) .pcard-fuzz { animation-delay: -.14s; }
.pcard-contact-item:nth-child(4) .pcard-fuzz { animation-delay: -.21s; }

@keyframes pcard-snow {
  0%   { background-position: 0 0,   0 0,    1px 0,   2px 1px, 3px 2px; }
  25%  { background-position: 0 1px, 1px 1px, 2px 3px, 0 2px,   5px 6px; }
  50%  { background-position: 0 2px, 0 1px,  0 2px,   3px 0,    1px 4px; }
  75%  { background-position: 0 3px, 1px 0,  2px 4px, 1px 2px,  6px 1px; }
}
.pcard .pcard-contact-link:hover { border-color: var(--pc-edge); }
.pcard .pcard-contact-link:hover .pcard-contact-name { color: var(--pc-key); }

/* -------------------------------------------------------------
   UNLOCKED — the set powering on.

   contact-gate.js sets data-contact-open="true" on each link as it
   swaps the blob for the real address. The state reuses the CRT
   vocabulary this file already has: the value lands on a dark screen
   with #boot's amber on it, scanlined, and the row's border comes up
   from the shaded edge to the lit one.

   It is A CHANNEL TUNING IN, in THREE HARD STATES, steps(1, end),
   360ms: the snow the bar left behind, still on the screen; the field
   flash as the tuner locks; the settled screen with the address on
   it. No easing curve — a CRT settling is not an ease-out, and every
   other state change on this card is a frame boundary.

   The first frame has to redraw the static rather than keep it,
   because by the time this animation runs .pcard-fuzz does not exist:
   contact-gate.js's reveal() sets textContent on the value, which
   deletes the bar and the screen-reader line with it. So frame one
   restates the same two textures at the same amplification — the same
   --glass-dither and --pc-scan, the same contrast(9) over the same
   #808080 — and hides the arriving text under `color: transparent`
   for a tenth of a second. The reader sees the noise they were
   looking at a moment ago resolve into an address, which is what a
   channel coming in looks like and is exactly what has happened.

   It is switched off entirely under prefers-reduced-motion below,
   where the unlocked row simply IS the settled screen.

   It also runs on arrival for a reader who answered earlier in the
   tab — contact-gate.js replays the stored key on load — which is
   correct: the details are appearing for the first time on this page
   either way.

   THE ROW RESTACKS WHEN IT OPENS, and that is arithmetic rather than
   taste. Locked, the three tracks are icon, label, 72px bar and the
   row is 224px wide. Unlocked, the third track has to hold the
   longest of the four values — 203px of 13px Courier, measured off the
   built page — in the 94px the label leaves it, which comes out as
   three lines of a broken-up address.
   Dropping the value onto its own line under the label gives it the
   full 198px instead: one line for the phone number and both
   handles, two for the long address. It is the same two-line row the
   narrow-screen block at the foot of this section uses at 480px, for
   the same reason, and it is why the card grows 82px when the gate
   opens.
   ------------------------------------------------------------- */
.pcard .pcard-contact-link[data-contact-open="true"] {
  border-color: var(--pc-edge);
  color: var(--pc-ink);
  grid-template-columns: 16px minmax(0, 1fr);
  align-items: start;
}
.pcard .pcard-contact-link[data-contact-open="true"] .pcard-contact-name {
  color: var(--pc-ink);
}
.pcard .pcard-contact-link[data-contact-open="true"] .pcard-contact-value {
  padding: 4px 6px;
  /* Column 2 of the two-track row above: under the label, not
     beside it. */
  grid-column: 2;
  /* Hugs its text, exactly as the locked chip does. Left to fill the
     1fr track the screen is a wide dark rectangle with a short address
     adrift at one end of it, which reads as an empty input box the
     reader is being asked to type into — the opposite of the state it
     is announcing. max-width keeps a long address inside the row it
     is now allowed to be shorter than. */
  justify-self: start;
  max-width: 100%;
  background-color: var(--pc-crt-bg);
  background-image: var(--pc-scan);
  background-size: 100% 4px;
  color: var(--pc-key);
  letter-spacing: 0;
  text-transform: none;
  animation: pcard-powerup .36s steps(1, end) 1;
}
.pcard .pcard-contact-link[data-contact-open="true"]:hover {
  border-color: var(--pc-key);
}

@keyframes pcard-powerup {
  /* 1. the snow, redrawn: the bar the reader was looking at */
  0%, 32% {
    background-color: #808080;
    background-image:
      var(--pc-scan),
      var(--glass-dither),
      var(--glass-dither),
      var(--glass-dither),
      var(--glass-dither);
    background-size: 100% 4px, 2px 2px, 3px 5px, 5px 3px, 7px 7px;
    background-position: 0 0, 0 0, 1px 0, 2px 1px, 3px 2px;
    filter: contrast(9);
    color: transparent;
  }
  /* 2. the field flash as it locks */
  33%, 65% {
    background-color: var(--pc-key);
    background-image: none;
    filter: none;
    color: var(--pc-crt-bg);
  }
  /* 3. the settled screen, which is also the resting state */
  66%, 100% {
    background-color: var(--pc-crt-bg);
    background-image: var(--pc-scan);
    background-size: 100% 4px;
    filter: none;
    color: var(--pc-key);
  }
}

/* -------------------------------------------------------------
   The icons.

   16x16 viewBoxes drawn from rectangles on an 8-unit grid (see the
   ICONS table in src/build/card.js). They are set at their native
   16px — scaling a shape drawn on an 8-unit grid to 15 or 17 puts
   every edge on a half pixel, which is the same fault as setting
   Silkscreen at a fractional size. shape-rendering: crispEdges turns
   off the antialiasing that would otherwise round those corners into
   grey; the one arc in the set (the phone) comes out visibly stepped,
   which is the correct answer for this site rather than a defect.

   fill: currentColor, so an icon is never a colour of its own: it
   inherits from whatever state its row is in — quiet when locked,
   amber on hover, key when unlocked — and there is no second place to
   update when a phase colour moves. */
.pcard-icon {
  display: block;
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
  fill: currentColor;
  shape-rendering: crispEdges;
}

/* -------------------------------------------------------------
   The resume link — the card's one outgoing control.
   ------------------------------------------------------------- */
.pcard .pcard-resume {
  align-self: flex-start;
  /* The card's bottom-right corner, opposite the products. See the
     face's note on pinning the last item in each column. */
  margin-top: auto;
  padding: 9px 14px;
  border: 2px solid var(--pc-edge);
  text-decoration: none;
  color: var(--pc-key);
}
.pcard .pcard-resume:hover {
  background-color: var(--pc-edge-lo);
  color: var(--pc-ink);
}

/* -------------------------------------------------------------
   THE GATE IS A <dialog>, IN THE TOP LAYER, AND NOT PART OF THE CARD.

   engine/contact-gate.js opens it with showModal(). That puts it in
   the TOP LAYER: it paints above every stacking context on the page
   whatever their z-index, the browser draws a ::backdrop behind it,
   Escape closes it and focus is trapped inside it without a line of
   script. None of the card's positioning, none of its 3D and none of
   its ancestors reach it.

   SO IT DOES NOT INHERIT THE CARD'S LOOK — it has to be given one. It
   is a sibling of <section class="panel card-panel">, so it gets no
   font-family from .pcard (it would fall back to body's Georgia), no
   colour, no surface. What it takes deliberately is the card's
   MATERIAL — the same face ramp, the same scanline, the same dither,
   the same bevel and the same hard black shadow — so that the thing
   that opens when you touch a locked row is recognisably cut from the
   card you touched. What it does not take is the card's SHAPE: no
   7:4 ratio, no viewport-height cap, no two columns, no thickness.
   It is a dialog box, and it should look like one.

   THE `dialog:not([open])` RULE IS NOT OPTIONAL. The UA stylesheet
   hides a closed dialog with `dialog:not([open]) { display: none }`,
   and ANY author display declaration on .gate-modal beats it — the
   dialog would then sit in the page, open or not, in the flow and in
   the tab order, with the answer to the question in it. This file has
   now shipped that exact bug twice: .nav-drawer[hidden] above exists
   because of it, and the pass before this one hit it again on
   .pcard-gate[hidden]. The rule below is written with both the bare
   element selector — restoring the UA behaviour for any dialog this
   site ever adds — and the class, so that adding a `display` to
   .gate-modal later cannot quietly undo it.

   CENTRED AND CAPPED. `position: fixed; inset: 0; margin: auto` is
   the centring; `width: min(34rem, calc(100vw - 32px))` is the cap
   and the small-screen behaviour in one line — 34rem of Courier is a
   comfortable measure for two sentences of explanation, and at 360px
   the dialog is 328px wide with 16px of backdrop either side.
   max-height plus `overflow: auto` is for the short window (a phone
   in landscape with a keyboard up): the dialog scrolls, rather than
   putting the Unlock button off the bottom of the screen. dvh with a
   vh line before it, because dynamic viewport units are exactly for
   the case where a browser's own chrome is coming and going.

   THE BACKDROP DIMS; IT DOES NOT STOP THE SCENE. A translucent black
   over the beach, no blur: the scene is still running, still
   repainting, and still visible through the dim, which is the honest
   picture of what is happening — this dialog interrupts the READER,
   not the page. A backdrop-filter here would be a full-viewport blur
   composited over a 60fps canvas, which costs frames on exactly the
   machines this site is pretending to be.

   The colour is written literally rather than as var(--glass-tint) or
   var(--glass-dither). ::backdrop only began inheriting custom
   properties from its originating element recently; in a browser
   where it does not, `background: var(--anything)` resolves to the
   guaranteed-invalid value and the reader gets NO DIM AT ALL behind a
   modal. A literal rgba() cannot fail that way.

   CONTRAST. The dialog's face is the same two tokens as the card's —
   --pc-face-a to --pc-face-b — so every figure in the measurement
   note at the top of this section carries over unchanged and by
   arithmetic rather than by eye: --pc-ink on it is 11.30:1 at noon
   through 14.38:1 at dusk, --pc-ink-quiet 7.01:1 through 8.92:1,
   --pc-key 7.85:1 through 9.99:1, and the status pair 10.30:1 (ok)
   and 6.05:1 (error) at worst. The one case that is NOT covered by
   that arithmetic and has NOT been measured is the ::backdrop itself
   over the live scene: it is a scrim with no text on it, so nothing
   in WCAG applies to it, but "how dark is the beach through .68 of
   black at noon" is a number nobody here has taken.
   ------------------------------------------------------------- */
.gate-modal {
  display: block;
  position: fixed;
  inset: 0;
  margin: auto;
  width: min(34rem, calc(100vw - 32px));
  max-height: calc(100vh - 32px);
  max-height: calc(100dvh - 32px);
  overflow: auto;
  padding: 22px 24px 24px;
  border: 2px solid var(--pc-edge-lo);
  border-top-color: var(--pc-edge);
  border-left-color: var(--pc-edge);
  border-radius: 0;
  box-shadow: var(--pc-shadow);
  color: var(--pc-ink);
  font-family: "Courier New", ui-monospace, monospace;
  background-color: var(--pc-face-b);
  background-image:
    var(--pc-scan),
    var(--glass-dither),
    linear-gradient(180deg, var(--pc-face-a), var(--pc-face-b));
  background-size: 100% 4px, 2px 2px, 100% 100%;
  background-repeat: repeat, repeat, no-repeat;
  /* Two hard states, the set coming on: a bright band, then the box.
     Same steps(1, end) idiom as the unlock, same reason, and switched
     off with it under prefers-reduced-motion. */
  animation: gate-modal-on .16s steps(1, end) 1;
}
dialog:not([open]),
.gate-modal:not([open]) { display: none; }

.gate-modal::backdrop {
  background-color: rgba(4, 3, 8, .68);
}

@keyframes gate-modal-on {
  0%, 49%   { transform: scaleY(.06); }
  50%, 100% { transform: scaleY(1); }
}

.gate-modal-title {
  margin: 0;
  font-size: 16px;
  color: var(--pc-key);
}
.gate-modal-why {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--pc-ink-quiet);
}
/* Cancel. Quieter than Unlock on purpose — the shaded bevel rather
   than the lit one, the quiet ink rather than the key — because it is
   the way out, not the thing to do. It still lights on hover and it
   still takes the same inset ring as everything else. */
.gate-modal-close {
  align-self: flex-start;
  margin-top: 4px;
  padding: 9px 16px;
  border: 2px solid var(--pc-edge-lo);
  border-radius: 0;
  background-color: transparent;
  color: var(--pc-ink-quiet);
  cursor: pointer;
}
.gate-modal-close:hover {
  border-color: var(--pc-edge);
  color: var(--pc-ink);
}

/* -------------------------------------------------------------
   FOCUS — every interactive thing on the card, in one place.

   Six controls take focus here: the product links, the contact links
   (locked ones included — aria-disabled is a message to assistive
   technology, not a removal from the tab order, and a locked row is
   still a link to the gate), the resume link, and — in the dialog,
   which is a sibling of the card and not a part of it — the answer
   input, the Unlock button and Cancel. A focusable control with no
   visible focused state is a WCAG 2.4.7 failure outright.

   THE THREE GATE SELECTORS LOST THEIR `.pcard ` PREFIX, and that is a
   fix rather than a tidy-up. The gate form used to live inside the
   card; it is now inside <dialog class="gate-modal">, which is a
   sibling of the section the card is in and, once showModal() has run,
   is not a descendant of anything on the page. `.pcard
   .pcard-gate-input:focus-visible` matched nothing at all after that
   move — three controls with no focus ring, in a modal, where the
   keyboard is the only way in. The prefix was never carrying
   specificity for these three anyway: the rule they have to outrank is
   the site-wide `button:focus-visible` at (0,1,1), and a class plus a
   pseudo-class is (0,2,0).

   THE RING IS INSET, offset -3px, on all six. Three reasons now, and
   the second is the one that would have bitten:

     * It matches the guide's .g-grip and .g-row, so this site has one
       focus idiom rather than two.
     * The dialog scrolls (`overflow: auto`, for a small window), and
       an overflow container CLIPS its own paint: an outset ring on
       the Unlock button inside it would be sliced off wherever it met
       the dialog's edge. Inset, there is nothing to clip.
     * An outset ring on a card that is TILTED IN 3D is drawn in the
       card's rotated plane, hanging off the edge of the object over
       whatever the beach is showing there — which is a ring whose
       contrast is a function of the time of day and the camera
       position, i.e. unmeasurable. Inset, it sits on the card's own
       opaque face, where its contrast is the arithmetic above and the
       same at every hour.

   --pc-focus is fixed rather than phase-tinted for the same reason:
   one ring to verify, not five.

   :focus-visible and not :focus — a mouse click on a link should not
   leave a ring behind it. The input is the exception that needs no
   exception: focus-visible always matches for text entry.
   ------------------------------------------------------------- */
.pcard .pcard-product > a:focus-visible,
.pcard .pcard-contact-link:focus-visible,
.pcard .pcard-resume:focus-visible,
.pcard-gate-input:focus-visible,
.pcard-gate-go:focus-visible,
.gate-modal-close:focus-visible {
  outline: 3px solid var(--pc-focus);
  outline-offset: -3px;
  border-color: var(--pc-edge);
}
/* The border half is stated separately for the input because its
   resting border is the shaded edge and a field being typed into
   should look live even in a forced-colours mode that drops the
   outline colour. */
.pcard-gate-input:focus { border-color: var(--pc-focus); }

/* -------------------------------------------------------------
   Narrow screens, in two steps, because two different things stop
   working at two different widths.

   600px — THE CARD STOPS BEING A LANDSCAPE CARD. This is the
   DELIBERATE EXCEPTION to the upper-half rule above, and it is worth
   being explicit about it because it looks from the outside exactly
   like the rule failing:

     A 1.75:1 card at 360px wide is 206px tall and its two columns are
     about 150px each. That is a name broken across three lines and an
     address in a column the width of a thumb. The proportion is the
     whole point of the object on a screen wide enough to hold it, and
     unreadable on one that is not, so below 600px the ratio is
     released, the two columns become one, and the card takes the
     height its content needs. ON A PHONE IT WILL BE MORE THAN HALF
     THE SCREEN. That is the trade, taken knowingly: a card you can
     read that runs past the halfway line beats a card in perfect
     proportion that you cannot.

     The divider follows the fold — a rule down the middle of one
     column is a rule down nothing — and becomes the line between the
     two halves where the fold now is.

   480px — THE ROWS AND CHIPS STOP FITTING. Four product chips and a
   three-column contact row both technically fit at 360px, in the
   sense that they do not overflow: they just become columns two
   characters wide. So the products go to one per line, the contact
   row drops its value onto a second line under the label, and the
   gate's input and button stop sharing a line.

   The name steps 24px -> 16px: one whole grid multiple down, never to
   20px, for the reason given under Type above. The shadow steps to
   8px so the object still clears the column at this width.
   ------------------------------------------------------------- */
@media (max-width: 600px) {
  .pcard {
    width: 100%;
    /* The height floor comes off with the ratio: a stacked card is as
       tall as its contents and nothing else. */
    min-height: 0;
  }
  .pcard-face {
    grid-template-columns: minmax(0, 1fr);
    gap: 16px;
  }
  .pcard-col--reach {
    border-left: 0;
    border-top: 2px solid var(--pc-edge-lo);
    padding-left: 0;
    padding-top: 16px;
  }
  /* Nothing to pin to the bottom of a column that is now as tall as
     its own content. */
  .pcard-products { margin-top: 8px; }
  .pcard .pcard-resume { margin-top: 8px; }
}

@media (max-width: 480px) {
  .card-panel { padding: 0 8px 8px 0; }
  .pcard { padding: 16px 14px; }
  .pcard::before { box-shadow: var(--pc-shadow-sm); }
  .pcard .pcard-name { font-size: 16px; }

  .pcard-products { flex-direction: column; }
  .pcard .pcard-product > a { width: 100%; }

  .pcard .pcard-contact-link {
    grid-template-columns: 16px minmax(0, 1fr);
    align-items: start;
  }
  .pcard-contact-value { grid-column: 2; }

  .pcard-gate-row { flex-direction: column; align-items: stretch; }
  .pcard-gate-go { width: 100%; }
}

/* -------------------------------------------------------------
   Reduced motion.

   NOTHING ON THIS CARD MOVES. engine/card.js binds no pointer, touch
   or orientation handler at all when the setting is on, and clears
   the inline transform if the setting is turned on mid-page — so the
   card is permanently flat for these readers, and CSS must not put
   the motion back by another door.

   This section declares no transition anywhere (see the products'
   hover note), so there are exactly three moving parts and all three
   go:

     THE STATIC. .pcard-fuzz's four-frame snow is the one thing here
     that runs FOREVER, next to text, for as long as the reader leaves
     the page open — which is the precise thing this setting exists to
     stop. Frozen, it is still a bar of grain standing where a value
     is not: the state survives, only the movement goes.
     THE UNLOCK. The tuning-in is three frames of an event that has
     already happened; the unlocked row is a STATE, not the end of an
     animation, so removing it removes the flash and changes nothing
     else — the screen still lights, it just does not arrive.
     THE DIALOG'S ENTRANCE. A modal that snaps open from a band is a
     modal that moves under a reader who did not ask anything to move.
     Without it the dialog is simply there, which is what it is.

   The blanket transition: none is belt to those braces. It is written
   with the same selectors the states above use rather than as .pcard
   * so that it actually outweighs them; a bare .pcard * loses to
   .pcard .pcard-contact-link[data-contact-open="true"] on
   specificity, which is the classic way a reduced-motion block ends
   up doing nothing at all. .gate-modal is named separately for a
   different reason: it is not inside .pcard any more, so .pcard *
   never reached it.
   ------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  .pcard,
  .pcard *,
  .pcard-fuzz,
  .gate-modal,
  .gate-modal *,
  .pcard .pcard-contact-link[data-contact-open="true"] .pcard-contact-value {
    animation: none;
    transition: none;
  }
}

/* -------------------------------------------------------------
   The card on paper.

   Black on white, like the rest of .home-page already prints. The
   site-wide print block above already strips .panel's glass and
   forces the front page's colours to #000, but it knows nothing about
   .pcard: left alone the card would print as a dark slab with cream
   text on it, i.e. an empty rectangle, which is the exact failure
   that block's own comment describes for the panels.

   THE TRANSFORM OVERRIDE IS THE ONE EXCEPTION to this section's rule
   about never setting transform on the card. engine/card.js leaves an
   inline `perspective(700px) rotateX(…) rotateY(…)` on the element,
   and whatever angle the reader's pointer happened to leave it at is
   the angle it would print at — a card sheared across the page and
   clipped at the margin. It is scoped to @media print, so it never
   runs on screen and never fights the animation frame.

   THE SCREEN'S PROPORTIONS ARE A SCREEN'S PROBLEM. --pc-card-h is
   built out of vh, and vh on paper is the page box — a 45vh card on
   A4 is a third of a sheet with the derived width stretched across
   it. The height floor and the derived width both come off here and
   the card prints at the width of the column and the height of its
   contents.
   The two COLUMNS stay: a card is two columns, and that is as true on
   paper as it is on glass.

   The shortened lead-in comes off too. The print block above already
   sets `.home-page { padding: 0 }`, but .home-page:has(#who) at the
   top of this section is (0,2,0) and outranks it — so the first sheet
   would open with 32px of nothing before it did anything else.

   What survives: the name, the role, what is being built, the
   products, the contact rows and the resume link. What goes: the
   textures, the thickness, the shadow, the gate FORM and the whole
   DIALOG — an answer box and an Unlock button are nothing on paper,
   and a modal that happens to be open when somebody hits print is not
   the document. The contact rows print exactly as they stand on
   screen: the real address if the reader answered, and if they did
   not, an empty dashed field of the same fixed width the static bar
   had. A bar of snow prints as a grey smear (or, with backgrounds off
   as they are by default, as nothing at all); a ruled empty field
   says "withheld" in the vocabulary paper already has. That is the
   honest behaviour either way: the printed card says what the page
   said.
   ------------------------------------------------------------- */
@media print {
  .card-panel { padding: 0; border-bottom: 0; }
  .home-page:has(#who) { padding-top: 0; }
  .gate-modal { display: none !important; }

  .pcard {
    transform: none !important;
    transform-style: flat;
    width: auto;
    min-height: 0;
    background: none !important;
    background-color: transparent !important;
    background-image: none !important;
    box-shadow: none !important;
    border: 0.5pt solid #999;
    padding: 8pt;
    color: #000 !important;
    break-inside: avoid;
    page-break-inside: avoid;
  }
  .pcard::before { display: none !important; }
  .pcard-face { transform: none !important; gap: 0 12pt; }
  .pcard-col--reach { border-left: 0.5pt solid #999; padding-left: 12pt; }

  /* The withheld field. No animation to run on paper, no filter to
     amplify a texture the printer has already dropped — a ruled box
     of the same fixed width for every row, so nothing about it
     measures the value it is standing in for. 60pt is the 72px the
     screen uses, in the unit the rest of this block is written in. */
  .pcard-fuzz {
    animation: none !important;
    filter: none !important;
    background: none !important;
    width: 60pt;
    height: 9pt;
    border: 0.5pt dashed #000;
  }

  .pcard-gate { display: none !important; }
  .pcard-gate-status { display: none !important; }

  .pcard-name,
  .pcard-role,
  .pcard-building,
  .pcard-product,
  .pcard-contact-name,
  .pcard-contact-value,
  .pcard-resume { color: #000 !important; }
  /* Two classes again: the print block's own
     `.home-page h1 { font-size: 20pt }` outranks a bare .pcard-name
     exactly the way the screen rule does. */
  .pcard .pcard-name { font-size: 16pt; }

  /* Hatch, scanline and screen are all backgrounds, and browsers drop
     background colours from print by default — so on paper the locked
     value would be the word "Locked" with nothing marking it as a
     covered field, and the unlocked one would be an address with no
     screen under it. Neither needs the texture; both need the border
     that the texture was standing in for. */
  .pcard-product > a,
  .pcard-contact-link {
    background: none !important;
    background-image: none !important;
    border: 0.5pt solid #999;
    padding: 3pt 4pt;
  }
  .pcard-contact-value,
  .pcard-contact-link[aria-disabled="true"] .pcard-contact-value {
    background: none !important;
    background-image: none !important;
    padding: 0;
  }
  .pcard-product--broken,
  .pcard [class*="--broken"] {
    background: none !important;
    background-image: none !important;
    border: 0.5pt dashed #000;
    color: #000 !important;
  }
  .pcard-resume { border: 0.5pt solid #999; padding: 3pt 4pt; }
}
