/* ═══════════════════════════════════════════════════════════════
   Aryan Verma — portfolio
   Hand-written CSS. No framework, no build step.

   Contents
     01  tokens
     02  reset & base
     03  typography helpers
     04  ambience (grain, scanlines, cursor, matrix)
     05  boot overlay
     06  nav
     07  buttons
     08  hero
     09  section furniture
     10  terminal   (10b the samurai, 10c the résumé viewer)
     11  about
     12  skills
     13  work cards
     14  timeline
     15  contact & form
     16  footer
     17  reveal animations
     18  responsive
     19  reduced motion / print
   ═══════════════════════════════════════════════════════════════ */


/* ── 01 tokens ───────────────────────────────────────────────── */

:root {
  /* type */
  --f-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --f-sans: 'Space Grotesk', system-ui, -apple-system, Segoe UI, sans-serif;
  /* This used to be 'Shippori Mincho', 'Yu Mincho', serif — two names and a
     generic — and it ran off its own end. Yu Mincho is NOT on a stock Windows 11
     install (only Yu Gothic and MS Gothic are), so the chain reached `serif`,
     which resolves to Times, which has no kanji, so Chromium picked its own
     last resort: Microsoft YaHei — a SIMPLIFIED CHINESE face. Japanese rendered
     in Chinese glyph forms. Measured, not guessed: CSS.getPlatformFontsForNode
     reported YaHei on 37 of 42 CJK elements with the webfont blocked.
     getComputedStyle is no use here — it echoes the declared stack whether or
     not the font loaded, so it cheerfully reports "Shippori Mincho" either way.

     And this is not only the blocked case. display=swap paints fallback text
     first by design, so EVERY cold visit shows this stack until the woff2
     lands — 4.3s on a Fast-3G profile. The fallback is what most visitors
     actually read, which makes it worth more than two names.

     Ordered mincho-first (the design intent), then Japanese gothic, then the
     generic. Yu Gothic and Hiragino Sans are the wrong STYLE — gothic is sans,
     mincho is serif — but they carry the right GLYPH FORMS, and a Japanese sans
     beats a Chinese serif for text that is meant to read as Japanese. The
     generic stays `serif` because it states the intent; for CJK it changes
     nothing, since both generics dead-end at a latin face with no kanji. */
  --f-jp:   'Shippori Mincho', 'Hiragino Mincho ProN', 'Yu Mincho', 'YuMincho',
            'Noto Serif JP', 'MS PMincho', 'Yu Gothic', 'Hiragino Sans', serif;

  /* fluid scale */
  --t-xs:   clamp(.69rem, .66rem + .12vw, .76rem);
  --t-sm:   clamp(.8rem,  .77rem + .15vw, .89rem);
  --t-base: clamp(.95rem, .91rem + .2vw,  1.06rem);
  --t-lg:   clamp(1.1rem, 1.03rem + .35vw, 1.3rem);
  --t-xl:   clamp(1.4rem, 1.25rem + .8vw, 2rem);
  --t-2xl:  clamp(1.9rem, 1.5rem + 2vw,   3rem);
  --t-hero: clamp(2.7rem, 1.4rem + 8.5vw, 8.5rem);

  /* rhythm */
  --sp-sec: clamp(5rem, 3rem + 8vw, 9.5rem);
  /* How far below the nav a jumped-to section starts. --sp-sec is the gap you
     want between two sections while SCROLLING past them; it is far too much
     above the heading when you have just clicked the nav and the section is the
     only thing you asked to see. So a landing gets its own, smaller number and
     .sec spends the difference. See .sec's scroll-margin-top. */
  --sp-anchor: clamp(2.5rem, 4vw, 4.5rem);
  --wrap: 1180px;
  --gut: clamp(1.15rem, .6rem + 3vw, 3rem);

  /* The floating shell's footprint, declared once because four separate things
     have to agree on it: the window's width, the width it folds down to, the
     corner offset, and the strip of page that stays empty so that neither the
     window nor the folded bar ever lands on a paragraph. See §09b for the
     window and §18 for the rail — change --term-w here and both follow. */
  --term-w: 600px;
  --term-min-w: 240px;
  --term-edge: clamp(.8rem, 1.7vw, 1.9rem);
  --term-rail: calc(var(--term-min-w) + var(--term-edge) + 1.5rem);
  --term-rail-full: calc(var(--term-w) + var(--term-edge) + 1.5rem);

  --r: 4px;
  --r-lg: 10px;

  --ease: cubic-bezier(.22, 1, .36, 1);
  --ease-io: cubic-bezier(.6, .04, .32, 1);

  --nav-h: 68px;
}

/* The palette. Dark only, by design — there is no light theme and no
   toggle: the darkness IS the design, not a preference to be negotiated. 夜 */
:root {
  /* Belt and braces with the <meta name="color-scheme"> tag: this is what
     actually makes scrollbars and native form controls render dark. */
  color-scheme: dark;

  --bg:        #05060a;
  --bg-2:      #080a10;
  --elev:      #0c0f17;
  --elev-2:    #11151f;
  --line:      rgba(255, 255, 255, .085);
  --line-2:    rgba(255, 255, 255, .16);
  --txt:       #e7e9f0;
  --txt-2:     #9aa2b6;
  /* Was #626b80, which failed WCAG AA everywhere it landed — and it lands in 46
     places, so it was one number causing 69 of the 93 failing text runs on the
     page. Raised to relative luminance .239, chosen against the LIGHTEST surface
     it sits on rather than against --bg: a mid-grey loses contrast as the
     backing gets lighter, so #626b80 measured 3.79:1 on --bg but only 3.42:1 on
     an --elev-2 card, and the card is the binding constraint. AA on that surface
     needs luminance >= .209; #767f98 clears it by 2%, which is inside the margin
     a future half-percent-lighter card would eat, so this takes the next step up
     instead. Searched with .preview-tools/contrast-solve.js (TXT3= overrides the
     token at runtime), not eyeballed.

     The three-tier hierarchy survives, which was the thing at risk: by
     luminance the tiers were --txt .816 / --txt-2 .361 / --txt-3 .147 and are
     now .816 / .361 / .239. The dim tier moves up into the gap below --txt-2
     without closing it, so "quieter than secondary" still reads that way. */
  --txt-3:     #7d86a0;

  --red:       #ff3b53;   /* 朱 vermilion — the dominant accent */
  --red-soft:  rgba(255, 59, 83, .13);
  --grn:       #3dfaa8;   /* terminal green */
  --grn-soft:  rgba(61, 250, 168, .12);
  --vio:       #8b6cff;

  --glow:      0 0 40px -8px rgba(255, 59, 83, .45);
  --shadow:    0 24px 60px -22px rgba(0, 0, 0, .95);
  --grain-o:   .038;
  --scan-o:    .05;
  --vig-o:     .85;
  --card-sheen: rgba(255, 255, 255, .05);
}


/* ── 02 reset & base ─────────────────────────────────────────── */

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

* { margin: 0; padding: 0; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* Anchor targets stop level with the bottom of the fixed nav — that is all
     this line does, and all it should do: it is the one number that is true for
     every target on the page, because the nav covers the same strip whatever you
     jumped to. No breathing room on top of it here.

     The breathing room is a per-section question, so it is answered per section:
     see .sec's scroll-margin-top, which pulls a landing back up through the top
     padding the section only wants when you are scrolling past it. Adding a
     margin HERE instead would push every target down by the same amount,
     including the ones — contact, the hero — that already sit where they should. */
  scroll-padding-top: var(--nav-h);

  /* Reserve the scrollbar's strip whether or not a scrollbar is drawn. This page
     is always tall enough to scroll, so on a browser with classic scrollbars the
     strip is occupied anyway and this costs nothing — and it buys the one thing
     `body.is-locked` cannot do for itself. Locking overflow removes the scrollbar,
     the viewport gets ~15px wider, and everything laid out against its edges jumps
     sideways: the centred wrap, the fixed nav, the terminal window in its corner.
     Two things lock the page (the boot overlay, and the résumé viewer in §10c),
     and the viewer opens from halfway down where that jump is plainly visible. */
  scrollbar-gutter: stable;
}

body {
  background: var(--bg);
  color: var(--txt);
  font: 400 var(--t-base)/1.7 var(--f-sans);
  font-feature-settings: 'kern' 1, 'liga' 1;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
  min-height: 100vh;
}

body.is-locked { overflow: hidden; }

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

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

button, input, textarea, select {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
}
button { cursor: pointer; }

ul, ol { list-style: none; }

:focus-visible {
  outline: 2px solid var(--red);
  outline-offset: 3px;
  border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none; }

/* What the blade leaves behind. Both spellings, for the same reason §10a's
   .ghost gives for its pair — and because that pair was the only one in the
   sheet, so the one selection rule that matters on every paragraph of the page
   was the one Firefox <62 did not get. They cannot share a selector list: an
   unknown pseudo-element invalidates the entire rule it appears in. */
::selection      { background: var(--red); color: #fff; }
::-moz-selection { background: var(--red); color: #fff; }

/* scrollbar */
* { scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg-2); }
::-webkit-scrollbar-thumb {
  background: var(--line-2);
  border: 3px solid var(--bg-2);
  border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover { background: var(--red); }

.wrap {
  width: 100%;
  max-width: var(--wrap);
  margin-inline: auto;
  padding-inline: var(--gut);
}

.skip-link {
  position: fixed;
  top: 10px; left: 10px;
  z-index: 999;
  padding: .6em 1.1em;
  background: var(--red);
  /* Not #fff. White on this vermilion is 3.5:1 and cannot be rescued by picking
     a better white — AA at 14px needs a foreground luminance of 1.30 and white
     is 1.0. The ceiling is the background: white needs bg luminance <= .183, and
     --red's red channel alone contributes .213, so NO colour with r:255 can
     carry white text at AA no matter what its green and blue are.
     --bg on --red is 5.79:1, and it costs nothing here — the skip link only
     appears on keyboard focus, so it is not a surface anyone forms an impression
     of. The one control that exists for accessibility should not be the one that
     fails it. */
  color: var(--bg);
  font: 500 var(--t-sm)/1 var(--f-mono);
  border-radius: var(--r);
  transform: translateY(-160%);
  transition: transform .25s var(--ease);
}
.skip-link:focus { transform: translateY(0); }


/* ── 03 typography helpers ───────────────────────────────────── */

.prompt { color: var(--grn); }

.caret {
  display: inline-block;
  width: .55em; height: 1.05em;
  background: var(--grn);
  vertical-align: -.16em;
  animation: blink 1.05s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }

/* vertical Japanese rails flanking the hero */
.vrail {
  position: absolute;
  top: 50%;
  writing-mode: vertical-rl;
  font: 500 var(--t-sm)/1 var(--f-jp);
  letter-spacing: .42em;
  color: var(--txt-3);
  opacity: .55;
  transform: translateY(-50%);
  user-select: none;
  pointer-events: none;
}
.vrail--left  { left: clamp(.5rem, 2.2vw, 2.2rem); }
.vrail--right { right: clamp(.5rem, 2.2vw, 2.2rem); color: var(--red); opacity: .4; }
.vrail::before,
.vrail::after {
  content: '';
  display: block;
  width: 1px; height: 46px;
  margin-inline: auto;
  background: currentColor;
  opacity: .4;
}
.vrail::before { margin-bottom: 1.1em; }
.vrail::after  { margin-top: 1.1em; }

/* glitch — layered chromatic split on hover / periodically */
.glitch {
  position: relative;
  display: block;
}
.glitch::before,
.glitch::after {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  pointer-events: none;
  clip-path: inset(0 0 0 0);
  opacity: 0;
}
.glitch::before { color: var(--grn); }
.glitch::after  { color: var(--red); }
.glitch.is-glitching::before {
  opacity: .85;
  animation: gl-a .42s steps(2) 1;
}
.glitch.is-glitching::after {
  opacity: .85;
  animation: gl-b .42s steps(2) 1;
}
@keyframes gl-a {
  0%   { transform: translate(0); clip-path: inset(12% 0 62% 0); }
  25%  { transform: translate(-5px, 2px); clip-path: inset(72% 0 8% 0); }
  50%  { transform: translate(4px, -2px); clip-path: inset(34% 0 44% 0); }
  75%  { transform: translate(-3px, 1px); clip-path: inset(6% 0 80% 0); }
  100% { transform: translate(0); clip-path: inset(0 0 100% 0); }
}
@keyframes gl-b {
  0%   { transform: translate(0); clip-path: inset(66% 0 12% 0); }
  25%  { transform: translate(5px, -2px); clip-path: inset(18% 0 58% 0); }
  50%  { transform: translate(-4px, 2px); clip-path: inset(48% 0 30% 0); }
  75%  { transform: translate(3px, -1px); clip-path: inset(80% 0 4% 0); }
  100% { transform: translate(0); clip-path: inset(100% 0 0 0); }
}

/* ── portrait in the letterforms ───────────────────────────────
   A second copy of the word, filled with the photo instead of a colour, laid
   over the white original.

   The photo is a hood in near-darkness and that dictates every number here.
   Measured: half its pixels are pure black, 90% of them sit under 2% luminance
   and the brightest thing in the frame — a spectacle lens — only reaches 64%.
   So: poured in as-is it paints the glyphs #0a0a0a on a #05060a page and the
   name goes missing. Merely brightened, it washes the word to a flat grey,
   losing the white without gaining an image.

   What works is brightness(10) to stretch that 0–16% band across the full
   range, then invert. Inverted, the hood is white and changes nothing, while
   the lit parts — glasses, eyes, the edge of the hood — turn dark and punch
   *into* the letters. The word stays white and legible, and what shows
   through it is unmistakably a photograph.

   210% zooms past the full frame onto the face, because at 1:1 the treated
   image is mostly white with lines too thin to survive being cut into
   letterforms. The offset positions the densest part of the face over the
   middle of the word rather than the last letter.

   Padding with a matching negative inset is not decoration. line-height is
   .86 here, so the glyphs overflow their own line box; the background only
   paints inside the padding box, and without the extra band the tops and
   bottoms of the letters would clip to nothing. The negative inset puts the
   text back where it was so the two copies stay in register. */
.glitch__photo {
  position: absolute;
  /* Hug the word, don't stretch to the line. .glitch is a block, so `inset: 0`
     would hand the background a box as wide as the whole <h1> — and then the
     percentage crop below lands somewhere different at every viewport width,
     because the word grows with the type scale and the block grows with the
     column. max-content pins the background box to the letters themselves, so
     the framing holds from 360px to 2560px. */
  inset: -.22em auto -.22em -.06em;
  width: max-content;
  padding: .22em .06em;
  font: inherit;
  font-style: normal;
  color: transparent;
  -webkit-text-fill-color: transparent;
  background: url('../assets/pfp.jpg') 40% 38% / 210% auto no-repeat;
  -webkit-background-clip: text;
  background-clip: text;
  filter: grayscale(1) brightness(10) invert(1) contrast(1.8);
  opacity: .58;
  pointer-events: none;
  user-select: none;
  -webkit-user-select: none;
  transition: opacity .35s var(--ease), filter .35s var(--ease);
}
/* The word comes from the attribute so the <h1> only contains it once. The
   clip applies to text painted by descendants too, generated content
   included, so the background still lands inside these letterforms. */
.glitch__photo::before { content: attr(data-text); }
/* Not under a contrast theme. forced-colors puts a solid Canvas backplate behind
   text so it stays readable over images, and this copy of the word then runs
   that backplate through its own invert() at .58 — a grey slab over the name,
   the letters pale grey inside it, the top of it cutting into the ./initialize
   line. It is the filter and not the photo: without the image the slab stays,
   without the filter it goes. The face could not survive the palette
   anyway, and the element is aria-hidden, so nothing is lost but the slab. The
   `aryan` egg below still fires; it just has nothing to resolve here. */
@media (forced-colors: active) {
  .glitch__photo { display: none; }
}
/* The surge. Same duration and the same stepped, un-eased feel as the
   chromatic split, so it reads as one event: the interference gets worse and
   the face is what's underneath it. */
.glitch.is-glitching .glitch__photo {
  opacity: .95;
  filter: grayscale(1) brightness(10) invert(1) contrast(2.2);
  animation: gl-photo .42s steps(3) 1;
}
@keyframes gl-photo {
  0%   { transform: translate(0); clip-path: inset(0 0 0 0); }
  30%  { transform: translate(3px, -1px); clip-path: inset(0 0 28% 0); }
  55%  { transform: translate(-2px, 1px); clip-path: inset(22% 0 0 0); }
  80%  { transform: translate(2px, 0); clip-path: inset(0 0 8% 0); }
  100% { transform: translate(0); clip-path: inset(0 0 0 0); }
}

/* Say the name. Type `aryan` anywhere that is not a text field and the surge
   above fires, and then instead of settling back to a 58% ghost the portrait
   RESOLVES: full opacity and the blown-out treatment dropped, so for two seconds
   the face inside the letters is just a photograph.

   No new mechanism and no new keyframes — the two properties this changes are
   exactly the two .glitch__photo already transitions, so both the resolve and the
   two-second fade back are the transition that was there all along. Which is
   also why this rule carries no animation of its own: the spike is is-glitching's
   job, and stacking a second animation on the same element would have them fight
   over `transform`. */
.glitch.is-named .glitch__photo {
  opacity: 1;
  filter: grayscale(.14) contrast(1.14) brightness(1.04);
}


/* ── 04 ambience ─────────────────────────────────────────────── */

.fx-grain,
.fx-scan,
.fx-vignette,
.fx-matrix {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 90;
}

/* film grain — inline SVG turbulence, no image request */
.fx-grain {
  opacity: var(--grain-o);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
  animation: grain 6s steps(6) infinite;
  mix-blend-mode: overlay;
}
@keyframes grain {
  0%,100% { transform: translate(0,0); }
  16%  { transform: translate(-3%, 2%); }
  33%  { transform: translate(2%, -3%); }
  50%  { transform: translate(-2%, -2%); }
  66%  { transform: translate(3%, 1%); }
  83%  { transform: translate(-1%, 3%); }
}

/* CRT scanlines */
.fx-scan {
  opacity: var(--scan-o);
  background: repeating-linear-gradient(
    to bottom,
    transparent 0 2px,
    rgba(255, 255, 255, .55) 2px 3px
  );
}

.fx-vignette {
  opacity: var(--vig-o);
  background: radial-gradient(ellipse at center,
    transparent 42%, rgba(0, 0, 0, .5) 100%);
}

/* ── the tube warms up ─────────────────────────────────────────
   Triple-click the background and the ambience above stops being ambience for
   five seconds: scanlines and grain surge, the vignette closes down, and the
   scanline layer slips sideways the way a mistuned CRT does.

   It works by overriding the three tokens on <html>, which is the only reason it
   is three lines instead of thirty — every layer already reads its opacity from a
   variable, so the effect is a change of value and not a change of mechanism.
   The transition is declared on the layers themselves rather than here so that
   the way back down is eased too; a crank that ramps up over 400ms and then snaps
   off reads as a bug.

   WHAT IS NOT TRANSFORMED: the page. A real horizontal roll takes the whole
   image with it, and the honest version of that here would mean putting a
   transform on a wrapper around live body text — which would make it a
   containing block, and .termwin is fixed. Moving text sideways under someone's
   eyes for five seconds is also just unpleasant. So the slip is on the scanline
   layer alone: the interference moves, the content does not. It reads as a tube
   with a fault, which is what was wanted, and it cannot break the layout.

   steps(2) on a 200ms cycle rather than something smooth, because a CRT does not
   ease. Two whole pixels, so the repeating gradient never lands on a half pixel
   and shimmers. */
.fx-scan, .fx-grain, .fx-vignette { transition: opacity .4s var(--ease); }

html.is-crt {
  --scan-o:  .17;
  --grain-o: .11;
  --vig-o:   1;
}
html.is-crt .fx-scan { animation: crt-slip 200ms steps(2) infinite; }
@keyframes crt-slip {
  0%   { transform: translate3d(  0, 0, 0); }
  50%  { transform: translate3d(-2px, 0, 0); }
  100% { transform: translate3d( 1px, 0, 0); }
}

/* The roll band, on the vignette rather than on the scanlines, because at
   --scan-o .17 a band drawn in that layer would be five times too faint to see —
   whereas --vig-o is 1 while the crank is on, so a band there paints at its
   declared strength. Starts above the viewport and travels well past the bottom;
   the layer is inset:0 and fixed, so nothing it does can extend the page. */
html.is-crt .fx-vignette::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: -22%;
  height: 20%;
  background: linear-gradient(to bottom, transparent,
              rgba(214, 235, 255, .10) 42%, rgba(226, 240, 255, .20) 52%, transparent);
  animation: crt-roll 1.4s linear infinite;
}
@keyframes crt-roll {
  from { transform: translate3d(0,    0, 0); }
  to   { transform: translate3d(0, 640%, 0); }
}

/* matrix rain — hidden until the terminal `matrix` command */
.fx-matrix {
  z-index: 95;
  opacity: 0;
  visibility: hidden;
  transition: opacity .5s var(--ease), visibility .5s;
}
.fx-matrix.is-on { opacity: .5; visibility: visible; }

/* custom cursor — a dot that tracks exactly + a ring that lags */
.cursor,
.cursor-ring {
  position: fixed;
  top: 0; left: 0;
  z-index: 200;
  pointer-events: none;
  will-change: transform;
  opacity: 0;
  transition: opacity .3s var(--ease);
}
body.cursor-ready .cursor,
body.cursor-ready .cursor-ring { opacity: 1; }

.cursor {
  width: 6px; height: 6px;
  margin: -3px 0 0 -3px;
  background: var(--red);
  border-radius: 50%;
}
.cursor-ring {
  display: grid;
  place-items: center;
  width: 34px; height: 34px;
  margin: -17px 0 0 -17px;
  border: 1px solid var(--line-2);
  border-radius: 50%;
  transition: width .28s var(--ease), height .28s var(--ease),
              margin .28s var(--ease), border-color .28s var(--ease),
              background-color .28s var(--ease), opacity .3s var(--ease);
}
.cursor-ring__label {
  font: 500 9px/1 var(--f-mono);
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--bg);
  opacity: 0;
  transition: opacity .2s var(--ease);
}

/* over a link/button */
body.cursor-hover .cursor-ring {
  width: 52px; height: 52px;
  margin: -26px 0 0 -26px;
  border-color: var(--red);
  background: var(--red-soft);
}
body.cursor-hover .cursor { opacity: .35; }

/* over an element with data-cursor="..." */
body.cursor-label .cursor-ring {
  width: 62px; height: 62px;
  margin: -31px 0 0 -31px;
  background: var(--red);
  border-color: var(--red);
}
body.cursor-label .cursor-ring__label { opacity: 1; }
body.cursor-label .cursor { opacity: 0; }

/* over a text INPUT — a caret, in the terminal's green, matching the real
   caret-color the shell and the contact fields already set. This is the state
   for a field you can type into; selectable prose gets the blade below, and the
   two are deliberately different objects because they afford different things:
   one puts characters where you click, the other takes them away. */
body.cursor-text .cursor-ring {
  width: 2px; height: 26px;
  margin: -13px 0 0 -1px;
  border-radius: 1px;
  background: var(--grn);
  border-color: transparent;
}
body.cursor-text .cursor { opacity: 0; }

/* ── over selectable prose: the blade ──────────────────────────
   A vertical katana, tip up, centred on the pointer exactly where an I-beam's
   hot point is — so it still reads as "text goes here" first and as 打刀 second.
   Three parts in 30px, and the count is why none of it is rotated: a tilted
   30px object stops reading as a cursor and starts reading as a small picture
   following the mouse. The theme is in the silhouette and the colour, not in a
   jaunty angle.

     y  0..24   blade (::before) — steel fading to vermilion, chisel tip
     y 24..26   tsuba (::after)  — the guard, the only part wider than the box
     y 26..34   tsuka (this element's own background) — wrapped hilt, bottom-anchored

   Blade three times the hilt, and that ratio is the whole silhouette. The first
   pass was 19 over 9 — barely two to one — and it read as a gladius: a stubby
   thing with a big red crossguard in the middle. Worse, the blade's gradient
   went red at 46%, so the steel visually ended halfway up and the eye put the
   guard higher than it was. Filmed at 4x in .preview-tools/cursor-shot.js,
   which is the only way to judge a 4px-wide object. The steel now holds to
   about 72% and only warms at the very base, near the guard, where a warm
   reflection is what you would actually see.

   The guard is the reason the blade is a pseudo-element rather than this box:
   it has to overhang, and a 12px-wide box would put the blade's own 4px in the
   middle of 12px of empty hit area for no reason.

   NOT box-shadow for the glow. clip-path clips an element's shadow along with
   its content, so a box-shadow here would be cut off exactly at the silhouette
   and disappear. filter: drop-shadow() reads the CLIPPED alpha and traces the
   blade, which is the whole point of the tip. */
body.cursor-sel .cursor-ring {
  width: 4px; height: 34px;
  margin: -17px 0 0 -2px;
  border: 0;
  border-radius: 0;
  /* Positioned + sized rather than a colour stop, so the repeat that makes the
     柄巻 wrap can stay a plain 4px repeating gradient and still be confined to
     the bottom 8px.

     `0 100%` — BOTTOM-ANCHORED, and it has to be. This was `0 26px`, measured
     down from the top, which is the same 8px strip only while the box is 34px
     tall. On press it grows to 42 and ::before's `bottom: 10px` grows with it, so
     the blade extended straight over a hilt that had stayed at 26..34 and ate all
     but the 2px hidden behind the guard: press to draw the sword and the handle
     vanished. Every other part of this is anchored to the bottom edge already,
     which is exactly why the odd one out was the one that broke. */
  background: repeating-linear-gradient(180deg, #8a2130 0 2px, #57121c 2px 4px)
              0 100% / 100% 8px no-repeat;
}
body.cursor-sel .cursor-ring::before {
  content: '';
  position: absolute;
  inset: 0 0 10px 0;
  background: linear-gradient(180deg, #fff 0 8%,
                rgba(238, 241, 248, .96) 38%,
                rgba(214, 221, 236, .82) 74%,
                rgba(255, 59, 83, .62));
  clip-path: polygon(50% 0, 100% 15%, 100% 100%, 0 100%, 0 15%);
  filter: drop-shadow(0 0 5px rgba(255, 59, 83, .55));
  transition: filter .16s var(--ease);
}
/* A gradient and not `background: var(--red)`, which at 10x4px was the loudest
   thing in the whole cursor — a flat saturated bar reads as a plus sign, and the
   eye went to the crossing rather than to the tip that marks the caret. Lit top
   edge into a dark base is the same trick as the blade: two stops make it a
   fitting rather than a mark. */
body.cursor-sel .cursor-ring::after {
  content: '';
  position: absolute;
  left: -3px; right: -3px; bottom: 8px;
  height: 2px;
  background: linear-gradient(180deg, #ff6478, #b4192c);
}
body.cursor-sel .cursor { opacity: 0; }

/* Press, while over prose: the draw. The box grows 8px and the margin absorbs
   all of it (-17 → -25, so bottom stays 17px under the pointer), which means
   the hilt holds still and the extra length goes into the blade — ::before is
   pinned 10px off the bottom, so it lengthens on its own. Width, height and
   margin are already in the transition list above, so the draw is animated and
   the return is too. */
body.cursor-sel.cursor-down .cursor-ring { height: 42px; margin-top: -25px; }
/* .68 and not the .95 this started at. drop-shadow traces the blade's alpha, so
   a hard flare on a 27px blade spills straight over the guard and the hilt 8px
   below it and drowns both — the drawn sword lost its handle. Filmed at 4x. */
body.cursor-sel.cursor-down .cursor-ring::before {
  filter: drop-shadow(0 0 8px rgba(255, 59, 83, .68)) brightness(1.18);
}

/* The press scale for every OTHER state lives in js/main.js, composed into the
   same transform the follow loop writes. It used to be a rule here —
   `body.cursor-down .cursor-ring { transform: scale(.82) }` — and it had never
   once fired: the loop sets .cursor-ring's transform as an INLINE style on
   every frame, and an inline declaration beats any stylesheet rule short of
   !important. Which could not be used either, because it would beat the
   tracking as well and pin the ring to the top-left corner. So the value moved
   to the only place that can hold both at once. */

/* Hide the whole custom-cursor rig on touch / no-hover devices,
   and give the real cursor back.

   And under a Windows contrast theme, where it is not a style choice but a
   missing pointer. forced-colors repaints every background as Canvas and drops
   gradients and shadows, and the rig is made of those: the dot is a red fill,
   the I-beam a green one, the blade nothing but gradients. Over prose the
   pointer was simply gone — 0 changed pixels where normal mode has 144, with the
   real cursor switched off below. Someone on a contrast theme has often set a
   large system pointer as well, and the one they chose beats any we draw.
   Measured in .preview-tools/forced-colors.js. */
@media (hover: none), (pointer: coarse), (forced-colors: active) {
  .cursor, .cursor-ring { display: none; }
}

/* EVERYTHING, and the !important is not laziness — it is the fix.
   This used to enumerate: body.cursor-ready plus a, button, input, textarea and
   [data-tilt], because §02's `button { cursor: pointer }` outranks a bare
   `body.cursor-ready`. But `cursor` is an INHERITED property, and a rule that
   matches an element directly always wins over a value that element inherited,
   no matter how weak the selector. So every `cursor: pointer` written anywhere
   else in this sheet punched a hole in the rig and put the OS hand on top of
   the dot and the ring — .sec__num i, .sam__hit, and .term__bar with its dots
   and peek label once the window is folded. Ten elements, and no screenshot can
   ever catch one, because the OS cursor is not part of the page's raster.
   Measured in .preview-tools/cursor-leaks.js, which now fails if any element
   reports a cursor other than `none` while the rig is armed.

   Specificity cannot express "later than everything, including rules I have not
   written yet", and a longer list would just be the same bug with more entries.
   The declaration IS an override of the whole sheet, so it says so. It is safe
   because the gates are still on: the coarse-pointer query above never reaches
   this block, forced-colors: none keeps it off whenever the rig is hidden for a
   contrast theme, and .cursor-ready is only added once a real mouse has moved,
   so a keyboard-only or touch visitor never loses a cursor they were using. A
   browser too old to know forced-colors fails that test and keeps its own
   cursor on top of the rig — two pointers, never none.

   The elements that lost a MEANINGFUL pointer did not lose the hint — §06's
   HOVER list picked up .sec__num i, .sam__hit and the folded bar in the same
   change, so they get the red ring instead, which says "target" louder than the
   OS hand ever did. */
@media (hover: hover) and (pointer: fine) and (forced-colors: none) {
  body.cursor-ready,
  body.cursor-ready * { cursor: none !important; }
  /* And the last 10px: <html> keeps its own `auto` because .cursor-ready is on
     <body>, which shows a real arrow over the strip that scrollbar-gutter holds
     back. Separate rule on purpose — :has() is baseline but an unsupported
     selector invalidates its whole rule, and sharing one would take the blanket
     above down with it. */
  html:has(body.cursor-ready) { cursor: none; }
}


/* ── 05 boot overlay ─────────────────────────────────────────── */

.boot {
  position: fixed;
  inset: 0;
  z-index: 500;
  display: grid;
  place-items: center;
  background: var(--bg);
  transition: opacity .6s var(--ease), visibility .6s;
}
.boot.is-done {
  opacity: 0;
  visibility: hidden;
}
.boot__inner {
  width: min(560px, 86vw);
  font-family: var(--f-mono);
  font-size: var(--t-sm);
}

/* Identity frame — the portrait that flashes up while the boot log claims
   to be verifying who you are, then leaves again. Three states, driven from
   main.js: default (empty, awaiting), .is-live (photo in, vermilion frame),
   .is-out (photo gone, green frame).

   The frame keeps its box in all three states. An element that appears
   mid-sequence inside a vertically-centred overlay would drag the log and
   the progress bar upward as it arrived — the same class of bug as the
   growing log box, and just as visible. */
.boot__id {
  display: flex;
  align-items: center;
  gap: 1.15em;
  margin-bottom: 1.5em;
}
.boot__id-frame {
  position: relative;
  flex-shrink: 0;
  width: 116px; height: 116px;
  overflow: hidden;
  background: var(--bg-2);
  border: 1px solid var(--line-2);
  border-radius: var(--r);
  transition: border-color .5s var(--ease), box-shadow .5s var(--ease);
}
.boot__id img {
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  /* Arrives desaturated and slightly over-scaled, resolves to a clean
     frame — reads as a sensor settling rather than an image swapping in. */
  filter: grayscale(1) contrast(1.2);
  transform: scale(1.07);
  transition: opacity .55s var(--ease), filter 1s var(--ease),
              transform 1s var(--ease);
}
.boot__id-scan {
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 24%;
  background: linear-gradient(to bottom,
    transparent, rgba(61, 250, 168, .24), transparent);
  opacity: 0;
  pointer-events: none;
}
.boot__id figcaption {
  font: 500 var(--t-xs)/1.5 var(--f-jp);
  letter-spacing: .16em;
  color: var(--txt-3);
  transition: color .45s var(--ease);
}
.boot__id figcaption i {
  display: block;
  margin-top: .4em;
  font: 400 9px/1 var(--f-mono);
  font-style: normal;
  letter-spacing: .18em;
  text-transform: uppercase;
  opacity: .75;
}

/* photo in */
.boot__id.is-live .boot__id-frame {
  border-color: var(--red);
  box-shadow: var(--glow);
}
/* A shade brighter than the source file. The photo is deliberately almost
   black, which is the point of it — but at 116px it has to still read as a
   person rather than a dark rectangle. */
.boot__id.is-live img {
  opacity: 1;
  filter: brightness(1.22) contrast(1.04);
  transform: none;
}
.boot__id.is-live .boot__id-scan { animation: idscan 1.35s var(--ease-io) 2; }
.boot__id.is-live figcaption { color: var(--red); }

/* photo out — frame stays, subject doesn't.
   Plain border-color first, then the blend; see the note on .nav for why every
   color-mix() in this file is written twice. Pre-2023 browsers get the green at
   full strength instead of half-mixed into the line colour, which for a frame
   that is on screen for under a second is the right way to be wrong. */
.boot__id.is-out .boot__id-frame {
  border-color: var(--grn);
  border-color: color-mix(in srgb, var(--grn) 50%, var(--line));
}
.boot__id.is-out figcaption { color: var(--grn); }

@keyframes idscan {
  0%   { transform: translateY(-130%); opacity: 0; }
  14%  { opacity: 1; }
  86%  { opacity: 1; }
  100% { transform: translateY(440%); opacity: 0; }
}
/* Fixed height, not min-height: the overlay is centred, so a box that grows
   as lines arrive drags the whole thing upward line by line. Bottom-anchored
   and clipped, so if lines wrap on a narrow screen the oldest scrolls off the
   top instead — which is what a console does.
   The height lives on the wrapper, never on the <pre>: a flex <pre> would
   blockify its own inline <b>/<i> spans onto separate rows. */
.boot__logwrap {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  height: 21em;
  margin-bottom: 1.4em;
  overflow: hidden;
}
.boot__log {
  font: inherit;
  line-height: 1.5;
  color: var(--grn);
  white-space: pre-wrap;
  word-break: break-word;
}
.boot__log b { color: var(--red); font-weight: 500; }
.boot__log i { color: var(--txt-3); font-style: normal; }
.boot__bar {
  height: 2px;
  background: var(--line);
  overflow: hidden;
}
.boot__bar span {
  display: block;
  width: 0;
  height: 100%;
  background: linear-gradient(90deg, var(--grn), var(--red));
  transition: width .18s linear;
}
.boot__skip {
  margin-top: .9em;
  color: var(--txt-3);
  font-size: var(--t-xs);
  letter-spacing: .12em;
  text-transform: uppercase;
}


/* ── 06 nav ──────────────────────────────────────────────────── */

.nav {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  gap: var(--gut);
  height: var(--nav-h);
  padding-inline: var(--gut);
  /* Two background declarations, and the first one is not redundant. color-mix()
     shipped in Chrome 111, Safari 16.2 and Firefox 113 — all 2023 — and a browser
     older than that does not fall back to something reasonable, it throws the whole
     declaration out at parse time as a syntax error. This element is
     position: fixed with z-index 100, so no background meant the page scrolling
     underneath came straight through the nav links. backdrop-filter covered for it
     on Safari, which has had the prefixed version since 9; on Firefox before 103
     there was no blur either and the bar was simply invisible.

     The fallback is the opaque --bg rather than a hand-written rgba(5,6,10,.78),
     for two reasons: it cannot go stale when --bg changes, and it errs toward
     legible. Old browsers lose the glass, not the contrast. No @supports needed —
     the cascade already does this, since anything that understands the second line
     applies it over the first and anything that does not never sees it.

     The other three color-mix() calls in this file are border-colors with a plain
     border underneath them, so they degrade to "the state change does not show"
     rather than to a hole. They are still given fallbacks, because a focus ring
     that silently stops indicating focus is the same class of bug as this one. */
  background: var(--bg);
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  backdrop-filter: blur(14px) saturate(1.4);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
  border-bottom: 1px solid transparent;
  transition: border-color .3s var(--ease), background-color .3s var(--ease),
              transform .4s var(--ease);
}
.nav.is-stuck { border-bottom-color: var(--line); }

/* Auto-hidden while scrolling down. Also made inert, so its buttons
   can't be clicked or tabbed to while they're off-screen — but keyboard
   focus reaching in (Shift+Tab back up) brings it straight back. */
.nav.is-hidden {
  transform: translateY(-100%);
  pointer-events: none;
}
.nav.is-hidden:focus-within {
  transform: none;
  pointer-events: auto;
}

.nav__mark {
  display: flex;
  align-items: center;
  gap: .6em;
  flex-shrink: 0;
}
.nav__glyph {
  display: grid;
  place-items: center;
  width: 30px; height: 30px;
  background: var(--red);
  color: #fff;
  font: 700 15px/1 var(--f-jp);
  border-radius: 3px;
  box-shadow: var(--glow);
  transition: transform .35s var(--ease);
}
.nav__mark:hover .nav__glyph { transform: rotate(-8deg) scale(1.06); }
.nav__name {
  font: 700 var(--t-sm)/1 var(--f-sans);
  letter-spacing: .13em;
}
.nav__name em {
  font-style: normal;
  color: var(--txt-3);
  margin-left: .35em;
}

.nav__links {
  display: flex;
  align-items: center;
  gap: clamp(.35rem, 1.4vw, 1.5rem);
  margin-left: auto;
}
.nav__links a {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .1em;
  padding: .35em .55em;
  font: 500 var(--t-xs)/1.25 var(--f-mono);
  letter-spacing: .06em;
  color: var(--txt-2);
  transition: color .25s var(--ease);
}
.nav__links a i {
  font: 500 9px/1 var(--f-jp);
  font-style: normal;
  letter-spacing: .18em;
  color: var(--txt-3);
  transition: color .25s var(--ease);
}
.nav__links a::after {
  content: '';
  position: absolute;
  bottom: -2px; left: 50%;
  width: 0; height: 1px;
  background: var(--red);
  transform: translateX(-50%);
  transition: width .3s var(--ease);
}
.nav__links a:hover,
.nav__links a.is-active { color: var(--txt); }
.nav__links a:hover i,
.nav__links a.is-active i { color: var(--red); }
.nav__links a:hover::after,
.nav__links a.is-active::after { width: 78%; }

/* Holds the burger, and nothing else — so it only exists on mobile.
   Left displayed on desktop it would add one more `gap` to .nav and
   push the links in from the right padding edge. */
.nav__tools {
  display: none;
  align-items: center;
  gap: .5rem;
  flex-shrink: 0;
}

.btn-icon {
  display: grid;
  place-items: center;
  width: 36px; height: 36px;
  border: 1px solid var(--line);
  border-radius: var(--r);
  color: var(--txt-2);
  font: 500 13px/1 var(--f-jp);
  transition: border-color .25s var(--ease), color .25s var(--ease),
              background-color .25s var(--ease);
}
.btn-icon:hover {
  border-color: var(--red);
  color: var(--red);
  background: var(--red-soft);
}
/* justify-content is needed because .btn-icon's `place-items` only sets the
   cross axis here — without it both bars stack at the top of the button. */
.nav__burger {
  display: none;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 5px;
}
.nav__burger span {
  width: 16px; height: 1.5px;
  background: currentColor;
  transition: transform .3s var(--ease), opacity .25s var(--ease);
}
.nav.is-open .nav__burger span:first-child { transform: translateY(3.25px) rotate(45deg); }
.nav.is-open .nav__burger span:last-child  { transform: translateY(-3.25px) rotate(-45deg); }

.nav__progress {
  position: absolute;
  bottom: -1px; left: 0;
  width: 0;
  height: 1.5px;
  background: linear-gradient(90deg, var(--red), var(--grn));
}


/* ── 07 buttons ──────────────────────────────────────────────── */

.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: .7em;
  padding: .82em 1.5em;
  font: 500 var(--t-sm)/1 var(--f-mono);
  letter-spacing: .05em;
  border: 1px solid transparent;
  border-radius: var(--r);
  overflow: hidden;
  transition: transform .28s var(--ease), border-color .28s var(--ease),
              color .28s var(--ease), box-shadow .28s var(--ease);
}
.btn i {
  font: 500 var(--t-xs)/1 var(--f-jp);
  font-style: normal;
  letter-spacing: .1em;
  /* Was `opacity: .75`. On the ghost buttons that was a dimmed --txt; on
     .btn--solid it was 75% white on vermilion, which measured 2.5:1 — the
     faintest text in any button on the page, and inside the primary CTA.
     Dropping it takes these to the same ratio as the button's own label, so the
     Japanese sublabel is no longer the weakest link in a control it shares. It
     still reads as secondary: 9-12px, uppercase, wide tracking, its own line. */
}
.btn i.ico-dl { font-family: var(--f-mono); }
.btn > span, .btn > i { position: relative; z-index: 1; }
.btn:active { transform: translateY(1px); }
.btn--sm { padding: .62em 1.1em; }

/* solid — a wipe fills from the left on hover */
.btn--solid {
  background: var(--red);
  /* Was `color: #fff`, which is 3.5:1 on --red at 14.2px and fails AA — in the
     one button on the page that matters most, the hero's primary CTA.

     Worth being precise about why the obvious fixes are not available, because
     "pick a better white" is the instinct and it cannot work. AA at this size
     needs the foreground to reach relative luminance 1.3006 against #ff3b53, and
     white is 1.0 — there is no lighter colour, so no foreground passes. Nor can
     the red be nudged: white at 4.5:1 needs a background luminance <= .1833,
     while the red channel at 255 contributes .2126 on its own. Any colour with
     r: 255 is over the ceiling before green and blue are considered; the lightest
     red that could carry white is about #ef0000.

     So the choice was between the accent and the text colour, and this flips the
     text. --bg on --red is 5.79:1 — a clear pass — and it keeps --red byte for
     byte and the type scale untouched, where darkening the fill to ~#e11d38
     would have changed the accent everywhere it is used and bumping the label to
     18.66px bold would have bought the 3:1 large-text bar by redesigning the
     button. It is also already this design's own idiom: :hover below set
     `color: var(--bg)` on this button long before the audit. */
  color: var(--bg);
  box-shadow: var(--glow);
}
.btn--solid::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--txt);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform .38s var(--ease);
}
/* No `:hover { color: var(--bg) }` any more — the resting colour is that now, so
   the rule set a property to the value it already had. Hover still reads as a
   state change, and a bigger one than before: ::before wipes --txt across the
   fill, so the text goes from --bg on red at 5.79:1 to --bg on near-white at
   16.7:1 while the glyphs themselves hold still. */
.btn--solid:hover::before { transform: scaleX(1); }

/* ghost */
.btn--ghost {
  border-color: var(--line-2);
  color: var(--txt);
}
.btn--ghost:hover {
  border-color: var(--red);
  color: var(--red);
  background: var(--red-soft);
}


/* ── 08 hero ─────────────────────────────────────────────────── */

.hero {
  position: relative;
  display: grid;
  align-items: center;
  min-height: 100svh;
  padding: calc(var(--nav-h) + 3rem) var(--gut) 5.5rem;
  overflow: hidden;
}
.hero__canvas {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  z-index: 0;
  opacity: .85;
}
.hero__body {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: var(--wrap);
  margin-inline: auto;
}

.hero__eyebrow {
  font: 400 var(--t-sm)/1 var(--f-mono);
  color: var(--txt-2);
  letter-spacing: .04em;
  margin-bottom: 1.5rem;
}
.hero__eyebrow b { color: var(--red); font-weight: 500; }

.hero__title {
  font: 700 var(--t-hero)/.86 var(--f-sans);
  letter-spacing: -.045em;
  text-transform: uppercase;
  margin-bottom: 1.1rem;
}
.hero__title .glitch--accent {
  -webkit-text-stroke: 1.5px var(--red);
  color: transparent;
  padding-left: .07em;
}
/* A contrast theme puts a Canvas backplate behind each run of text, and the
   plate is the font's full height, not the line box. At .86 the two lines' plates
   overlap by a third of a line and each is painted over the one above it: VERMA's
   took the feet off ARYAN (all but a sliver on the left, where the padding is),
   and ARYAN's took the bottom half off the ./initialize line. At normal the line
   box is the font's height, so the plates sit edge to edge. The name gets taller,
   but only there. */
@media (forced-colors: active) {
  .hero__title { line-height: normal; }
}
/* An outline with nothing inside it doesn't survive a contrast theme: the
   outline is forced to the text colour, and the text colour here is transparent.
   Chromium forces that as well and paints VERMA solid in CanvasText. Firefox
   keeps the transparent, so the outline goes transparent with it and the word
   paints nothing at all. Naming CanvasText gives both the same solid word. */
@media (forced-colors: active) {
  .hero__title .glitch--accent { color: CanvasText; }
}
.hero__role {
  display: flex;
  flex-wrap: wrap;
  gap: .45em;
  align-items: baseline;
  font: 500 var(--t-xl)/1.3 var(--f-mono);
  margin-bottom: 1.4rem;
  min-height: 1.4em;
}
.hero__role-static { color: var(--txt-3); }
.type { color: var(--grn); }
.type::after {
  content: '';
  display: inline-block;
  width: .5em; height: 1em;
  margin-left: .12em;
  background: var(--grn);
  vertical-align: -.14em;
  animation: blink 1.05s steps(1) infinite;
}

.hero__lede {
  max-width: 46ch;
  font-size: var(--t-lg);
  color: var(--txt-2);
  margin-bottom: 2.4rem;
}

.hero__cta {
  display: flex;
  flex-wrap: wrap;
  gap: .8rem;
  margin-bottom: 2.6rem;
}

.hero__social {
  display: flex;
  flex-wrap: wrap;
  gap: 1.6rem;
}
.hero__social a {
  position: relative;
  font: 400 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--txt-3);
  transition: color .25s var(--ease);
}
.hero__social a::after {
  content: '';
  position: absolute;
  left: 0; bottom: -5px;
  width: 100%; height: 1px;
  background: var(--red);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform .3s var(--ease);
}
.hero__social a:hover { color: var(--txt); }
.hero__social a:hover::after { transform: scaleX(1); }

.hero__scroll {
  position: absolute;
  left: 50%; bottom: 1.6rem;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .6em;
  transform: translateX(-50%);
  font: 400 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--txt-3);
  transition: color .25s var(--ease);
}
.hero__scroll:hover { color: var(--red); }
.hero__scroll i {
  width: 1px; height: 40px;
  background: linear-gradient(to bottom, currentColor, transparent);
  animation: drop 1.9s var(--ease-io) infinite;
}
@keyframes drop {
  0%   { transform: scaleY(0); transform-origin: top; }
  45%  { transform: scaleY(1); transform-origin: top; }
  55%  { transform: scaleY(1); transform-origin: bottom; }
  100% { transform: scaleY(0); transform-origin: bottom; }
}


/* ── 09 section furniture ────────────────────────────────────── */

.sec {
  position: relative;
  /* One number per section, because two things read it: the padding below, and
     the anchor landing under that. A section that wants a different lead-in
     overrides --sec-pt and both follow — .sec--contact used to override
     padding-top alone, which moved the layout and left the landing behind. */
  --sec-pt: var(--sp-sec);
  padding-block: var(--sec-pt) var(--sp-sec);

  /* Land the CONTENT under the nav, not the section's top edge. html's
     scroll-padding-top already stops the edge level with the nav; on its own
     that left the heading a third of the way down a 900px viewport with ~190px
     of empty padding above it, so clicking "About" looked like it had
     overshot — the section was there, but the part with words on it was not.
     Pulling back by the padding it does not need puts the heading where the
     nav's own gap would put it.

     Negative on purpose: scroll-margin is a <length>, so unlike scroll-padding
     it may go below zero, and this is the one place that is the point rather
     than a trick. min() caps it at 0 so the rule can only ever GIVE BACK a
     section's own excess lead-in and never push one further down the screen —
     which is what keeps contact, whose padding is already smaller than
     --sp-anchor, landing exactly where it lands today. */
  scroll-margin-top: min(0px, calc(var(--sp-anchor) - var(--sec-pt)));
}
.sec--alt { background: var(--bg-2); }
.sec--alt::before,
.sec--alt::after {
  content: '';
  position: absolute;
  left: 0; right: 0;
  height: 1px;
  background: var(--line);
}
.sec--alt::before { top: 0; }
.sec--alt::after  { bottom: 0; }

/* faint asanoha-ish diagonal weave behind alt sections */
.sec--alt .wrap { position: relative; z-index: 1; }
.sec--alt::before { z-index: 1; }

.sec__head {
  max-width: 58ch;
  margin-bottom: clamp(2.4rem, 4vw, 4rem);
}
.sec__num {
  display: inline-flex;
  align-items: baseline;
  gap: .5em;
  margin-bottom: .9rem;
  font: 500 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .3em;
  color: var(--red);
}
.sec__num::before {
  content: '';
  width: 28px; height: 1px;
  background: var(--red);
  align-self: center;
}
.sec__num i {
  font-family: var(--f-jp);
  font-style: normal;
  color: var(--txt-3);
  letter-spacing: 0;
  /* Counting up (see js/main.js) swaps this glyph seven times in half a second.
     A width transition would fight the swap, but a colour one rides along with
     it — so the numeral warms to red as it counts and cools back afterwards
     without anything having to time the fade. */
  transition: color .28s var(--ease);
  cursor: pointer;
}
/* Only the numerals are clickable, and only they get the cursor — the eyebrow's
   rule and its Arabic digits are not a target. Nothing else marks it out: an
   underline or a hover colour would answer the question the egg is asking. */
.sec__num.is-counting i { color: var(--red); }
.sec__title {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: .6em;
  font: 700 var(--t-2xl)/1.1 var(--f-sans);
  letter-spacing: -.03em;
  margin-bottom: .7rem;
}
.sec__title em {
  font: 500 var(--t-lg)/1 var(--f-jp);
  font-style: normal;
  letter-spacing: .16em;
  color: var(--txt-3);
}
.sec__sub {
  color: var(--txt-2);
  font-size: var(--t-base);
}


/* ── 10 terminal ─────────────────────────────────────────────── */

/* The shell is a floating window, not a block in the page: pinned
   bottom-right, stowed while you're up in the hero, and on screen from the
   terminal section down until you close it. See "stowed" below.

   z-index 96 is chosen against the rest of the stack, not picked at random:
   it clears the grain/scanline/vignette layers (90) and the matrix rain (95)
   so the window stays readable while katakana falls behind it, but sits under
   the nav (100), the custom cursor (200) and the boot overlay (500) — site
   chrome stays on top and clickable, and the boot curtain still covers
   everything, which is what lets the window arrive as the curtain lifts. */
.termwin {
  /* Sized so a `help` listing or a `neofetch` block lands in one screenful
     without scrolling — that's what makes it feel like a shell rather than a
     chat bubble. Height is viewport-relative with a floor, so a short laptop
     screen still gets a usable window and a tall one doesn't get a monolith. */
  --term-h: clamp(320px, 50vh, 480px);
  position: fixed;
  right: var(--term-edge);
  bottom: var(--term-edge);
  z-index: 96;
  width: min(var(--term-w), calc(100vw - 1.6rem));
  transform-origin: 100% 100%;

  /* Closed is the resting state in CSS, and it is now also the state the
     document ships: index.html carries `is-open` on this element but
     `term-stowed` on <body>, so the very first paint is the stowed one whether
     or not a script ever runs. This used to say the window "is there whether or
     not a script ever runs", and that was the bug rather than the feature — the
     comment on the <body> tag in index.html has the measurements.

     `visibility 0s linear .34s` on the way out, `0s` on the way in. That
     asymmetry is the point: hiding waits for the fade to finish, revealing
     happens on the same tick as the class change. A transitioned visibility
     is briefly neither one thing nor the other, and focus() aimed into that
     window silently does nothing — which strands the focus ring. */
  opacity: 0;
  visibility: hidden;
  transform: translateY(14px) scale(.97);
  /* `width` is in here because folding narrows the window as well as flattening
     it (see the is-min rule below), and a right-anchored width transition reads
     as the thing sliding back into its corner. One element, one property, and
     only on a click — not the kind of layout animation that costs anything. */
  transition: opacity .3s var(--ease), transform .34s var(--ease-io),
              width .34s var(--ease-io), visibility 0s linear .34s;
}

/* ── stowed above the terminal section ─────────────────────────
   The window used to be on screen from the first paint, and in this corner
   that meant it sat on the lower-right quadrant of the hero's torii — the
   right pillar and the near end of the kasagi. So above #terminal the shell
   is *stowed*, which index.html declares on <body> in the served markup and
   module 09 in js/main.js maintains from there on. The markup is not a
   belt-and-braces duplicate of the script: it is the only thing that can get
   the FIRST paint right, because main.js is deferred and cannot run before it.

   Note what this does and doesn't do. It gates the state class rather than
   restating the hidden values: `is-open` and `is-min` still mean exactly what
   they meant, so scrolling back up and down again restores whichever state
   you left the window in, and closing it while stowed still sticks. It also
   means every breakpoint's own hidden state is reused as its stowed state —
   the desktop window fades down 14px, the mobile one slides off the bottom
   edge (see §18) — instead of one hard-coded transform being wrong at one of
   them. And the outbound transition on .termwin above is the one that plays,
   `visibility` delay and all.

   :not() on <body> and not on <html> because that's where this file's other
   body-level state lives (is-locked, cursor-ready). With no JS the class now
   ships and is never removed, so this selector never matches and the window
   stays away — deliberately. Without a script it is a dead box with no input,
   no output and no samurai, parked over the lower-right of every screen. Note
   that the .no-js fallback for .reveal in §17 is the opposite call on purpose:
   that one keeps real text content readable, so it has to stay. */
body:not(.term-stowed) .termwin.is-open {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition: opacity .3s var(--ease), transform .34s var(--ease-io),
              width .34s var(--ease-io), visibility 0s;
}

/* Cold start: whatever the first frame decides is a starting condition, not
   something the visitor did, so it must not animate. DO NOT DELETE THIS ON THE
   GROUNDS THAT term-stowed NOW SHIPS IN THE MARKUP — that only retired one of
   the three jobs it was doing.

   The retired one: stowing at boot used to be a state *change* as far as the
   engine was concerned, so it dutifully played the exit transition and the
   window faded down out of the hero's corner over .34s. Now the markup already
   says stowed and module 09's stow(true) agrees with it, so there is nothing to
   interpolate and nothing to silence.

   The two that remain, both of which still need it:
     · land BELOW #terminal — a #contact link, a restored scroll position — and
       module 09 calls stow(false) on that first frame instead. That IS a change,
       from the shipped stowed state to visible, and without this the window
       would fade up out of the corner as though someone had asked for it;
     · the fold, below.

   Held for one frame while module 09 forces a reflow, then dropped, so every
   later stow animates normally. No !important needed: 0,2,1 beats the .termwin
   base rule, and the is-open rules that outrank it can't match while the window
   is stowed anyway.

   .term__body is in the list because the same frame may also decide the window
   should arrive *folded* — land on #contact from a shared link and the shipped
   full-size window would otherwise fold itself over .34s while you watch. The
   height transition that drives the fold lives on .term__body, not on .termwin,
   so silencing .termwin alone would only silence half of it. */
body.term-cold .termwin,
body.term-cold .termwin .term__body,
body.term-cold .termlaunch { transition: none; }

.term {
  border: 1px solid var(--line-2);
  border-radius: var(--r-lg);
  background: var(--elev);
  /* Heavier than --shadow: this thing hovers over live content and needs to
     read as detached from it, not as another card in the flow. */
  box-shadow: 0 2px 8px -2px rgba(0, 0, 0, .7),
              0 28px 64px -16px rgba(0, 0, 0, .92);
  overflow: hidden;
  font-family: var(--f-mono);
}
.term:focus-within {
  /* Of the four color-mix() calls in this file this is the one that had to have a
     fallback: it is a focus indicator. Dropped, the border stays at --line-2 and
     the window gives a keyboard user no sign of where focus went — which the
     box-shadow below partly covers, but partly is not a standard to hold a focus
     ring to. Full-strength green rather than 45% mixed, so the old-browser
     failure is toward a louder ring, never a quieter one. */
  border-color: var(--grn);
  border-color: color-mix(in srgb, var(--grn) 45%, var(--line-2));
  box-shadow: 0 2px 8px -2px rgba(0, 0, 0, .7),
              0 28px 64px -16px rgba(0, 0, 0, .92),
              0 0 32px -12px var(--grn-soft);
}

.term__bar {
  display: flex;
  align-items: center;
  gap: .9em;
  padding: .68em .95em;
  background: var(--elev-2);
  border-bottom: 1px solid var(--line);
  user-select: none;
}
.term__dots { display: flex; gap: 12px; }
.term__dots i,
.term__dot {
  position: relative;
  width: 11px; height: 11px;
  flex-shrink: 0;
  border: 0;
  border-radius: 50%;
  background: var(--line-2);
  transition: filter .2s var(--ease), transform .2s var(--ease);
}
/* The circle is the paint; this is the target. 11px + 12px of inset gives a
   23px hit area — the dots are spaced so those areas abut instead of
   overlapping, so you can't hit "close" while aiming for "collapse". */
.term__dot::after { content: ''; position: absolute; inset: -6px; border-radius: 50%; }
.term__dot--close { background: var(--red); }
.term__dot--min   { background: #f6c452; }
.term__dots i     { background: var(--grn); }
.term__dot:hover  { filter: brightness(1.35); transform: scale(1.15); }
.term__dot:focus-visible { outline-offset: 5px; }
/* A contrast theme repaints every background as Canvas, and a background is all
   these two buttons are — border: 0 above — so close and collapse turned into
   empty title bar. A border is the one part forced-colors keeps and recolours,
   and box-sizing keeps the circle at 11px. The green one is left to vanish: it
   is aria-hidden, does nothing, and drawn next to two real buttons it would
   read as a third. */
@media (forced-colors: active) {
  .term__dot { border: 1px solid ButtonText; }
}

.term__path {
  font-size: var(--t-xs);
  color: var(--txt-3);
  letter-spacing: .05em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The two transitions here do unrelated jobs. `color`/`border-color` are the
   hover. `font-size`/`border-width` are the fold, and they are the fold because
   of what this button does to the bar's HEIGHT.

   Folding used to `display: none` this button, and the button is the tallest
   thing in the bar — 30px against the peek label's 12px — so the bar lost 18px
   of height in the frame the class landed. .termwin is bottom-anchored, so an
   18px shorter card is an 18px LOWER roof, and .sam stands at `bottom: 100%` of
   it: the samurai dropped 18px, un-eased, on the exact frame his sword landed.
   Which is the same complaint the lunge earned, at an eighth the size.

   Collapsing it instead costs no magic numbers, because every dimension of this
   button is already proportional to its font size: `line-height` is a unitless
   1.7 and the padding is in `em`. Take font-size to 0 and the box goes with it,
   both axes, no height:auto to interpolate from and no bound to guess. The
   border is the one part that is not proportional, so it goes on its own.

   `margin-right` is the third thing, and it is here because a collapsed button
   is still a flex ITEM: it keeps its share of .term__bar's `gap`, and measured,
   the folded 240px bar has 8.6px of slack in its flex line against a 15.3px gap.
   So keeping it in flow overran the line by 6.7px, both `margin-left: auto`s
   collapsed, and the peek label was shoved 6.6px into the bar's right padding —
   a resting state, and a visibly lopsided one. The negative margin gives the gap
   back. It is not a magic number either: .term__bar's gap is `.9em` of an
   inherited --t-base, and `-.9 * var(--t-base)` is that same length written out,
   so it tracks the type clamp across every viewport instead of being right at
   one of them. It transitions with the rest so the label never jumps.

   `visibility` is what display:none was really buying — a 0×0 button at opacity 0
   is still tabbable and still announced — and it has to wait for the collapse or
   it blinks out on frame one. But a transition list is read off the style being
   transitioned TO, and unfolding transitions to THIS rule, so a delay written
   here would apply to coming back as well: the button would grow for 340ms while
   still invisible and then pop in. Hence --clear-hide, which the is-min rule
   raises to .34s and this one leaves at 0. Leaving is delayed; arriving is not. */
.term__clear {
  --clear-hide: 0s;
  margin-left: auto;
  flex-shrink: 0;
  padding: .3em .7em;
  font-size: var(--t-xs);
  color: var(--txt-3);
  border: 1px solid var(--line);
  border-radius: 3px;
  transition: color .2s var(--ease), border-color .2s var(--ease),
              font-size .34s var(--ease-io), border-width .34s var(--ease-io),
              margin-right .34s var(--ease-io),
              opacity .2s var(--ease), visibility 0s linear var(--clear-hide);
}
.term__clear:hover { color: var(--red); border-color: var(--red); }

/* Only shown while collapsed, where `clear` would be meaningless. */
.term__peek {
  display: none;
  margin-left: auto;
  flex-shrink: 0;
  font: 400 var(--t-xs)/1 var(--f-jp);
  letter-spacing: .12em;
  color: var(--grn);
}
/* Verb by input device, not by viewport width: a 1200px touchscreen is
   tapped and a 700px window is clicked. */
.term__peek::after { content: '\00a0 click to open'; }
@media (hover: none) {
  .term__peek::after { content: '\00a0 tap to open'; }
}

/* Collapsed — the window folds to its title bar rather than leaving. Two
   different needs: "stop covering that paragraph" and "I'm done with you". */
.termwin.is-min .term__bar { cursor: pointer; border-bottom-color: transparent; }
/* Not `display: none` — see .term__clear above for why an instant 18px is
   worse than a transitioned one. `visibility` still takes it out of the tab
   order and out of a screen reader once it has finished shrinking, which is
   the only thing display:none was really buying. */
.termwin.is-min .term__clear {
  --clear-hide: .34s;                        /* leave the a11y tree last, not first */
  font-size: 0;
  border-width: 0;
  margin-right: calc(-.9 * var(--t-base));   /* hand .term__bar's gap back */
  opacity: 0;
  visibility: hidden;
}
.termwin.is-min .term__peek { display: block; }
.termwin.is-min .term__body {
  height: 0;
  opacity: 0;
  /* Hidden once the fold has finished, so the collapse animates instead of
     the content vanishing first. `visibility` also keeps the folded prompt
     and scrollback out of the tab order and out of a screen reader — clipping
     to zero height with overflow:hidden does neither. */
  visibility: hidden;
  /* `transform` is in the list for §10b's severed slab, which sets a value and
     nothing else. It has to be listed HERE rather than declared over there: a
     `transition` shorthand on .termwin.is-cut-min .term__body has the same
     specificity as this rule and comes later in the file, so it would replace
     this list wholesale — and the first thing to go would be `height`, i.e. the
     fold itself. It did, once, and only a filmstrip caught it. */
  transition: height .34s var(--ease-io), opacity .24s var(--ease),
              transform .18s var(--ease), visibility 0s linear .34s;
}

/* Folded, there is nothing left to show but the title bar — so it stops
   claiming a window's worth of width as well as a window's worth of height.
   240px is the narrowest the bar still reads at: three dots, the fold hint,
   and the padding, with the path dropped. The path goes rather than
   ellipsizing, because at this width it truncates to "a…" and the dots
   already say what this is.

   This is the load-bearing rule for §18. Reserving a permanent 600px of page
   for a window that is only that wide inside its own section would cost more
   than the layout can pay at laptop widths; reserving 240px does not. */
@media (min-width: 861px) {
  .termwin.is-min { width: var(--term-min-w); }
  .termwin.is-min .term__path { display: none; }
}

.term__body {
  display: flex;
  flex-direction: column;
  height: var(--term-h);
  overflow: hidden;
  /* Unfolding reveals on the same tick, so the prompt is focusable the moment
     the class comes off. See the note on .termwin. */
  transition: height .34s var(--ease-io), opacity .24s var(--ease),
              transform .18s var(--ease), visibility 0s;
}

.term__screen {
  /* Sized by the window, not by itself — the body owns the height so the
     collapse animation has a single number to drive. */
  flex: 1 1 auto;
  min-height: 0;
  padding: 1.05em 1.1em;
  overflow-y: auto;
  overscroll-behavior: contain;
  font-size: var(--t-sm);
  line-height: 1.65;
}
.term__screen:focus { outline: none; }
/* The output is a tab stop (tabindex=0, so it can be scrolled from the keyboard)
   and its focus ring is the window's: .term:focus-within tints the border and
   adds a glow. Under forced-colors the border is recoloured to the same system
   colour either way and the glow is dropped, so tabbing in changed nothing on
   screen. Inset, because .term__body clips anything outside the screen's box.
   CanvasText, not Highlight: Firefox leaves Highlight as its see-through
   selection tint, which barely shows against Canvas. */
@media (forced-colors: active) {
  .term__screen:focus-visible { outline: 2px solid CanvasText; outline-offset: -2px; }
}

/* one printed line */
.tl-line { white-space: pre-wrap; word-break: break-word; }
.tl-line + .tl-line { margin-top: 1px; }
.tl-echo   { color: var(--txt); margin-top: .7em; }
.tl-echo b { color: var(--grn); font-weight: 400; }
.tl-echo i { color: var(--txt-3); font-style: normal; }
.tl-out    { color: var(--txt-2); }
.tl-key    { color: var(--grn); }
.tl-hi     { color: var(--txt); font-weight: 500; }
.tl-red    { color: var(--red); }
.tl-dim    { color: var(--txt-3); }
.tl-err    { color: var(--red); }
.tl-jp     { font-family: var(--f-jp); color: var(--txt-3); letter-spacing: .1em; }
.tl-rule   { color: var(--line-2); }
.tl-gap    { height: .6em; }
.tl-line a { color: var(--grn); text-decoration: underline; text-underline-offset: 3px; }
.tl-line a:hover { color: var(--red); }

/* `banner` output — wide ASCII, allowed to scroll rather than wrap */
.tl-art {
  margin: .5em 0;
  font: 500 clamp(5px, 1.72vw, 11px)/1.06 var(--f-mono);
  color: var(--red);
  white-space: pre;
  overflow-x: auto;
  overflow-y: hidden;
}

/* `neofetch` output — art beside the info column */
.tl-neo {
  display: flex;
  gap: 1.6em;
  align-items: flex-start;
  margin: .4em 0;
  flex-wrap: wrap;
}
/* The art is a grid of ▀ ▄ █ and spaces, so it only holds together if every cell
   is the same size. Two separate things were stopping that, both measured:

   1. It does NOT use --f-mono, and that is the whole fix for the skew. Google
      Fonts serves JetBrains Mono in latin/latin-ext/cyrillic/greek subsets and
      Block Elements (U+2580-259F) is in none of them, so ▀ ▄ █ silently fall
      back to a local monospace while the SPACES stay in JetBrains Mono. Measured
      at 10px: blocks 5.498px, space 6px. Two advances on one line means each row
      is as wide as its own mix of blocks and gaps, so no two rows share a column
      grid and nothing can be made to line up by editing the art. Naming one
      family that supplies both puts them back on one advance. (The old art also
      had a plain authoring bug — its right pillar was written one column further
      right below the nuki than above it — but that was separate, and fixing it
      alone would not have straightened anything.) (document.fonts.check claims
      JetBrains Mono has the blocks. It is wrong; the advance is the evidence.)

   2. Courier New rather than the generic stack, because the advance also has to
      land on whole device pixels or the columns antialias into seams. A 0.55em
      font gives 5.5px at 10px, which is 11 device px at 2x — fine — but half
      pixels at 1x, where a solid slab measures 6 dim columns out of 62. Courier
      New is 0.6em, so 10px is exactly 6px and the slab is solid at 1x AND 2x.
      This costs nothing aesthetically: the art contains no letterforms, only
      blocks, so which font draws them is invisible.

   A flat 10px, not a clamp, and that follows from point 2. Only sizes where
   0.6em is a whole number stay sharp — 10, 15, 20 — so the old
   clamp(7px, 1.5vw, 10px) spent its entire lower range seaming. It was there to
   keep 25 columns from overflowing a narrow terminal; the art is 21 columns now
   and 126px fits anything, and .tl-neo wraps the info column underneath below
   that. Fixed size, always integral, always sharp.

   line-height must stay exactly 1. At 1.15 there is ~1.5px of background between
   every line, which is 2 dead rows through the middle of anything solid. */
.tl-neo__art {
  flex-shrink: 0;
  font: 500 10px/1 'Courier New', ui-monospace, monospace;
  color: var(--red);
  white-space: pre;
}
.tl-neo__info { min-width: 0; flex: 1 1 16em; }

.term__form {
  display: flex;
  align-items: center;
  gap: .6em;
  flex-shrink: 0;
  padding: .8em 1.1em;
  border-top: 1px solid var(--line);
  background: var(--elev-2);
}
.term__ps1 {
  flex-shrink: 0;
  font-size: var(--t-sm);
  color: var(--txt-3);
}
.term__ps1 span { color: var(--grn); }
.term__ps1 b { color: var(--vio); font-weight: 400; }
.term__input {
  flex: 1;
  min-width: 0;
  font-size: var(--t-sm);
  caret-color: var(--grn);
}
/* iOS Safari zooms the whole page in when a field under 16px takes focus, and does
   not zoom back out when it loses it. --t-sm is 12.9px on a phone. The prompt comes
   up with the input so the line being typed on stays one size; the output above does
   not, because its banners and tables are laid out for --t-sm and would re-wrap.
   Gated on the input device like the hint keys below, since it is a touch visitor
   who gets zoomed, at any width. max() so a larger root size still wins. No engine
   that runs here performs the zoom, so .preview-tools/ios-zoom.js measures the rule
   iOS applies — the focused field's computed size — instead. */
@media (hover: none), (pointer: coarse) {
  .term__ps1,
  .term__input { font-size: max(16px, var(--t-sm)); }
}
/* Green, not the global red — a red box around a text field in a page that
   also uses red for form errors reads as "you did something wrong". */
.term__input:focus-visible {
  outline-color: var(--grn);
  outline-offset: 2px;
}
/* `opacity: 1` rather than dropping the declaration: Firefox's UA stylesheet
   applies its own opacity to ::placeholder, so deleting the line would hand the
   dimming back to the browser instead of removing it. The .6 that used to be
   here was on top of --txt-3 and took the word "help" — the one hint a visitor
   has that this terminal accepts input at all — down to 2:1. */
.term__input::placeholder { color: var(--txt-3); opacity: 1; }
.term__hintkey {
  flex-shrink: 0;
  font-size: var(--t-xs);
  /* No `opacity: .6` — it was --txt-3 dimmed a second time, at 2.6:1. This one
     only showed up once the probe started driving the terminal open instead of
     reading it stowed, which is the argument for driving it. */
  color: var(--txt-3);
}
/* "↹ tab  ↑ history" names two keys, so like .term__peek's verb above it belongs
   to the input device and not to the width. §18's `max-width: 860px` block also
   hides it, and that rule stays — but width alone got both edges backwards, and
   measurably so (.preview-tools/help-pointer.js reports the computed display):
   a 1280px touchscreen was shown a Tab key it has not got, while an 860px
   desktop window that has one was not told. The two rules answer two different
   questions and both are worth answering — this one "are there keys?", that one
   "is there room on the input row?" — and since a hint is only ever hidden,
   never restored, they compose without either having to know about the other. */
@media (hover: none), (pointer: coarse) {
  .term__hintkey { display: none; }
}

/* ── the terminal's home base, in the page ──────────────────────
   #terminal keeps the heading and the command palette. The window used to
   sit here; now this is what the nav link, the hero scroll cue and `goto
   terminal` all land on, and what you use to get the window back. */
.termhome {
  padding: clamp(1.1rem, 2.4vw, 1.7rem);
  background: var(--elev);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  font-family: var(--f-mono);
}
.termhome__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1rem clamp(1rem, 2.5vw, 1.8rem);
  margin-top: clamp(1.1rem, 2.2vw, 1.6rem);
  padding-top: clamp(1.1rem, 2.2vw, 1.6rem);
  border-top: 1px solid var(--line);
}
.termhome__open { flex-shrink: 0; }
.termhome__hint {
  flex: 1 1 20ch;
  font-size: var(--t-xs);
  line-height: 1.85;
  color: var(--txt-3);
}
.termhome__hint code { color: var(--grn); }
.termhome__hint kbd {
  display: inline-block;
  padding: .15em .45em;
  font: 400 .95em/1.3 var(--f-mono);
  color: var(--txt-2);
  background: var(--elev-2);
  border: 1px solid var(--line-2);
  border-radius: 3px;
}

/* ── reopen button ──────────────────────────────────────────────
   Occupies the corner the window vacated, so close and reopen are the same
   gesture in the same place. It is the inverse of the window's own state:
   exactly one of the two is ever on screen. */
.termlaunch {
  position: fixed;
  right: clamp(.8rem, 1.7vw, 1.9rem);
  bottom: clamp(.8rem, 1.7vw, 1.9rem);
  z-index: 96;
  display: flex;
  align-items: center;
  gap: .6em;
  padding: .7em 1.05em;
  font: 500 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--txt-2);
  background: var(--elev-2);
  border: 1px solid var(--line-2);
  border-radius: 99px;
  box-shadow: 0 18px 40px -14px rgba(0, 0, 0, .9);

  opacity: 0;
  visibility: hidden;
  transform: translateY(10px) scale(.96);
  transition: opacity .26s var(--ease), transform .3s var(--ease),
              color .2s var(--ease), border-color .2s var(--ease),
              visibility 0s linear .3s;
}
/* Shown on the same tick as the class — close() hands focus straight to this
   button, and it has to be focusable by then. That constraint is the whole
   reason a cut delays this rule's PAINT rather than the class: see "the pill
   waits its turn" in §10b, which holds opacity and transform for the length of
   the fall and deliberately leaves visibility alone.

   Stowed with the window, for the same reason: a pill is small, but it would
   be the one thing left sitting on the gate. Same gating trick — see the note
   above .termwin's own is-open rule. */
body:not(.term-stowed) .termlaunch.is-shown {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition: opacity .26s var(--ease), transform .3s var(--ease),
              color .2s var(--ease), border-color .2s var(--ease),
              visibility 0s;
}
.termlaunch:hover { color: var(--grn); border-color: var(--grn); }
.termlaunch__ps1 { color: var(--grn); letter-spacing: 0; }
.termlaunch i {
  font: 400 11px/1 var(--f-jp);
  font-style: normal;
  letter-spacing: .1em;
  color: var(--txt-3);
}

.term__chips {
  display: flex;
  flex-wrap: wrap;
  gap: .45rem;
}
.term__chips button {
  padding: .38em .8em;
  font-size: var(--t-xs);
  color: var(--txt-2);
  border: 1px solid var(--line);
  border-radius: 99px;
  transition: color .2s var(--ease), border-color .2s var(--ease),
              background-color .2s var(--ease), transform .2s var(--ease);
}
.term__chips button::before { content: '$ '; color: var(--txt-3); }
.term__chips button:hover {
  color: var(--grn);
  border-color: var(--grn);
  background: var(--grn-soft);
  transform: translateY(-1px);
}


/* ── nothing to see ────────────────────────────────────────────
   A line in the footer painted in nothing, which only exists while it is
   selected. Ctrl+A finds it; so does dragging across the footer, and so does the
   reader-mode or plain-text copy of the page, which is the version of this joke
   that costs nothing to anybody.

   `color: transparent` and not `display: none`, `visibility: hidden`,
   `font-size: 0` or a clip: all of those remove it from selection as well as
   from view, which would leave nothing for ::selection to reveal. Transparent
   text is still text — it lays out, it selects, it copies, and a screen reader
   reads it, which is fine because it is a real sentence and not a trick.

   It reserves its own line in the footer. That is the cost and it is the point:
   the blank gap under "Built by hand" is the only clue there is. */
.ghost {
  display: block;
  margin-top: .5rem;
  color: transparent;
  font: 500 var(--t-xs)/1.5 var(--f-mono);
  letter-spacing: .04em;
}
/* Both spellings. ::selection is standard and shipped everywhere current, but
   Firefox only unprefixed it in 62 and the prefixed rule is one line — and
   unlike most vendor pairs these cannot be combined into one selector list,
   because an unknown pseudo-element invalidates the whole rule it appears in. */
.ghost::selection      { color: var(--red); background: rgba(255, 59, 83, .14); }
.ghost::-moz-selection { color: var(--red); background: rgba(255, 59, 83, .14); }

/* ── 10b the samurai ─────────────────────────────────────────── */

/* A pixel-art samurai stands on the window's top border, breathing, and cuts
   the window down when you dismiss it. Art: CraftPix's free Samurai_Commander
   sheets — see assets/README.txt for the credit and the frame counts.

   HE IS A CHILD OF .termwin, AND THAT IS THE WHOLE DESIGN.

   The hard part of a figure standing on this window is that the window's height
   changes: folding runs a .34s `height` transition on .term__body. The obvious
   approach — measure the window in JS and match a transform to that duration
   and easing — is a race between the compositor and the main thread, and it
   loses on any long task: his feet float above a roof that has stopped moving.
   It is also wrong from the first frame, because offsetHeight read straight
   after `.is-min` lands still reports the *old* height (measured: 488px when
   the answer is 38px — the bar's display swaps apply instantly, the body's
   height does not).

   `bottom: 100%` on an absolutely-positioned child has neither problem. A
   percentage offset resolves against the parent's used height in the *same*
   layout pass that shrank it, so his feet are on the roof frame-for-frame with
   no JS at all. Measured across a fold and an unfold at five viewports: the gap
   between his feet and the window's top edge is 0.00px on every animation
   frame. Nothing to desync, nothing to cache, nothing to re-probe on resize.

   Being a child also settles his presence for free, and correctly. He inherits
   the window's opacity, so he is there in exactly the states the window is
   there in: he rides the fold down onto the folded bar, he fades out with the
   window when you close it, and he is stowed with it up in the hero — which is
   the right answer, because the hero's corner is the torii's corner and §10's
   stow rule exists precisely to clear it. He also inherits §19's print rule.

   Note what is NOT tokenised: the title bar's height. It is 54px open and 36px
   folded (the path and the `clear` button both leave), so any constant would be
   wrong by 18px in one state. Nothing here needs that number — which is the
   point of anchoring to the roof rather than to the bar. */

.termwin {
  /* Where the close-cut crosses the roof. Measured off the composited page
     rather than derived from the cell grid, because deriving it got two separate
     things wrong.

     The tip of attack_2's blade, on the contact frame the beam is actually drawn
     against — sam-a2 is 150ms in steps of 5 and holds frame 4 from 120ms, and
     the beam is not born until 150 — lands 247.5px from the window's right
     corner and 32.5px above the roof. That is ~7.5px further out than cell
     arithmetic predicts, and the bulk of it is the `left: 5px` registration
     nudge this pose carries below: the zoom doubles those 5 cell px and
     .sam__flip turns them around, so they move his blade 10px AWAY from the
     corner rather than 5 towards it. Easy to miss on paper. Impossible to miss
     in a screenshot diff.

     Then the beam is not vertical. It rakes down-left at 32deg — the angle of
     the tip's own travel between frames — so the point where its line has to
     cross the roof is not under the tip at all, it is 32.5 x tan32 = 20.3px
     further left again. 247.5 + 20.3 = 267.8 for the centreline, and
     .term__cut--v is a 3px bar pinned by its right edge, so this is that
     minus 1.5.

     What that buys: his blade, the 32.5px of border it does not reach, and the
     beam are one straight stroke. At the old value the line, projected back up
     to the blade's own height, passed 26px to the right of the tip — so the
     stroke went right, then kinked left, and the tip hung off the side of it.
     "Why he no longer drops" below is the other half of this: the drop existed
     to close the same gap by moving HIM, and this closes it by aiming the beam,
     which is the half that does not put a man through a window. */
  --sam-cut-x: 266px;
}

/* The anchor. Never transformed — every animation lives on a descendant, so
   this box stays pure layout and the roof contract above cannot be broken by a
   stray transform.

   WHY 2× AND NOT 1×. At 1× the art is a 55×92 smudge: these sheets were drawn
   to be displayed doubled, and undoubled he reads as a coloured smear rather
   than a figure. The argument against 2× was arithmetic — a 256px box against a
   240px folded window overhangs the reserved rail by 16px — but that measures
   the BOX, and the box is mostly empty. Measuring the pixels instead: the cell's
   figure occupies x27..82 idling and never exceeds x10..120 on any frame of any
   sheet, so mirrored and doubled and right-anchored, the furthest-left painted
   pixel lands 274px from the viewport's right edge against a --term-rail of
   288.5px. Nothing he paints, in any pose, reaches page text. The 16px of
   overhang is transparent, pointer-events:none, and paints nothing.

   Only integer scale. The sashimono pole is 1px wide and strobes at anything
   fractional, which is also why this is a flat 2× rather than something that
   adapts: a scale that changes mid-session pops, and 1.5× shimmers. */
.sam {
  /* ── his ground speed, and it lives HERE for a reason ──────────
     These two used to sit on .termwin, back when the only sprite on the page was
     a child of it. They are read by `.sam.p-run .sam__cel` and by
     `.sam.is-runin .sam__rig`, with no fallback — and `sl` and `spar` both build a
     throwaway sprite in a fixed overlay at body level now, outside .termwin
     entirely. So --sam-run-dur did not resolve on those, which made
     `animation: sam-run var(--sam-run-dur) steps(8) infinite` INVALID AT
     COMPUTED-VALUE TIME, which resets the whole shorthand, which means
     animation-name: none. The run cycle simply did not play: both crossings were
     one frozen frame of run.png being slid across the page. Which is exactly what
     it looked like, and no amount of tuning the translate was ever going to fix
     it.

     On .sam they cannot go missing again. Every sprite that exists has this class
     — the real one, .sam--road, .sam--free — wherever it happens to live, and
     strike()'s inline override still wins over a class rule for the real one.

     AND THE NUMBERS ARE MEASURED, not chosen. run.png is eight frames and the
     foot that is on the ground drifts backwards 9.5 cell px per frame across it
     (contact patch by contact patch — .preview-tools/run-stride.js). That drift IS
     the ground speed: carry him at exactly 9.5 cell px per frame and the planted
     foot stays still on the ground, which is what running looks like. Slower and
     it slides forward under him, which is a moonwalk; faster and it slides
     backwards. Both read as gliding.

     Eight frames at 9.5 is 76 cell px per 440ms cycle. It used to be 120, 58% too
     fast, on the reasoning that the 2x scale doubles his stride so the apparent
     speed is unchanged — true of the stride, false of the skate, because the drift
     and the translate are both in cell px inside the zoom and so have to match
     each other there. js/samurai.js derives its SPEED from these same two
     numbers, so there is one ground speed on this page and not three.

     76 still starts him clear of the card: his painted right edge is 31 cell px
     inside his own box's right, so he begins 45 cell px past the corner and
     enters rather than fades in. */
  --sam-run-x: 76px;
  --sam-run-dur: 440ms;

  position: absolute;
  right: 8px;
  bottom: 100%;
  width: 256px;
  height: 256px;
  pointer-events: none;
  /* Lifts him off body text, the matrix rain and the hero canvas without a halo
     ring; the second shadow doubles as a contact shadow under his feet. Out here
     rather than inside the scale, so the radii stay in screen pixels. */
  filter: drop-shadow(0 0 3px var(--bg)) drop-shadow(0 0 7px rgba(5, 6, 10, .92));
}

/* The only node that knows about the scale. Everything below it works in CELL
   coordinates — 0..127, one sheet frame — so every keyframe offset, the blade
   drop and the run-in distance are all written as the numbers measured off the
   PNGs, and the doubling is applied once, here. Changing 2 to 3 is the entire
   diff for a 3× guard.

   It is also deliberately NOT the rig: the rig animates `transform`, and
   `transform` is one property, so a scale living there would have to be
   repeated in every keyframe of every pose and would silently multiply the
   translate distances. */
.sam__zoom {
  position: absolute;
  left: 0;
  top: 0;
  width: 128px;
  height: 128px;
  transform: scale(2);
  transform-origin: 0 0;
}

/* Carries the run-in, which since the lunge went is the only thing that moves
   him relative to the roof at all. Separate from the cel because the two need
   different timing functions: poses step, movement eases. */
.sam__rig {
  position: absolute;
  inset: 0;
}

/* The 128px porthole. Inside the rig, so the clip travels with him — a
   stationary clip would slice him in half during a run-in. */
.sam__stage {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

/* Every CraftPix sheet faces right; the window is in the right-hand corner, so
   unmirrored he would be staring off the edge of the screen and cutting away
   from the page. Mirrored he faces into the content and his cuts sweep across
   the window. -1 is exact, so nothing resamples.

   Its own node rather than a transform on the stage or the cel: on the stage
   the clip would flip too (harmless but confusing), and on the cel the frame
   stepping would run backwards. */
.sam__flip {
  position: absolute;
  inset: 0;
  transform: scaleX(-1);
}

/* The strip. One sheet at a time as a background, stepped by translating the
   strip behind the porthole — never by animating background-position, which
   repaints instead of compositing. */
.sam__cel {
  position: absolute;
  left: 0;
  top: 0;
  height: 128px;
  background-repeat: no-repeat;
  background-size: auto 128px;      /* sheets are 128 tall — 1:1, no resampling */
  image-rendering: pixelated;
  will-change: transform;
}

/* ── poses ─────────────────────────────────────────────────────
   One class per pose on .sam. Every offset is a literal multiple of 128px and
   every timing function is a literal steps() — no var() inside a keyframe or a
   timing function, which is the kind of thing that parses in three engines and
   silently kills all animation in the fourth.

   Loops use steps(n), which never shows the out-of-range frame because the
   animation restarts at 100%. One-shots use steps(n, jump-none) with
   `forwards`, so the last frame is a real frame and is *held* — plain steps(n)
   would land on the strip's end and flash frame 0 at the worst moment. */

/* The `left` values are registration, not layout. CraftPix drew these sheets one
   action at a time and did not align them to a shared origin, so his hips sit at
   a different column in every sheet: idle 57.9, run 40.9, jump 49.2, attack_2
   53.1, attack_3 54.1 (mean x of the hip band, the steadiest anchor on the
   figure). Left uncorrected, every pose change slides him sideways — worst on
   idle -> run, 17 cell px, which at scale(2) is a 34 device px jump the instant
   the class flips. Nudging each strip back to idle's column costs nothing:
   `left` is not animated, so it composes with the animated translateX for free.

   Two things this could have broken, both checked against the sheets rather than
   assumed. Rail: he is mirrored, so shifting a strip right in cell space moves
   his pixels left, toward the page. Reach works out to term-edge + 10 + 2*maxX
   against a 288px reserved rail, and the tightest sheet still clears by 4px.
   Bleed: the porthole is exactly one cell wide, so a shift of S trims S columns
   off the far edge of every frame — those columns have to be empty, and the
   tightest margin is 3px. Both verified per frame, not per sheet. */

.sam.p-idle  .sam__cel { width:  640px; background-image: url(../assets/samurai/idle.png);
                         animation: sam-idle 3200ms step-end infinite; }
.sam.p-run   .sam__cel { width: 1024px; left: 17px; background-image: url(../assets/samurai/run.png);
                         animation: sam-run var(--sam-run-dur) steps(8) infinite; }
.sam.p-leap  .sam__cel { width:  896px; left:  9px; background-image: url(../assets/samurai/jump.png);
                         animation: sam-leap 340ms steps(5, jump-none) forwards; }
/* One frame, held, so there is nothing to animate and no keyframes for it. See
   "riding the fold down" below for why the fold needs a falling frame at all. */
.sam.p-fall  .sam__cel { width:  896px; left:  9px; background-image: url(../assets/samurai/jump.png);
                         transform: translate3d(-512px, 0, 0); }
.sam.p-land  .sam__cel { width:  896px; left:  9px; background-image: url(../assets/samurai/jump.png);
                         animation: sam-land 240ms step-end forwards; }
.sam.p-cut-min   .sam__cel { width: 512px; left: 4px; background-image: url(../assets/samurai/attack_3.png);
                             animation: sam-a3 160ms steps(4, jump-none) forwards; }
.sam.p-cut-close .sam__cel { width: 640px; left: 5px; background-image: url(../assets/samurai/attack_2.png);
                             animation: sam-a2 150ms steps(5, jump-none) forwards; }
/* Leaving. The same jump.png the fold's tail uses, but frames 0..4 instead of
   2..6 — the gather and the flight, and no landing, because there is nothing off
   the right-hand edge of the screen to land on. step-end for the reason sam-land
   uses it: the five dwells are not equal. */
.sam.p-exit  .sam__cel { width:  896px; left:  9px; background-image: url(../assets/samurai/jump.png);
                         animation: sam-exit-cel 620ms step-end forwards; }

/* The breath. Worth spelling out, because two of the three things in here look
   like mistakes and the sheet is the only place the reasons are visible.

   idle.png is not five poses. Measured, the figure's ground row per frame is
   127, 127, 127, 126, 126 and its top row is 36, 36, 36, 35, 35 — so f3 and f4
   are the WHOLE figure, legs and feet included, drawn one pixel higher up the
   cell. Not a chest lifting: a translation.

   That is the bob, and the y offsets below cancel it. Left alone it is 1 cell px,
   which .sam__zoom's scale(2) turns into 2 CSS px on screen, and it moves his
   FEET — he stands on `bottom: 100%`, i.e. exactly on the window's top border, so
   every cycle his soles detached from a hard 1px edge and clipped back onto it.
   Against a straight line, 2px of that is not a breath, it is a twitch. Pushing
   f3/f4 back down by the 1px they were drawn up by plants him for good: stepping
   the paused loop through all five dwells and reading the lowest lit row of the
   rendered sprite gives one row for all five, where before it gave two.

   And it costs nothing, which is the part worth measuring before believing.
   Diffed in full colour against f0 with each frame first put back on f0's ground
   row, every frame still differs: 127, 143, 152 and 151 pixels, mean delta ~120
   of 255, and all of it inside rows 52..85 — the chest and the banner. 9.1% of
   the figure, in the right place. The shading was always doing the actual
   breathing; the translation was just riding along on top of it and spoiling it.

   1px and not a fraction, deliberately. A ramped or fractional offset would hold
   the cel at a fractional device px, .sam__zoom's scale(2) would resample it and
   `image-rendering: pixelated` would show every seam of that — the same trap as
   the fold's landing frame, see §10b further down. step-end plus a whole pixel
   means the offset is only ever exactly 0 or exactly 1, so it stays on the grid.

   The other thing that was wrong was the clock. Five uniform frames in 850ms is
   a breath every 0.85s, about 73 a minute, which is not breathing, it is
   shivering; a resting human runs 12–20. So 3.2s a cycle (~19 a minute) with
   step-end and hand-weighted dwells, since steps(5) can only ever divide the
   time equally. The settled frames get the long, easy dwells and shorten as he
   gathers; the turn lands at 56%; the last frame is held through to the wrap. */
@keyframes sam-idle {
  0%   { transform: translate3d(   0,   0, 0); } /* f0  settled            768ms */
  24%  { transform: translate3d(-128px, 0, 0); } /* f1  chest shifts       576ms */
  42%  { transform: translate3d(-256px, 0, 0); } /* f2  gathering          448ms */
  56%  { transform: translate3d(-384px, 1px, 0); } /* f3  drawn 1px high — put back */
  78%  { transform: translate3d(-512px, 1px, 0); } /* f4  likewise         704ms */
}                                                /* wrap to f0 is the release */
@keyframes sam-run  { from { transform: translate3d(0, 0, 0); }
                      to   { transform: translate3d(-1024px, 0, 0); } }
/* jump frames 2..6 — rise, apex, fall, land, recover */
@keyframes sam-leap { from { transform: translate3d(-256px, 0, 0); }
                      to   { transform: translate3d(-768px, 0, 0); } }
/* Four frames, not two, and the last two are not from the landing at all.

   Measured off the sheet, jump.png's ground row per frame is 127, 127, 125, 113,
   117, 127, 127 and its silhouette top is 43, 46, 30, 31, 34, 59, 59 — so f5 and
   f6 are the same deep crouch, 22 cell px shorter than idle. This used to run
   f5 -> f6 and stop, which meant the move ended with him folded over his knees
   and the next pose, p-idle, stood him up in a single frame: 44 device px of
   silhouette, un-eased, at the quietest moment of the whole sequence. A move has
   to end where the next one begins, so the recovery is part of it: f1 takes the
   weight back and f0 is jump.png's own standing frame.

   That leaves 12 device px, not none — jump f0's top is 43 and idle f0's is 37,
   because f0 is drawn as the gather before a leap and is a touch shorter than a
   man standing. It is the floor for this sheet; nothing in jump.png tops out at
   37. But the residual runs the right way. Tops go 59 -> 59 -> 46 -> 43 -> 37, so
   the swap into idle continues a rise that is already three frames old and reads
   as the last of standing up rather than as a jump cut. His feet do not move at
   all across it: both f0s plant at row 127.

   step-end, not steps(4), because the four dwells are not equal. f5 is the hit
   and f6 the absorb, both quick; standing up takes longer than being knocked
   down. */
@keyframes sam-land {
  0%    { transform: translate3d(-640px, 0, 0); }   /* f5  the impact      80ms */
  33.3% { transform: translate3d(-768px, 0, 0); }   /* f6  absorbing it    80ms */
  66.7% { transform: translate3d(-128px, 0, 0); }   /* f1  weight coming back  */
  100%  { transform: translate3d(    0, 0, 0); }    /* f0  stood up = idle f0  */
}
@keyframes sam-a3   { from { transform: translate3d(0, 0, 0); }
                      to   { transform: translate3d(-384px, 0, 0); } }
@keyframes sam-a2   { from { transform: translate3d(0, 0, 0); }
                      to   { transform: translate3d(-512px, 0, 0); } }

/* The exit, frame by frame. jump.png's own drawing does a third of the lifting
   here: its ground rows are 127, 127, 125, 113, 117 for f0..f4, so by f3 the
   sprite has already picked his feet up 14 cell px before the rig has moved him
   anywhere. That is measured, and it is why the arc below is only 20 cell px
   tall — the two add up, and a 20px arc under a sheet that lifts 14 of its own
   reads as one jump of 34.

   The frame changes sit on the events of the arc rather than spread evenly. f0
   is the turn. f1 is the gather. f2 is the extension, and it is where the rig
   starts to move, so he leaves the roof on the frame his feet leave the ground.
   f3 is the tuck and holds through the rise; f4 puts his legs back out and takes
   over at 69%, which is the apex — the frame swap and the top of the arc are the
   same moment on purpose. */
@keyframes sam-exit-cel {
  0%     { transform: translate3d(     0, 0, 0); }  /* f0  he turns          60ms */
  9.68%  { transform: translate3d(-128px, 0, 0); }  /* f1  gather            60ms */
  19.35% { transform: translate3d(-256px, 0, 0); }  /* f2  extension — off   70ms */
  30.65% { transform: translate3d(-384px, 0, 0); }  /* f3  tuck, rising     250ms */
  69.35% { transform: translate3d(-512px, 0, 0); }  /* f4  legs out, apex   190ms */
}

/* ── the run-in, and why he no longer drops ────────────────────
   He used to step down into the cut, and it is worth writing down why he does
   not any more, because the geometry that argued for it is real. Both sheets
   resolve their blade well above his feet: measured off the pixels, attack_3's
   steel sits 64 cell px above them on its contact frame and attack_2's finishes
   18 above. Translating the rig down by exactly those numbers put the blade ON
   the border it severs — right on paper, wrong on screen. A figure standing on a
   surface does not sink through it. Traced frame by frame, the fold left his feet
   126 device px below the roof — half his own height, his legs behind the
   terminal's own text — and the close left him 34px under it and, because that
   lunge held to the end, faded him out down there rather than on the line he had
   just cut.

   So he stays planted and the beams keep the job of being the cut, which is the
   ordinary convention anyway: the effect lands on the target, and the sword only
   has to sell the swing. The price is an offset between his own steel and the
   beam — 36 device px on the close, 128 on the fold. Everything else still ties
   the two together: the beam fires on the contact frame, it enters the card at
   --sam-cut-x directly under his blade, and it rakes the way the blade travels.

   Nothing replaced the drop, because nothing could. Lateral is the only other
   axis and it is already spent: the rail arithmetic above leaves the tightest
   sheet 4 device px of clearance from page text, so a step INTO the window is
   2 cell px before his pixels are over the copy, and a step the other way is a
   retreat. The art carries the strike on its own — four frames of it on the
   fold, five on the close.

   Which leaves the rig one job. Worth keeping from the lunge's postmortem: it
   had to quantise its travel to whole device pixels, because .sam__zoom scales
   by 2 and .sam__cel is image-rendering:pixelated, so a fractional offset puts
   the composited layer between device pixels and the compositor resamples it —
   measured, at 46.25px 35.7% of his opaque pixels came out off the sheet's
   31-colour palette, and at 46px none did. The run-in does not quantise and does
   not need to: that shows up on a frame being HELD, and the run-in holds nothing.
   It is 440ms of continuous motion that ends on 0. */
.sam.is-runin .sam__rig { animation: sam-runin var(--sam-run-dur) linear both; }

/* Linear, because ground speed under a run cycle has to be constant or his
   feet skate. 120px in 440ms is ~273px/s, about three body-heights a second. */
@keyframes sam-runin {
  from { transform: translate3d(var(--sam-run-x), 0, 0); }
  to   { transform: translate3d(0, 0, 0); }
}

/* ── and then he leaves ────────────────────────────────────────
   He used to dissolve. `.termwin.is-sliced .sam` ramped his opacity to 0 over
   440ms while the two halves of the window fell — better than blinking out with
   the window, which is what it was written to fix, but still the one exit this
   figure cannot do. He is 31 colours, a 1px banner pole and hard pixel edges;
   going translucent turns all of it to mush for 160ms and there is no reading of
   a fading man that says he is leaving. §11 is now a pointer to here.

   So he leaves on his feet, out the way he came in: the run-in above brings him
   on from the right, and the right is where he goes back to.

   THE TURN. Every CraftPix sheet faces right and .sam__flip mirrors him so he
   faces the page — so leaving to the right means facing right, and the exit takes
   the mirror off. That costs a sideways hop, because the flip is about the
   stage's centre and the registration `left` composes with it. Measured off the
   sheets: his hip band sits at cell x 71.0 on attack_2's held last frame and 52.4
   on jump's f0, which through 128-(5+c) mirrored and 9+c not puts him at stage
   x 52.0 before the turn and 61.4 after it. Un-mirroring on its own would
   therefore jog him 9.4 cell px — 19 device px — to the right on the very frame
   he turns, which is the frame the eye is on. The arc starts at -9.5 to cancel
   it, and -9.5 rather than -9.4 because .sam__zoom doubles it and a whole device
   pixel is the only offset `image-rendering: pixelated` holds without
   resampling.

   That prediction was then checked against the composited page, and the check is
   worth keeping because it revises where the number comes from. exit-leap.js
   measures the jog on three row bands: legs +9.50 cell px, hips +11.13, torso
   +15.16. The bands disagree because the two frames either side of the turn are
   different drawings — attack_2's last frame is a finished overhead cut, leaning,
   with the sword out at full extension, and jump's f0 is an upright gather — so
   the torso really is further right afterwards and no single offset can align all
   three. The legs are the band that matters: no steel in them, and they are what
   reads as "he is standing there". -9.5 leaves 0.32 cell px on the legs, under
   one device pixel. Anyone re-deriving this from the hips will get 11 and think
   it is off by 1.6; it is not, it is aligned on the right band. */

/* THE ARC. A parabola, sampled. x is linear in time because nothing slows a jump
   down horizontally; y peaks 20 cell px up at 62% of the flight and is still
   12.5 up when he crosses the edge, so he reads as leaping away rather than as
   falling off the side — except on short screens, where the query below flattens
   it and the sheet's own lift carries the jump. Six stops and `linear` rather than two stops and an
   easing pair: one transform cannot carry a different timing function per axis,
   and sampled this densely the straight lines between samples are under half a
   cell px off the curve.

   HOW FAR. 182 cell px = 364 device px, against a requirement of 241 at the
   widest viewport. His leftmost painted pixel on the airborne frames is cell
   x 28, so with `left: 9` and the 2x zoom it sits 190px inside .sam's right-hand
   edge — measured 36.0 cell px in from that edge on the live page, against the
   36 the sheet predicts — and that edge is 8px plus --term-edge from the
   viewport's. --term-edge is a clamp that runs 12.8..30.4px, pinned to its
   ceiling on anything wider than 1788px, so the worst case is fixed and
   measurable: 222px from his painted edge to the viewport's, plus the 19 he
   starts to the left of, is 241.

   The other 123px are not slack for the geometry, they are slack for the clock,
   and this is the part that had to be measured rather than reasoned about. The
   deadline below is 100ms wide; frame gaps during the fall — two 600x450 clones
   animating at once — were seen at 49..70ms on the machine this was built on, and
   a phone will do worse. Travelling further means crossing the edge EARLIER in
   the flight: at 182 he is across at 72.7% of it rather than 84%, which turns
   100ms of margin into 269 and takes the whole question off the table. Unlike
   --sam-fall none of this is computed per screen, because out there overshoot is
   free — he is off the edge, <body> is overflow-x: hidden, and .termwin is fixed,
   so nothing he does can extend the document or raise a scrollbar. exit-leap.js
   asserts that last part rather than trusting it.

   The one thing the shadow taught: it does not need paying for. `drop-shadow`
   here is two near-black shadows, a contact shadow against the lit window — over
   the page's own near-black background it is invisible, so his painted edge and
   his apparent edge are the same pixel. The +12px the first draft of this reserved
   for it was reserving nothing.

   WHEN. 200ms after contact, scheduled by CUT.close.tail. The halves part on the
   impulse at 114 and 198ms, so he holds the finished cut through the parting and
   turns as they start to drop. The flight is 620ms, so it ends 820ms after
   contact — and is-sliced's delayed opacity hop takes the window, and him with
   it, at 920. He is a child of it; that is the deadline, and it is the reason the
   travel above is over-provisioned rather than fitted. Measured across three
   viewports he is across the edge 651..751ms after contact. */
.sam.is-exit .sam__rig  { --sam-lift: 20px; animation: sam-exit 620ms linear both; }
.sam.is-exit .sam__flip { transform: scaleX(1); }

/* Off the right is the exit; off the TOP is his head being sheared by the
   viewport, and the arc is the only thing that can cause it — he rests 256px
   above the window's roof and the window's roof is wherever --term-h and the page
   put it. Measured headroom above .sam: 258px at 1920x1080, 114 at 1440x900, 66
   at 1280x800, 45 at 900x740, 25 at 880x700. So a 40px apex is fine on anything
   tall and cuts his hat off below about 740, which is squarely inside the range
   he is allowed on (§10a hides him at 599, not 739).

   Flat rather than scaled, and one breakpoint rather than a ladder, for the same
   reason §10a gives for its own flat query: a band of heights that clip him "only
   a little" is worse than a clean either/or, and this way the number that has to
   stay true is a single measured one. What is left when the lift goes is not a
   slide — jump.png draws him 14 cell px higher on f3/f4 all by itself, and that
   lift is INSIDE .sam__stage's 128px porthole, so it cannot be clipped by
   anything. He still visibly rises 28 CSS px; he just does not carry the porthole
   up with him. */
@media (max-height: 779px) { .sam.is-exit .sam__rig { --sam-lift: 0px; } }

/* The x stops are the -9.5 start plus 0, 36.5, 73, 113, 145.5, 182 of travel.
   -9.5 is exact because it is HELD for 120ms against a static window and has to
   land on the pixel grid; the rest are mid-flight and half a cell px there is
   nothing anyone can see. y is written as fractions of --sam-lift rather than as
   the five numbers it resolves to, so that the query above can retune the arc by
   changing one value instead of restating the whole keyframe block. */
@keyframes sam-exit {
  0%, 19.35% { transform: translate3d(  -9.5px, 0, 0); }        /* planted, turned */
  35.48%     { transform: translate3d(    27px, calc(var(--sam-lift) * -0.540), 0); }
  51.61%     { transform: translate3d(  63.5px, calc(var(--sam-lift) * -0.875), 0); }
  69.35%     { transform: translate3d( 103.5px, calc(var(--sam-lift) * -1),     0); }  /* apex */
  83.87%     { transform: translate3d(   136px, calc(var(--sam-lift) * -0.915), 0); }
  100%       { transform: translate3d( 172.5px, calc(var(--sam-lift) * -0.625), 0); }  /* well clear */
}

/* Breathing costs a composited frame six times a second, forever, on every
   page. Free to stop when nobody can see him: closed, or stowed up in the hero.
   Scoped to the idle loop so a strike still plays out — the close choreography
   deliberately outlives `is-open`. Hidden tabs are handled in JS, which also
   has to abort a pending cut. */
.termwin:not(.is-open) .sam.p-idle .sam__cel,
body.term-stowed .sam.p-idle .sam__cel,
.sam.is-still .sam__cel { animation-play-state: paused; }

/* ── poking him ────────────────────────────────────────────────
   Two more sheets, and the only two of the pack's five leftovers worth shipping:
   Protect is a raised guard and Hurt is a flinch, which between them are the
   whole vocabulary of "stop that". Walk duplicates the run, Attack_1 duplicates
   the two cuts, and Dead is a different joke and a worse one.

   REGISTRATION, AND WHY IT IS 7 ON BOTH. The `left` numbers above come from the
   hip band, which is the steadiest anchor on a figure that is mid-stride or
   mid-swing. It is the wrong anchor here, and the sheets say so themselves:
   measured, protect and hurt have byte-identical lower bodies — mean x of the
   lit pixels in y110..127 is 52.82 on both, because CraftPix reused the standing
   legs — and yet their hip bands differ by 6 cell px, since one raises a guard
   and the other recoils the torso. Two sheets with the same legs cannot want two
   different registrations, so the hips are reading lean, not position.

   Anchored on the legs instead: round(60.27 - 52.82) = 7 for both, and the feet
   band y118..127 independently agrees (round(60.25 - 53.36) = 7). Residual is
   -0.45 cell px on the legs and +0.11 on the feet — under one device pixel
   either way, against -6.9 and +5.1 device px for the hip-band answers. Ground
   row is 127 on every frame of both sheets, the same as idle's f0, so there is no
   vertical jog to cancel either.

   Both invariants re-checked at 7 rather than assumed: post-shift extent is
   x40..92 for protect and x25..79 for hurt against a 128px porthole (35 and 48
   columns spare), and reach to page text is 212.4px and 242.4px against a
   288.5px rail (76.1 and 46.1px spare). Comfortable on both counts, which is
   what you would expect from a standing pose — it is the swings that are tight.

   One keyframe block for the pair: two frames is 0 to -128px whatever the sheet,
   and jump-none holds the second frame for the rest of the run rather than
   wrapping to an empty strip. Durations differ because a guard snaps up and a
   flinch takes the hit slightly slower. */
.sam.p-guard .sam__cel { width: 256px; left: 7px; background-image: url(../assets/samurai/protect.png);
                         animation: sam-2f 180ms steps(2, jump-none) forwards; }
.sam.p-hurt  .sam__cel { width: 256px; left: 7px; background-image: url(../assets/samurai/hurt.png);
                         animation: sam-2f 240ms steps(2, jump-none) forwards; }
@keyframes sam-2f { from { transform: translate3d(    0, 0, 0); }
                    to   { transform: translate3d(-128px, 0, 0); } }

/* The hit area. .sam is pointer-events:none and stays that way — see index.html
   for why the whole 256px box must not become clickable — so this is the one
   node that opts back in, and it is sized to the pixels rather than to the box.
   idle's painted union is cell x27..82, y35..127, which through .sam__zoom's 2x
   is 54px in, 70px down, 112 wide, and flush to the bottom because his feet are
   on the cell's last row.

   No hover state and no focus ring, deliberately: a visible affordance would
   turn "I wonder if he is clickable" into "there is a button here", and the
   entire joke is the first one. cursor:pointer is the whole hint, and it only
   appears once you are already on him. Consistent with the rest of the page —
   the custom cursor is scoped to [data-tilt] and does not apply here.

   Nothing is needed to disable this when the window is shut or stowed: closed
   resolves to `visibility: hidden` on .termwin, and a hidden subtree is not
   hit-testable at all. poke()'s own state checks are a belt on that. */
.sam__hit {
  position: absolute;
  left: 54px;
  top: 70px;
  width: 112px;
  bottom: 0;
  pointer-events: auto;
  cursor: pointer;
}

/* ── off duty ──────────────────────────────────────────────────
   Ninety idle seconds and the breath drops to a third speed — 3.2s a cycle is
   ~19 breaths a minute, 9.6s is ~6, which is a man asleep on his feet rather
   than a man on watch. Duration only: same sheet, same five dwells, same
   step-end, so the plant that "the breath" above spends thirty lines protecting
   is untouched. Nothing else in this file has to know.

   The すー is a pseudo-element and not a node, which is the entire reason to do
   it this way: one class on and one class off is the whole mechanism, there is
   nothing for a lost timer to leave behind, and js/samurai.js needs no teardown
   path for it. It inherits .sam's drop-shadows, which is what keeps 12px of grey
   text legible over body copy.

   WHERE it goes was measured off a 3x capture rather than derived, because the
   mirror makes the sheet's own columns misleading and because the sashimono is in
   the way. Inside the 256px box: his helmet occupies x125..148 y117..127, and the
   pole and banner are up and to his right at x143..180 y72..100 — he faces left,
   so the banner is the thing behind him. 92/100 puts the sigh just off his face,
   clear of the helmet by 7px, and the drift (-12, -24) carries it further up-left
   and away from the banner rather than across it. Anything higher — the 40px this
   started at — floats in empty space above the flag and reads as page furniture
   instead of as something coming out of him. */
.sam.is-doze.p-idle .sam__cel { animation-duration: 9600ms; }
.sam.is-doze::after {
  content: 'すー';
  position: absolute;
  left: 92px;
  top: 100px;
  font: 500 .78rem/1 var(--f-jp);
  letter-spacing: .1em;
  color: var(--txt-3);
  animation: sam-doze 3200ms var(--ease) infinite;
}
@keyframes sam-doze {
  0%   { opacity: 0; transform: translate3d(  0,   6px, 0); }
  28%  { opacity: .8; }
  100% { opacity: 0; transform: translate3d(-12px, -24px, 0); }
}

/* ── straight across (`sl`) ────────────────────────────────────
   The ls typo, answered with the man who is already standing there. He runs the
   length of the window and off the far side, and comes back to standing.

   --sam-cross-x is in CELL px, like --sam-run-x above and for the same reason:
   the rig lives inside .sam__zoom, so anything set here is doubled on screen.
   js/samurai.js halves the device distance before setting it — the one piece of
   arithmetic that has to happen in JS, because the distance depends on the live
   width of the card and CSS cannot ask for that.

   Negative, because the rig is above .sam__flip and therefore in unmirrored
   screen space: negative x is leftward, which is the direction he faces. Linear,
   because a run cycle over a non-constant ground speed skates. `both`, so he is
   held off the far edge for the frame between the animation ending and the class
   coming off — without it he teleports back to the corner one frame early. */
.sam.is-cross .sam__rig { animation: sam-cross var(--sam-cross-dur, 1600ms) linear both; }
@keyframes sam-cross {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(calc(var(--sam-cross-x, 0px) * -1), 0, 0); }
}

/* ── the path he crosses on (`sl`) ─────────────────────────────
   A 参道 — the stone approach to a shrine — laid across the viewport ahead of
   his feet, with 鳥居 to run through.

   TIMING IS NO LONGER ONE NUMBER. He is past in about 640ms at 1440px wide — see
   DASH in js/samurai.js — and the path outlasts him by 620ms before it fades,
   because a 参道 laid, run down and gone inside two thirds of a second is not
   something anyone gets to look at. Roughly 1.7s end to end, of which he is
   present for the first third.

   WHY THE PATH FORCED THE RUN TO BE REBUILT. `sl` used to translate the REAL
   sprite leftward inside .termwin and then drop him back on his post the instant
   the animation ended — a hard teleport, and the reason it never looked like
   anything. A path across the whole page cannot be a child of the window, so the
   crossing now works the way the spar below does: a throwaway sprite in a fixed
   overlay, the real .sam hidden for the length of it, and afterwards he runs
   back onto the roof with the same is-runin the window's own entrance uses. The
   teleport is gone by construction rather than by being animated around.

   ONE OVERLAY FOR BOTH, so the ground and the man cannot disagree about where
   the ground is: .sam-road__at is placed at the real sprite's measured foot line
   and the runner is placed from the same rect. DOM order does the stacking —
   haze, band, gates, then him last — so he passes in front of the stones and
   through the gates rather than behind them.

   THE VERMILION IS NOT A COINCIDENCE. --red is #ff3b53 and :root already
   comments it as 朱; 朱 is the colour torii are painted. The accent this site was
   built on was the right paint before there was anything to paint.

   His foot line is wherever the terminal roof is, not "ground level" for the
   page, and that is on purpose: the path is conjured for him, not page
   furniture. Pinning it to the bottom of the viewport instead would need him to
   descend to it first, which is a second animation to buy nothing.

   Z-INDEX 95, one BELOW .termwin — the opposite of .sam-spar, and the reason is
   the whole reason this reads as a path rather than as a bar across the UI. His
   foot line IS the window's roof, so a band hanging 38px below it lands exactly
   on the card's title bar. Above the window it painted a stone strip over the
   chrome; below it, the card occludes the band where they cross and the path
   simply passes behind the window. He is above the page and behind the terminal,
   which is also the truthful order: the path and the man are scenery, the window
   is the interface. .sam itself sits entirely above the card at `bottom: 100%`,
   so nothing occludes the runner at the handoff. */
.sam-road {
  position: fixed;
  inset: 0;
  z-index: 95;
  /* He exits past x=0 and must not raise a bar. That is now the ONLY thing this
     clip is for, and the paragraph that used to be here is worth keeping as a
     warning: it claimed the runner was "held off the left edge by sam-cross's
     `both` and clipped away here, so there is no frame with two of him visible",
     and named this clip as load-bearing for that. Both halves were false. cross()
     was sending him only far enough to put his LEADING painted edge past x=0 —
     he faces left, so that is his front — which left 84px of his 256px box inside
     this clip with sam-run still infinite on it. He ran on the spot at the left
     edge for over a second, and for 430ms of that the real sprite was visibly
     running back on at the same time. A comment asserting the invariant is not the
     same as something enforcing it. cross() now removes the runner at dur + 60, so
     the guarantee lives in the DOM. */
  overflow: hidden;
  pointer-events: none;
  /* Duration comes from JS so that the fade and the element's removal are one
     number rather than two that happen to agree. See OUT in cross(). */
  transition: opacity var(--sam-road-out, .42s) linear;
}
.sam-road.is-going { opacity: 0; }

/* ── 疾 ────────────────────────────────────────────────────────
   One character across the whole screen for the length of the crossing.

   WHY 疾 AND NOT SOMETHING LOUDER. `sl` is the locomotive gag — you type the
   train, something tears past — and 疾 is the character in 疾走, running at full
   speed, and in 疾風, a gale. Thirteen strokes with 矢, an arrow, sitting inside
   it: it is the right shape as well as the right word, which matters when it is
   going to be four hundred pixels tall.

   The caption is 「疾きこと風の如く」 and it is not decoration either. It is the
   first line of 風林火山 — 疾如風、徐如林、侵掠如火、不動如山 — Sun Tzu by way of
   Takeda Shingen, who had it on his battle standard. "Swift as the wind." The
   sprite is carrying a banner on his back in every frame of this animation. It
   also disambiguates the glyph: 疾 alone can be read as illness (疾病), and the
   compound standing next to it settles which 疾 this is.

   FIRST CHILD OF .sam-road, WHICH IS THE WHOLE LAYOUT DECISION. There is no
   z-index anywhere inside this overlay — DOM order is the stacking, exactly as it
   is for the sprite's own ghosts — so being first is what puts the glyph behind
   the haze, the stones, the gates and the man. Give it a z-index instead and it
   either sits over his head or needs a second number to stop it, and the overlay
   grows a stacking order that has to be maintained.

   It is a BACKDROP, so it is drawn as one: 6% white fill with a 2px vermilion
   stroke doing most of the reading. Body copy runs underneath it unharmed, which
   it must — the page is still the page. The stroke rather than a heavier fill is
   what buys legibility at that alpha; a 6% solid 疾 is a smudge, a 6% solid 疾
   with an outline is a character.

   No fade of its own at the end. .sam-road's opacity transition takes the glyph
   out with the stones and the gates, because they are one event and should leave
   as one. */
.sam-road__kanji {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.sam-road__kanji em {
  display: block;
  font-family: var(--f-jp);
  font-style: normal;
  font-weight: 700;
  /* Both axes, so it is bounded by whichever runs out first: at 1440x900 the
     height governs and at a tall narrow window the width does. min() rather than
     a vmin so the two limits can be tuned apart — 74/52 is the pair where the
     glyph is the biggest it can be without any stroke leaving the viewport. */
  font-size: min(74vh, 52vw);
  line-height: .84;
  color: rgba(226, 240, 255, .06);
  -webkit-text-stroke: 2px rgba(255, 59, 83, .13);
  text-shadow: 0 0 90px rgba(255, 59, 83, .1);
  /* Written top to bottom, which is the direction it would be written in. A
     right-to-left reveal would have matched the path being laid, and it was tried
     and it is worse: a kanji uncovered from the wrong end reads as a wipe over a
     picture of a character rather than as the character being written. */
  animation: sam-kanji-in var(--sam-kanji-dur, 420ms) var(--ease-io) both;
}
/* 縦書き down the right-hand side, because that is where a caption goes on a
   poster and because horizontal text under the glyph would land in the middle of
   the body copy. Held well above the terminal card: .termwin is z-index 96 and
   would occlude it anyway, but a caption that disappears behind a window is still
   a caption someone laid out badly. */
.sam-road__kanji span {
  position: absolute;
  top: 20%;
  right: clamp(12px, 3.4vw, 56px);
  writing-mode: vertical-rl;
  font-family: var(--f-jp);
  font-weight: 500;
  /* Not --t-sm. At 14px in .42 red on a black overlay this measured 24x153 and
     read as a scrollbar artefact rather than as an inscription — the one piece of
     the egg that actually SAYS something, and it was the least legible thing in
     the frame. The glow is what lets the alpha stay low enough to keep the 疾
     behind it the subject: the strokes get their contrast from the halo rather
     than from being opaque. */
  font-size: clamp(15px, 1.35vw, 19px);
  letter-spacing: .34em;
  color: rgba(255, 89, 108, .6);
  text-shadow: 0 0 14px rgba(255, 59, 83, .34);
  animation: sam-kanji-in var(--sam-kanji-dur, 420ms) var(--ease-io) both;
  /* Behind the glyph in time as well as in space: the character lands, then it is
     named. Both share one duration from JS so the pair scales with the crossing. */
  animation-delay: calc(var(--sam-kanji-dur, 420ms) * .55);
}
@keyframes sam-kanji-in {
  from { opacity: 0; clip-path: inset(0 0 100% 0); transform: scale(1.06); }
  30%  { opacity: 1; }
  to   { opacity: 1; clip-path: inset(0 0 0 0); transform: scale(1); }
}

/* Zero-height, so everything on the path is positioned against the one line
   that matters and the band is the only thing with a thickness. */
.sam-road__at {
  position: absolute;
  left: 0;
  right: 0;
  height: 0;
}

/* ── the bed the stones are set into ──────────────────────────
   This used to BE the path: one element, a repeating gradient faking the joints,
   wiped in with a clip-path. Filmed close up (.preview-tools/road-build.js, the
   `before` set) that reads as a grey ruler, and for a reason no amount of colour
   tuning could fix — a repeating gradient repeats. Thirty-one identical stones at
   identical 46px intervals with identical tone is a measuring device, not a
   surface, and nothing about it can be under construction because a gradient
   cannot arrive.

   So the stones left, and what stayed behind is the ground UNDER them: the dark
   sub-base, wiped right to left ahead of his feet so there is something for a
   flagstone to land on. Darker than any stone, deliberately — the joints between
   them are this showing through, and a joint has to be the darkest thing in the
   drawing or the surface reads as tiles laid on a floor rather than as stone set
   into earth.

   STILL A CLIP-PATH WIPE, and still for the original reason: the width is what
   the gradient is sized against, so animating the width re-resolves it every
   frame where a clip only hides what is already painted. The stones are what had
   to stop being a gradient. This never did.

   40px rather than 38 — two more only, and they go on the bottom fade, because
   the stones now stand 26px proud of the line and the shadow they sit in needs
   somewhere to end. */
.sam-road__band {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 40px;
  clip-path: inset(0 0 0 100%);
  animation: sam-road-lay var(--sam-road-dur, 1200ms) linear both;
  background: linear-gradient(to bottom,
    #090c12 0 64%, rgba(5, 6, 10, .58) 64% 88%, rgba(5, 6, 10, 0) 100%);
}

/* Warmer at the end he started from — and this is all that is left of the 2px
   bright rule that used to run the whole width here. Two things replaced it and
   both are better because both MOVE. Every stone carries its own lit top edge
   now, so the surface line is made of stones instead of drawn across them; and
   the accent is the build front (below), a light that travels with the
   construction — which is exactly what the comment that used to sit here said a
   static gradient could not do, written as an apology for a red dash parked at
   the right edge of the page for the rest of the run.

   What is left is the residue: a wide soft warmth where he pushed off, with no
   edge of its own to be mistaken for a UI element. */
.sam-road__band::before {
  content: '';
  position: absolute;
  right: 0;
  top: -22px;
  width: 32%;
  height: 52px;
  background: linear-gradient(to left,
    rgba(255, 88, 74, .17), rgba(255, 88, 74, .05) 46%, rgba(255, 88, 74, 0));
}

@keyframes sam-road-lay {
  from { clip-path: inset(0 0 0 100%); }
  to   { clip-path: inset(0 0 0 0); }
}

/* ── 石畳 — the course, one element per flagstone ──────────────
   THE WHOLE RAKE OF THE PATH IS ON THE CONTAINER, not on the stones, and that is
   the only reason the stones can animate at all. 5deg of skew turns every
   vertical joint into a lean, which is the depth cue the old band bought with a
   96deg gradient; putting it on each stone instead would mean every stone's
   transform carried both the rake and the animation, and the animation would win.
   One skew
   here, thirty-one clean transforms below.

   Left/right rather than a width, so it spans the overlay however wide that is,
   and 26px tall: that is the thickness of the stone, seen nearly edge-on, and it
   hangs BELOW his foot line rather than above it. His feet meet the top edge —
   y=0 is the ground plane and everything under it is the front face of the
   course. Get that backwards and he walks on air. */
.sam-road__course {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 26px;
  transform: skewX(-5deg);
}

/* Each stone: a top face lit from above, a front face turning away below it, and
   a shadow cast into the joint on its left. --n, --j, --d and its width all come
   from js/samurai.js, which walks the viewport right to left dealing them out.

   --n IS ONE NUMBER DOING THREE JOBS and that is the point of it. Tone, edge
   brightness and — over in the JS — how deep the stone sits are all read off the
   same 0..1, so a stone that has settled into the bed is also the one catching
   less light. Three independent randoms would let a sunken stone come out the
   brightest on the path, which is the specific way procedural texture looks fake.

   hsl() with calc() rather than a colour per stone written out in JS, so the
   palette stays in the stylesheet where the rest of the palette is. Every var()
   here carries a fallback, and they are load-bearing: `calc(21% + * 7%)` is not a
   syntax error CSS recovers from, it throws the whole background away and leaves
   an invisible stone. */
.sam-road__stone {
  position: absolute;
  left: 0;
  top: calc(var(--j, 0) * 1px);
  width: 44px;
  height: 26px;
  /* Origin at the TOP because that is the ground plane: the squash on landing has
     to compress the stone's visible thickness downward, away from the line his
     feet are on. Origin at the bottom instead lifts the tread off the line for a
     few frames every time a stone seats, i.e. it puts a ripple through the one
     edge that must not move. */
  transform-origin: 50% 0;
  animation: sam-stone-set var(--sam-stone-dur, 160ms) linear both;
  animation-delay: var(--d, 0ms);
  /* FOUR STOPS AND THE LAST ONE IS TRANSPARENT, which is not a flourish. The first
     tuning of this ran the tread from 20% to 28% lightness and ended the front face
     on a solid colour, and filmed on the page that is a row of pale concrete blocks
     with a hard bottom edge — the brightest thing in the frame after the sprite
     himself, and a wall rather than a path. The band this replaced sat at #1b1f29,
     which is 11% lightness, and it was right about that even while it was wrong
     about everything else.
     So: a dark slate tread, a front face darker again, and the bottom two pixels
     given away to the bed so the course has no edge of its own down there to be
     read as the top of a wall. */
  background:
    /* Weathering. One light patch and one dark patch, put in DIFFERENT places by
       --n — the light one at --n across, the dark one at its mirror — because a
       stone cut from a gradient with no mottling on it is a tile, and thirty-odd
       tiles from the same gradient are thirty-odd copies of one tile. At 3x zoom
       the version without these reads as bevelled glass panels. Two radials per
       stone is what buys "this piece of stone and not that one". */
    radial-gradient(118% 62% at calc(var(--n, .5) * 100%) 16%,
      rgba(226, 240, 255, .05), rgba(226, 240, 255, 0) 62%),
    radial-gradient(88% 54% at calc(100% - var(--n, .5) * 100%) 76%,
      rgba(0, 0, 0, .3), rgba(0, 0, 0, 0) 64%),
    /* And the faces. THE STOPS DELIBERATELY DO NOT MEET: 22%→31% and 41%→49% are
       gaps, so CSS ramps between the faces instead of butting them, and the ramp is
       the chamfer where one face turns into the next. Written as `0 22%, 22% 41%`
       it is three hard bands, which is a drawing of a bevel rather than an edge. */
    linear-gradient(to bottom,
      /* the tread — the only face with sky on it */
      hsl(213 14% calc(13% + var(--n, .5) * 7%)) 0 22%,
      /* the turn of the edge */
      hsl(216 18% calc(8% + var(--n, .5) * 4%)) 31% 41%,
      /* the front face, in its own shadow */
      hsl(220 26% calc(4.5% + var(--n, .5) * 2%)) 49% 84%,
      rgba(5, 6, 10, 0) 100%);
}
/* The lit edge — one pixel, and thirty-odd of them are the path's surface line.
   Made of stones, so it breaks at every joint and steps up and down with --j,
   which is the entire difference between a stone path and a drawn rule. */
.sam-road__stone::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 1px;
  /* Cool, because the light on this page is cool, but not BLUE: at 32% saturation
     thirty-odd of these in a row read as a strip of LED and the eye goes to them
     instead of to the man. 18% is the same light, on stone. */
  background: hsl(210 18% calc(38% + var(--n, .5) * 22%));
  opacity: .55;
}
/* Shadow cast into the joint to its left. Outside the box on purpose: a joint
   belongs to the gap, not to either stone, and drawing it inside would eat a
   pixel of tread off every stone in the course. */
.sam-road__stone::after {
  content: '';
  position: absolute;
  left: -3px;
  top: 0;
  bottom: -2px;
  width: 3px;
  background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, .7));
}

/* Dust, on roughly one stone in three — js/samurai.js decides which, and gives
   each one a --px so the puffs do not all drift the same way. Every stone
   throwing dust is a fog bank; every third is an event happening in several
   places at once, which is what construction looks like.

   Timed off --sam-stone-dur rather than given its own delay, so it fires at the
   instant the stone seats however long the seating takes. The 55% is where the
   impact is in sam-stone-set below; the two numbers are the same moment and are
   written as such. */
.sam-road__stone b {
  position: absolute;
  left: 50%;
  top: -4px;
  width: 56px;
  height: 24px;
  margin-left: -28px;
  opacity: 0;
  animation: sam-dust-puff var(--sam-dust-dur, 420ms) ease-out both;
  animation-delay: calc(var(--d, 0ms) + var(--sam-stone-dur, 160ms) * .55);
  background: radial-gradient(46% 52% at 50% 72%,
    rgba(206, 220, 244, .26), rgba(150, 168, 196, .11) 48%, rgba(150, 168, 196, 0) 74%);
}

/* Rising, not dropping. Both were built and the difference is not subtle: stones
   falling in from above spend their whole flight in the air ABOVE his foot line,
   which is where the 鳥居 are, so a cascade of them reads as rubble coming down
   around the gates. Rising up out of the bed keeps every moving pixel below the
   line, in the darkest part of the haze, and it is the truthful fiction anyway —
   this path is conjured for him, not delivered.

   THE FILTER IS WHY YOU CAN SEE ANY OF THAT HAPPEN. Without it the cascade was
   invisible, and the reason is worth writing down because the geometry was
   already right: nine stones ARE in flight at any moment, but a stone rising out
   of a black bed is a dark slate sliver at partial opacity against a darker
   background, so the front looked like a hard cut between no path and path. What
   was missing was not motion, it was contrast — and the honest place to get it is
   the build front, which is a light. A stone lit by the thing that is making it,
   cooling as it settles out of the front's reach, is both the legible drawing and
   the only physically coherent one. brightness() is composited in Chromium, so
   thirty-odd of these cost about what the transforms already cost.

   2.1 is a ceiling, not a taste. brightness() multiplies the whole subtree, so it
   scales the joint shadow and the dark bottom face along with the lit top — past
   about 2.2 the tonal spread between neighbouring stones collapses and the eight
   in flight at any moment merge into one bright grey tube, which is a chrome pipe
   being extruded, not a road being laid. Filmed at 2.9 to find that out.

   The -3px at 58% is a stone coming up proud of the line and settling back into
   it. It is three pixels for a fifth of a second and it is the difference between
   a stone that arrives and a stone that appears. */
@keyframes sam-stone-set {
  0% {
    animation-timing-function: cubic-bezier(.16, .6, .3, 1);
    opacity: 0;
    transform: translate3d(0, 20px, 0) scaleY(.5);
    filter: brightness(2.1);
  }
  22% { opacity: 1; }
  58% { transform: translate3d(0, -3px, 0) scaleY(1.1); filter: brightness(1.35); }
  78% { transform: translate3d(0, 1px, 0) scaleY(.96); }
  100% { opacity: 1; transform: translate3d(0, 0, 0) scaleY(1); filter: brightness(1); }
}

@keyframes sam-dust-puff {
  0%   { opacity: 0; transform: translate3d(0, 4px, 0) scale(.26); }
  20%  { opacity: 1; }
  100% { opacity: 0; transform: translate3d(var(--px, -6px), -15px, 0) scale(1.55); }
}

/* ── the build front ──────────────────────────────────────────
   The light the construction happens at, and the piece the `before` film was most
   obviously missing: a clip-path wipe has a leading edge, but a leading edge is a
   cut, and a cut across a dark page is invisible. Nothing marked where the path
   was being made, so the path was not being made — it had simply already
   happened, slightly to the right.

   It travels the overlay's own measured width in --sam-road-dur, the same span in
   the same time as the wipe and the same time the last stone is dealt, so the
   three cannot drift apart: js/samurai.js hands all of them one --sam-road-span
   and one --sam-road-dur. That is why the span is measured off the appended
   element rather than written as 100vw — since `scrollbar-gutter: stable` went on
   <html>, 100vw and the width of a fixed overlay are not the same number, and a
   front that finishes 10px short of the last stone is a front that visibly
   detaches from its own work.

   NO mix-blend-mode. .sam-road has a z-index, which isolates blending to the
   overlay's own contents, so `screen` here would blend against the haze and the
   stones and against nothing at all everywhere else — an effect that changes
   halfway across the page for reasons no one could see. Bright colours at honest
   alpha instead. */
.sam-road__front {
  position: absolute;
  left: var(--sam-road-span, 100vw);
  top: -36px;
  width: 2px;
  height: 58px;
  animation: sam-road-front var(--sam-road-dur, 1200ms) linear both;
  background: linear-gradient(to bottom,
    rgba(255, 236, 225, 0),
    rgba(255, 236, 225, .8) 60%,
    rgba(255, 122, 106, .68));
  box-shadow: 0 0 18px 4px rgba(255, 112, 96, .24);
}
/* The halo, which is most of what you actually see of it. */
.sam-road__front i {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 230px;
  height: 140px;
  margin: -70px 0 0 -115px;
  background: radial-gradient(50% 50% at 50% 50%,
    rgba(255, 142, 114, .24), rgba(255, 96, 88, .1) 44%, rgba(255, 96, 88, 0) 72%);
}
/* And light pooling forward onto ground that has no stones on it yet, which is
   the detail that makes the front read as the CAUSE of the path rather than as a
   decoration travelling along it.

   A radial rather than the linear rule it started as, and the reason is the frame
   it was caught in: 190px of 7px-tall horizontal gradient sitting on a path line
   with no stones under it yet reads as a stray underline in the body copy. Light
   spilled on the ground has no edges; an ellipse anchored at the front says that
   and a rectangle cannot. */
.sam-road__front b {
  position: absolute;
  right: 100%;
  top: 32px;
  width: 205px;
  height: 26px;
  background: radial-gradient(62% 52% at 100% 50%,
    rgba(255, 150, 122, .26), rgba(255, 150, 122, .07) 52%, rgba(255, 150, 122, 0) 78%);
}
@keyframes sam-road-front {
  0%   { opacity: 0; transform: translate3d(0, 0, 0); }
  7%   { opacity: 1; }
  90%  { opacity: 1; }
  100% { opacity: 0; transform: translate3d(calc(var(--sam-road-span, 100vw) * -1), 0, 0); }
}

/* ── the sheen ────────────────────────────────────────────────
   For the 620ms after he is gone. The path was asked to outlast him so there
   would be time to look at it, and in the `before` film what you look at is a
   still image for two thirds of a second — which reads as the animation having
   ended early rather than as a held beat. One slow pass of light down the finished
   course, in the same direction the stones were laid, and the hold becomes the
   path cooling rather than the page waiting.

   Delayed by exactly --sam-road-dur, so it starts on the frame the last stone is
   dealt and never travels over unlaid ground. Clipped to 12px, because a
   highlight on the front face of the course would be light coming from below. */
.sam-road__sheen {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 12px;
  overflow: hidden;
}
.sam-road__sheen::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 100%;
  width: 42%;
  animation: sam-sheen var(--sam-sheen-dur, 900ms) linear both;
  animation-delay: var(--sam-road-dur, 1200ms);
  background: linear-gradient(to left,
    rgba(226, 240, 255, 0), rgba(226, 240, 255, .1) 40%,
    rgba(255, 208, 190, .2) 58%, rgba(226, 240, 255, 0));
}
/* -1.42, not -(span + 42vw): the highlight is 42% of the span wide, so the
   distance that clears it off the far edge is 1.42 spans. Written as one multiple
   of one number so the width above and the travel here cannot disagree — mixing
   vw into this was a bug waiting for the first viewport with a scrollbar. */
@keyframes sam-sheen {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(calc(var(--sam-road-span, 100vw) * -1.42), 0, 0); }
}

/* ── 鳥居 ──────────────────────────────────────────────────────
   HE HAS TO FIT UNDER IT, which the first version of this got backwards. A torii
   is monumental — the whole gesture of one is that you pass beneath it — and at
   96px tall it stood chest-high on a sprite whose painted pixels reach 186px
   above his feet. He towered over the gate and stepped past it rather than
   through it, which is the one thing a gate must not let you do.

   So every dimension is a fraction of --h and --h is 210px: taller than his 186,
   with the 貫 above his head. The proportions are a 明神 torii's — pillars 5% of
   the height, the 笠木 overhanging to the full width, the 貫 two tenths of the way
   down — and the clear opening between the pillars works out at .7 x 210 = 147px
   against his 110px of painted width. He runs through the middle of it with room
   either side.

   Ten boxes: two pillars off the container's own pseudo-elements, the 笠木 and
   島木 (the paired top beams that make it a 明神 rather than a plank on two posts)
   plus the 額束 strut off the <i>, the 貫 off the <b>, two stone plinths off the
   <u> and two kicks of dust off the <s>. `left` is the gate's CENTRE — the
   negative margin does that — because js/samurai.js knows where he will be, not
   where a box edge should go.

   The pillars lean 1.2deg inward. That is 転び, and it is the cheapest detail
   here by a wide margin: without it this is two rectangles and a lintel, and
   with it it is a torii.

   AND NOT ONE OF THEM IS A FLAT FILL ANY MORE. Every box was `background:
   currentColor` — filmed close up that is three vermilion rectangles and a
   lintel, which is a diagram of a torii. Painted wood is round: it has a lit
   side, a core, and a turn away into shadow, and at 10px of pillar width four
   gradient stops are enough to say so. The weathering over the top is two pixels
   on, five off, at 10% black — invisible as texture, but it stops the posts
   reading as vector art, which is the whole of what it is for.

   AND NOT IN --red. At the full accent on full opacity three of these were the
   loudest thing on the page by a distance — they stopped being scenery he runs
   through and became three enormous UI chevrons with body copy showing between
   the pillars. Painted wood at night is a darker, duller vermilion than a
   hyperlink is, so the colour is knocked back and the rise settles at .62 rather
   than at 1. He stays the brightest thing in his own animation, which is the
   whole point of it. */
.sam-road__torii {
  --h: 210px;
  position: absolute;
  bottom: 0;
  width: var(--h);
  height: var(--h);
  margin-left: calc(var(--h) / -2);
  /* #c22d42 is the mid tone every gradient below is built around and it used to be
     `color`, read by six `background: currentColor` declarations. It is not
     `color` any more because nothing reads currentColor here now — each timber
     needs its own three stops — and a `color` on an element with no text in it is
     a declaration that looks live and is not. Kept as a comment instead, since the
     number itself is still the answer to "what colour is this gate". */
  opacity: 0;
  /* Duration from JS, because the rise has to keep its proportion to a crossing
     whose length changed by 5x. cross() divides 420 by DASH and passes the result
     back in, and computes each gate's --d lead from that same number, so "already
     standing there before he reaches it" survives the dash instead of collapsing
     into three gates that all go up in the first frame. Fallback matches the
     number cross() divides, so a JS-less page still gets the original rise. */
  animation: sam-road-rise var(--sam-road-rise, 420ms) var(--ease) both;
  animation-delay: var(--d, 0ms);
  /* The foot of the gate, because that is what it is being raised FROM. See the
     rise keyframes below for why this line matters more than it looks like. */
  transform-origin: 50% 100%;
}
.sam-road__torii::before,
.sam-road__torii::after {
  content: '';
  position: absolute;
  top: calc(var(--h) * .055);
  bottom: 0;
  width: calc(var(--h) * .05);
  transform-origin: 50% 100%;
  background:
    /* Grain DOWN the post, not across it. The first pass of this ran the repeat
       `to bottom` — rings every 7px — and at 3x zoom a 10px-wide post with rings on
       it is bamboo, or corrugated pipe. Timber grain runs the length of the timber,
       and one pixel in four down a painted post is weathering you can feel without
       being able to point at. */
    repeating-linear-gradient(to right,
      rgba(0, 0, 0, .08) 0 1px, rgba(0, 0, 0, 0) 1px 4px),
    linear-gradient(to right,
      #7c1a27 0 13%, #b62d41 34%, #93222f 63%, #58101a 100%);
}
.sam-road__torii::before { left: calc(var(--h) * .1);  transform: rotate(1.2deg); }
.sam-road__torii::after  { right: calc(var(--h) * .1); transform: rotate(-1.2deg); }

/* 亀腹 — the stone the post stands on, and the reason the gates used to look like
   they had been dropped onto the page rather than built on it. A torii does not
   meet the ground: it meets a plinth, and without one the pillar simply stops,
   which is what every rectangle standing on a line looks like.

   Stone-coloured rather than vermilion, from the same palette as the flagstones,
   because it is the same material — that shared colour is what ties the gates to
   the path instead of leaving them floating above it. Splayed by 12% either side
   so it reads as bearing weight. Positioned off --h like everything else: the
   pillar's centre is .125h in, the plinth is .092h wide, so it starts .079h in and
   the two arithmetic facts cannot drift apart. */
.sam-road__torii u {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: calc(var(--h) * .045);
}
.sam-road__torii u::before,
.sam-road__torii u::after {
  content: '';
  position: absolute;
  bottom: 0;
  width: calc(var(--h) * .092);
  height: 100%;
  clip-path: polygon(13% 0, 87% 0, 100% 100%, 0 100%);
  background: linear-gradient(to bottom, hsl(216 13% 27%), hsl(220 18% 12%));
}
.sam-road__torii u::before { left: calc(var(--h) * .079); }
.sam-road__torii u::after  { right: calc(var(--h) * .079); }

/* Dust at the foot of each post as the gate seats. Same keyframes the flagstones
   use, because it is the same event at a different scale — and reusing the name
   is not only tidiness: .preview-tools/road-build.js drives this animation by
   name, so a second set of keyframes doing the same job is a second thing that
   can fail to be registered there.

   Delayed to 62% of the rise, which is where the overshoot in sam-road-rise
   bottoms out — the gate is heaviest at that instant and that is when the ground
   should answer. Wider and slower than a flagstone's puff, since something two
   hundred pixels tall just landed on it. */
.sam-road__torii s {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 0;
}
.sam-road__torii s::before,
.sam-road__torii s::after {
  content: '';
  position: absolute;
  bottom: calc(var(--h) * -.03);
  width: calc(var(--h) * .34);
  height: calc(var(--h) * .15);
  opacity: 0;
  animation: sam-dust-puff calc(var(--sam-road-rise, 420ms) * 4) ease-out both;
  animation-delay: calc(var(--d, 0ms) + var(--sam-road-rise, 420ms) * .62);
  /* Carrying more alpha than the flagstone puffs and not because it is bigger:
     these are inside .sam-road__torii, which settles at opacity .62, so whatever
     is written here arrives on screen multiplied by that. .4 lands at .25 — the
     same weight as a stone's dust, stated where the reader can check it. */
  background: radial-gradient(46% 52% at 50% 74%,
    rgba(212, 198, 188, .4), rgba(160, 148, 142, .16) 48%, rgba(160, 148, 142, 0) 74%);
}
.sam-road__torii s::before { left: calc(var(--h) * -.045); --px: -14px; }
.sam-road__torii s::after  { right: calc(var(--h) * -.045); --px: 14px; }

/* 笠木 — the top beam, overhanging both pillars, with its ends lifted. A real one
   curves; a clip-path with the mid-span dropped a couple of percent is the flat
   version of that curve and is indistinguishable at this size. */
.sam-road__torii i {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: calc(var(--h) * .055);
  clip-path: polygon(0 0, 9% 42%, 91% 42%, 100% 0, 100% 100%, 0 100%);
  /* Lit along the top and shaded underneath, which is the one place on the whole
     gate where the light direction is unambiguous: the sky is up. */
  background: linear-gradient(to bottom, #cf3d51 0 26%, #ab2739 54%, #74192a);
}
/* 島木 under it, slightly inset — the pair is the 明神 signature — and 額束, the
   short strut standing on the 貫 under the centre of the beams. The 島木 sits in
   the 笠木's shadow, so it is darker than either face of the beam above it. */
.sam-road__torii i::before {
  content: '';
  position: absolute;
  left: 4%;
  right: 4%;
  top: 100%;
  height: calc(var(--h) * .028);
  background: linear-gradient(to bottom, #8e2033, #631322);
}
.sam-road__torii i::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 100%;
  width: calc(var(--h) * .035);
  height: calc(var(--h) * .11);
  margin-left: calc(var(--h) * -.0175);
  background: linear-gradient(to right, #851d2d 0 22%, #ae2f42 52%, #6a1522);
}
/* 貫 — through the pillars rather than sat on top of them, three tenths down.
   Rounded the same way as the posts, since it is the same timber. */
.sam-road__torii b {
  position: absolute;
  left: calc(var(--h) * .06);
  right: calc(var(--h) * .06);
  top: calc(var(--h) * .2);
  height: calc(var(--h) * .035);
  background: linear-gradient(to bottom, #bb3346 0 24%, #9c2536 58%, #6b1523);
}

/* ── raised, not translated ───────────────────────────────────
   Each gate is already there by the time he reaches it — js/samurai.js sets --d
   from the same arithmetic the spar uses for the headings, minus a lead — and what
   changed here is what "arriving" looks like.

   IT USED TO BE A 16px SLIDE AND A FADE, i.e. a gate that was always that size
   and merely somewhere else a moment ago. With transform-origin at the foot and
   scaleY from .68, the same 84ms instead shows a torii coming up out of the ground
   to its full height. It costs one extra number and it is the difference between
   scenery being placed and scenery being built, which is the whole subject of
   this animation.

   The 1.035 at 72% is the overshoot the dust answers — see .sam-road__torii s,
   which fires at 62% precisely so the kick lands while the gate is still settling
   rather than after it has stopped. Two hundred pixels of timber does not come to
   rest silently and the ground is the only thing here that can say so.

   Settling at .62 rather than 1 is deliberate and old: at full opacity these stop
   being scenery he runs through and become three enormous UI chevrons. */
/* --o rather than a flat .62, and the fallback is the old number so a JS-less page
   is unchanged. Filmed, all three gates were 210x210 at opacity .62 — literally
   the same object three times in one frame, which is the loudest procedural tell
   in the shot and the one thing no amount of gradient work on a single gate can
   fix. cross() now hands each one its own --h and --o. A real 参道's gates differ
   because they were donated at different times by different people, so the
   variation is the truthful kind rather than perspective faked without a
   vanishing point. Note 55% carries the same var: hard-coding .7 there would make
   the faintest gate momentarily the brightest on its way up. */
@keyframes sam-road-rise {
  0%   { opacity: 0; transform: translate3d(0, 14px, 0) scaleY(.68); }
  55%  { opacity: calc(var(--o, .62) * 1.13); }
  72%  { transform: translate3d(0, 0, 0) scaleY(1.035); }
  100% { opacity: var(--o, .62); transform: translate3d(0, 0, 0) scaleY(1); }
}

/* Haze along the path, and it is doing more work than it looks like. Without it
   the stones and the gates sit on top of live body copy with nothing between
   them, and the whole thing reads as an overlay that has gone wrong rather than
   as a scene: you see paragraph text through the middle of a torii. A short
   gradient rising off the path line puts air behind them.

   Its own element with its own copy of the wipe, because the band's clip-path
   clips the band's children — anything extending above that 38px box is clipped
   away entirely, so this cannot be a pseudo-element of it. Identical duration and
   both appended in the same frame, so the two fronts travel together. */
.sam-road__haze {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 104px;
  clip-path: inset(0 0 0 100%);
  animation: sam-road-lay var(--sam-road-dur, 1200ms) linear both;
  background: linear-gradient(to top,
    rgba(5, 6, 10, .62), rgba(5, 6, 10, .28) 46%, rgba(5, 6, 10, 0));
}

/* The runner. Placed by measurement, so .sam's own corner anchoring has to be
   switched off — and it has to be switched off by something at least as specific
   as .sam itself, hence the descendant selector rather than a bare class. */
.sam-road .sam--road {
  right: auto;
  bottom: auto;
}

/* ── the smear he leaves ───────────────────────────────────────
   At 1.727 device px/ms he clears his own painted width in about eight display
   frames, and the eye supplies a streak whether or not one is drawn. Drawing it is
   the difference between reading as "fast" and reading as "went past", which is
   the thing that was asked for.

   THE FIRST ATTEMPT WAS 流線 — manga speed lines, a comb of 1px rules hung off
   .sam__rig behind him — and it was wrong in a way worth recording, because it
   looked perfectly reasonable as CSS. A rectangle of parallel lines travelling
   alongside a figure does not read as that figure moving fast. It reads as a
   graphic stuck to his back: it has its own edges, its own colour, and its own
   shape, so the brain files it as a second object rather than as more of the first
   one. .preview-tools/shots/sl-film-06.png is what that looked like.

   WHAT ACTUALLY WORKS IS AN AFTERIMAGE, and it costs almost nothing here because
   the runner is already a stack of five nested nodes that cross() writes by hand.
   One extra .sam__stage copy, showing the pose he held one sheet frame ago AT the
   place he was standing one sheet frame ago. Not a stylised trail — the actual
   previous frame of the actual animation, which is what a camera with a slow
   shutter would have caught.

   BOTH HALVES COME OUT OF THE SAME "one frame back", and this is the part that
   makes it correct rather than approximate:

     distance   one cycle carries him STRIDE = 76 CELL px (152 device, halved by
                the zoom this lives inside), over eight frames. One frame is
                9.5 cell px. Note what this does NOT depend on — the cycle length.
                STRIDE is fixed by the art, so 9.5 stays right at any speed and
                only the delay below has to follow DASH.
     pose       one frame of eight is an eighth of a cycle backwards, which as a
                delay is minus seven eighths of one: -0.875 x var(--sam-run-dur).
                Negative rather than +0.125 so it is already inside the cycle at
                t=0 — a positive delay leaves the ghost sitting on frame 0 with no
                animation applied at all until it elapses, which is a visibly wrong
                pose for the first stride.

   9.5 cell px is 19 device px, and 19 device px at 1.727 px/ms is 11ms, which is
   one frame of an 88ms eight-frame cycle. The two agree because they are the same
   measurement written twice, and .preview-tools/sl-dash.js checks it on the page
   rather than here in a comment.

   THE FRACTION IS FINE AND THE INTEGER WOULD NOT BE. 9.5 is a cell-px value inside
   scale(2), so it lands on 19 whole device pixels and the bitmap is never
   resampled — which matters on a sprite whose sashimono pole is one pixel wide.
   Rounding it to 10 here to look tidier is what would put it on a half pixel.

   ONE COPY, AND THIS WAS DECIDED BY LOOKING RATHER THAN BY ARGUING — the six
   candidates and the sheet that settled it are .preview-tools/ghost-ab.js. Two
   things came out of it, and both are about the sashimono, the tall banner on his
   back:

     spacing   at two frames apart (19 cell px, 38 device) the copies stop
               overlapping. The banner is a narrow near-vertical shape, so three of
               them 38px apart read as three men running in formation. One frame is
               the spacing at which the trailing edge goes soft instead.
     count     but going the other way and stacking three or four copies one frame
               apart is its own failure, and a less obvious one. The banner is
               ribbed; evenly spaced copies of a ribbed shape interfere into a
               lattice, and what you see is a picket fence rather than a blur. Two
               copies already hint at it. One does not, because there is nothing for
               a single echo to interfere WITH.

   So: one copy, and it can afford to be stronger than a member of a stack would be
   — .38 rather than the ~.2 that a three-deep ramp would have to end on. It has to
   survive being on screen for a third of a second while travelling at 1.7px/ms,
   which is a much dimmer thing than this still frame suggests.

   Only .sam--road.is-cross, so the real sprite on his post can never sprout copies
   of himself and the spar keeps its own language. */
.sam-road .sam--road.is-cross .sam__stage--ghost {
  /* --g is 1..GHOSTS, set per copy in cross(). GHOSTS is 1, so --g is always 1 —
     the arithmetic is left in terms of it anyway so that the count stays a single
     constant in one file, and raising it degrades to something sane rather than to
     a pile of identical copies. */
  transform: translate3d(calc(9.5px * var(--g)), 0, 0);
  opacity: calc(.38 / var(--g));
}
.sam-road .sam--road.is-cross .sam__stage--ghost .sam__cel {
  /* Same sheet, same cycle, wound back an eighth of a turn per copy.
     `animation-delay` survives here because `.sam.p-run .sam__cel`'s shorthand is a
     different declaration — the shorthand resets delay to 0, and this then
     overrides it. Put it in the shorthand instead and specificity order resets it
     right back. */
  animation-delay: calc(var(--sam-run-dur) * -0.875 * var(--g));
}

/* And the real one, stood down for the length of it. visibility rather than
   display, because display:none on an ancestor drops the .sam__hit out of hit
   testing AND would restart the breath loop from frame 0 on the way back;
   visibility does the first and not the second. */
.sam.is-away { visibility: hidden; }

/* ── the spar ──────────────────────────────────────────────────
   `spar` sends him across the whole viewport and splits every heading he passes.

   A SECOND SPRITE IN A FIXED OVERLAY, and never the real one. Everything above
   rests on .sam being a child of .termwin at `bottom: 100%` — that one line is
   what puts his feet on a folding roof with no measurement, no resize handler and
   no JS. Moving him out of that parent for the length of a joke, and then
   putting him back, would mean reproducing the contract by hand in the one place
   it is least worth reproducing. So js/samurai.js builds the same five nested
   nodes inside this overlay, every pose rule above applies to it unchanged
   because they are all keyed on .sam, and then it throws the whole thing away.

   The real .sam does not move, does not change class, and cannot be left in a
   strange state if this is interrupted halfway through.

   Descendant selector rather than a bare .sam--free, so the two overrides beat
   .sam's own right/bottom on specificity rather than on source order. overflow
   hidden because he exits leftward past x=0 and <body> is only overflow-x
   hidden — a fixed overlay that could extend the page would raise a scrollbar
   mid-animation, which is the one side effect this must not have.

   bottom: 34% puts his feet two thirds of the way down the viewport, which is
   where a heading band usually sits. z-index 97 is one above .termwin's 96: he
   is the thing in motion, so he passes in front of the window he came from. */
.sam-spar {
  position: fixed;
  inset: 0;
  z-index: 97;
  overflow: hidden;
  pointer-events: none;
}
.sam-spar .sam--free {
  right: -288px;      /* his own box, plus slack, off the right edge */
  bottom: 34%;
}

/* Each heading, as his blade reaches it: clipped to the upper-left side of a
   raked line, with a clone of it clipped to the lower-right and both nudged
   apart. Same 32deg family as .term__cut--v, because it is the same stroke.

   The clip-paths are STATIC and only the offsets animate. A clip-path that
   animates is a clip-path that can be interrupted mid-value and left there, and
   this one is on a live line of body text; a class that either applies whole or
   not at all is the version that cannot leave a heading half-visible. Which is
   also why the split is a clone rather than DOM surgery on the heading itself:
   see split() in js/samurai.js, which does the same thing to the terminal card
   for the same reason.

   Only .sec__title, not .sec__num. The promise was that headings split as he
   passes, and cleaving the numeral above each one as well doubles the clones for
   an effect nobody is looking at during a 4px nudge. */
.sec__title.is-cleaved {
  clip-path: polygon(0 0, 100% 0, 100% 55%, 0 100%);
  animation: sec-cleave-a 520ms var(--ease) both;
}
.sam-spar__half {
  position: absolute;
  margin: 0;
  clip-path: polygon(0 100%, 100% 55%, 100% 100%);
  animation: sec-cleave-b 520ms var(--ease) both;
}
@keyframes sec-cleave-a {
  0%   { transform: translate3d(0, 0, 0); }
  16%  { transform: translate3d(-4px, -4px, 0); }
  100% { transform: translate3d(0, 0, 0); }
}
@keyframes sec-cleave-b {
  0%   { transform: translate3d(0, 0, 0); }
  16%  { transform: translate3d(4px, 4px, 0); }
  100% { transform: translate3d(0, 0, 0); }
}

/* ── the cuts ──────────────────────────────────────────────────
   Both are absolutely positioned inside .term, so its existing overflow:hidden
   and border-radius clip them to the card — which is what makes a raked line
   read as a cut through a window rather than a stray diagonal on the page.
   .term gains `position: relative` for this; relative does not trap fixed
   descendants, and .term has none. */
.term { position: relative; }

.term__cut {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

/* Minimise: a horizontal line along the top border, growing right-to-left out
   of his blade. The whole width, not just his 73px of reach — a cut line longer
   than the blade is the convention, and the window then folds along it. */
.term__cut--h {
  left: 0;
  right: 0;
  top: 0;
  height: 2px;
  transform-origin: 100% 50%;
  transform: scaleX(0);
  background: linear-gradient(to left, #fff 0 6%, rgba(226, 240, 255, .95) 6% 26%,
                              var(--red) 26% 74%, transparent);
  box-shadow: 0 0 14px 1px rgba(255, 59, 83, .55);
}

/* Close: a kesa-giri. 32deg is measured, not chosen — Attack_2's blade travels
   48px across and 76px down between its overhead frame and its contact frame,
   and atan(48/76) is 32deg. Clockwise sends the bottom of the bar leftward,
   which is the direction the mirrored blade descends. */
.term__cut--v {
  top: 0;
  right: var(--sam-cut-x);
  width: 3px;
  height: 150%;
  transform-origin: 50% 0;
  transform: rotate(32deg) scaleY(0);
  background: linear-gradient(to bottom, #fff 0 4%, rgba(226, 240, 255, .9) 4% 20%,
                              var(--red) 20% 78%, transparent);
  box-shadow: 0 0 16px 1px rgba(255, 59, 83, .5);
}

/* One red pulse across the card as it dies. An overlay rather than a filter on
   .term, so nothing new becomes a containing block. */
.term__cut--wash { inset: 0; background: var(--red-soft); }

.termwin.is-cut-min   .term__cut--h    { animation: sam-slash-h 220ms var(--ease) forwards; }
.termwin.is-cut-close .term__cut--v    { animation: sam-slash-v 440ms var(--ease) forwards; }
.termwin.is-cut-close .term__cut--wash { animation: sam-wash   280ms var(--ease) forwards; }

@keyframes sam-slash-h {
  0%   { opacity: 1; transform: scaleX(0); }
  55%  { opacity: 1; transform: scaleX(1); }
  100% { opacity: 0; transform: scaleX(1); }
}
/* 440ms rather than 250ms, and the extra 190ms is all tail. The beam is a child
   of .term, so once the card is split each half carries its own copy of it
   clipped to its own cut edge — which means a beam that outlives the parting
   lights both raw edges as they come away, and one that dies at 250ms leaves
   them dull black at the exact moment they open. The 28%/40% stops reproduce the
   old 125ms draw and 175ms hold to the millisecond; only the fade is longer. It
   is out by 440ms and the halves keep falling until 860ms, which is the right way
   round: the beam is what opened them, not what carries them down. */
@keyframes sam-slash-v {
  0%   { opacity: 1;   transform: rotate(32deg) scaleY(0); }
  28%  { opacity: 1;   transform: rotate(32deg) scaleY(1); }
  40%  { opacity: .85; transform: rotate(32deg) scaleY(1); }
  100% { opacity: 0;   transform: rotate(32deg) scaleY(1); }
}
@keyframes sam-wash { 0% { opacity: 0; } 22% { opacity: 1; } 100% { opacity: 0; } }

/* ── the fold: one piece comes off ─────────────────────────────
   The fold cannot be two falling halves, and that is geometry rather than
   timidity. His blade lands on the ROOF line — .sam sits at `bottom: 100%` of
   .termwin, so his feet and .term__cut--h are the same 2px of screen — and the
   piece above a cut at the very top edge is the top edge. Two halves would need
   the cut moved down to the bar/body seam, which would put the line 38px below
   the steel that supposedly made it.

   So the fold severs the piece it actually can: the BODY comes off the bar
   along the line he cut, and the bar is what is left standing. That is still
   two pieces of window, one of which survives — which is exactly what minimise
   means, and why this reads better here than a shatter would.

   The transform is on .term__body rather than on its two children so the slab
   stays RIGID: shearing the scrollback and the prompt independently reads as
   text sliding around inside a window, not as a piece of the window leaving it.
   Origin at the top-right is where the cut began — attack_3's blade travels
   right-to-left, so the far end is the end that drops. The body genuinely goes
   to zero height underneath all this (see .termwin.is-min .term__body), so the
   collapse is real and .term's overflow does the clipping for free.

   The box-shadow is the cut edge, and it is what makes the severance visible at
   all. Without it the slab is the same colour as the card it is sliding off, so
   the only evidence it moved is the text — and .termwin.is-min .term__body has
   already faded that to nothing by 240ms. A red hairline along the slab's top,
   with a glow above it, draws the raw edge into the gap the slide opens up.

   Which is also why the slide is 180ms (see .term__body) and not the .34s of the
   collapse it rides: the slab has to finish travelling while it is still opaque
   enough to see. A blade's impulse should outrun the fold anyway — the piece is
   kicked off, and then the window closes over where it was.

   A value, an origin and a shadow, and deliberately no `transition`: the timing
   lives on .term__body's own rule, because a shorthand here outranks nothing but
   replaces everything.

   No separate rule for .term__screen / .term__form any more. They used to carry
   the shear and their own fade; the shear moved up to the slab so it would stay
   rigid, and the fade was always being done twice — .termwin.is-min .term__body
   fades the whole slab, these two just did it again a hair faster. */
.termwin.is-cut-min .term__body {
  transform: translate3d(-16px, 26px, 0) rotate(-2deg);
  transform-origin: 100% 0;
  box-shadow: 0 -1px 0 var(--red), 0 -12px 22px -6px var(--red-soft);
}

/* ── riding the fold down ──────────────────────────────────────
   The fold moves him, and it moves him a long way. .termwin is `position: fixed;
   bottom: var(--term-edge)`, so a card that loses height keeps its floor and
   drops its ROOF — and .sam is at `bottom: 100%` of that card. Traced against a
   real click, the roof falls 468px in the .34s the collapse takes, peaking near
   4700px/s. Whatever pose he is in, he is in it while being carried the height
   of the window straight down.

   So the poses after contact are chosen against that trace, not against a beat:

   contact ....... sam-a3 holds attack_3's f3 from 120ms. The follow-through gets
                   140ms of it, which is the pause a stroke needs to land.
   p-fall at 260   jump f4, held. This is the one frame in any sheet drawn as a
                   man in the air on the way DOWN, and the fold is the only place
                   on the site that needs one. It is not decoration: without it he
                   held a finished-slash pose through 327px of descent, which
                   reads as a sprite being dragged by its parent rather than a man
                   falling with the floor he just cut.
   p-land at 420   19px of roof travel left. f4 -> f5 puts his feet back on row
                   127 as the card stops, so the sheet's own return to the ground
                   IS the impact. Then f6, f1, f0 stand him up (see sam-land).
   done at 780     120ms upright before the breath resumes.

   The 260 is load-bearing and it is not a round number. f4 draws his feet at row
   117 where every other frame plants them at 127, so entering it lifts his feet
   20 device px off the border they were standing on (and his head 10, since the
   frame is also drawn more compact) — that lift is altitude, it is what makes the
   frame read as airborne, and it must NOT be cancelled with a rig transform. The lunge
   postmortem above is the whole argument: a compensating ramp holds a frame at
   fractional device px, .sam__zoom's scale(2) resamples it, and the pixel art
   stops being pixel art. The free fix is timing. At 260 the roof is already
   moving 15-50px per frame, so a 20px lift arrives inside motion that is larger
   than it is — and what it reads as is the floor leaving his feet, which is
   exactly what just happened to him. Hand it to him at rest and it is a pop;
   hand it to him mid-fall and it is the fall. */

/* ── the close: cut into two pieces ────────────────────────────
   A kesa-giri leaves two halves, so the close leaves two halves. There is no
   way to fake that with one box: a single element can be clipped to one side of
   the line or the other, never to both at once and never to two sides that then
   move apart. So js/samurai.js photographs the card — cloneNode on .term, ids
   stripped, inert, values copied across — overlays the copy on the original and
   clip-paths the two of them to opposite sides of the blade. From then on the
   original IS the upper-left piece and the copy IS the lower-right one, and
   this file only has to push them apart.

   The polygons are computed in JS because they need the card's live height:
   the line enters the top edge under his blade and rakes down-LEFT at 32deg
   (clockwise takes (0,1) to (-sin32, cos32)), so where it leaves depends on how
   tall the window is, and --term-h is a clamp on the viewport. Everything else
   about the cut is here.

   Note what the clone buys beyond the split itself. .term__cut--v is a child of
   .term, so the copy has a beam too, and `.termwin.is-cut-close .term__cut--v`
   matches it — the two halves of the beam are clipped to the two halves of the
   card and tear apart with them. The glowing line does not get halved or
   duplicated; it is one line that comes apart, for free.

   Timing. 860ms, and the number is a budget rather than a taste: the copy is
   inserted one frame after contact (~165ms) and samurai.js's finish() tears the
   whole thing down at 1140ms, so anything past ~975ms would still be falling
   when its scaffolding is removed. 860ms lands the last frame at ~1025ms and
   leaves 115ms — seven frames — of slack. Within that:
     0-13.3%   together, while sam-slash-v rakes the line down (it hits full
               length at 123ms — a card that opens before the line is drawn is
               a card that fell apart on its own)
     13.3-23%  the halves part ~11px along the cut's normal, (cos32, sin32) —
               perpendicular to the line, which is the only direction a blade
               can push them
     23-100%   they fall off the bottom of the screen and only then stop being
               drawn (see term-piece-a)

   Those first two stops are the same wall-clock milliseconds they have always
   been — 114ms and 198ms — because the parting is an impulse, and an impulse
   does not get slower because the fall after it got longer. Only the fall grew.

   transform-origin is set per piece to roughly its own centroid so the two
   rotations don't compose back into one rigid body tilting. */
.term--ghost {
  position: absolute;      /* .term is `relative`; this is later, so it wins */
  inset: 0;
  pointer-events: none;
}
/* The copy is a photograph, not a second terminal: no blinking caret, no
   re-entrant transitions, nothing that could tick out of step with the
   original for the half second both are on screen. The beams are deliberately
   NOT in this list — they are the one thing that must still animate. */
.term--ghost .term__bar,  .term--ghost .term__bar *,
.term--ghost .term__body, .term--ghost .term__body * {
  animation: none !important;
  transition: none !important;
  caret-color: transparent;
}

/* Holds the card visible for the whole fall, then snaps it out — by which point
   both halves are below the bottom edge of the screen, so the snap has nothing
   to show. A zero-duration transition with a delay is what does the holding: the
   property sits at its old value for the delay and then jumps, which is a hold
   with no keyframes and no class to remove on a timer.

   920ms, deliberately 60ms longer than the 860ms the halves animate for. They go
   over the edge at ~93% of their own animation — the last 7% is overshoot, and
   the parabola is at its fastest there, so matching the two numbers exactly left
   only about three frames between "gone" and "snapped". The extra 60ms is spent
   holding a window whose contents are already off the bottom of the screen at
   their fill value: nothing moves, nothing is visible, and a run of dropped
   frames can no longer put the snap in front of a piece that is still in shot.

   0,2,0 outranks the .termwin base rule without !important, and the is-open
   rule that outranks *it* cannot match — close() has already taken is-open off.
   transform is pinned to `none` so the whole card does not also slide 14px into
   its corner and fight the two pieces.

   pointer-events for the same reason a demolished building gets a fence. The box
   does not move — the transforms are on the two halves, and the rule above pins
   .termwin itself to `none` — so for a full second after the window is destroyed
   there is a 600x506 invisible corpse sitting in the corner, and .termwin does
   not otherwise declare the property, so it computes `auto`. Measured: a click at
   its centre during the fall lands on .termwin. With this line it reaches the
   section underneath, which is where it was aimed. (Not the launcher pill, which
   shares this z-index and wins on DOM order — it was never in danger.) */
.termwin.is-sliced {
  transform: none;
  pointer-events: none;
  transition: opacity 0s linear 920ms, transform 0s, width 0s,
              visibility 0s linear 920ms;
}
.termwin.is-sliced .term         { animation: term-piece-a 860ms both;
                                   transform-origin: 22% 78%; }
.termwin.is-sliced .term--ghost  { animation: term-piece-b 860ms both;
                                   transform-origin: 78% 22%; }

/* Neither half fades, and that is the point. They used to go translucent at 70%
   and be gone by 100% having fallen 112px and 152px — which is to say they
   dissolved in mid-air, inside the window's own footprint, while there was still
   screen below them to fall through. Now they hold opacity 1 for every frame
   they exist and simply leave: down past the bottom edge of the viewport, where
   being snapped out costs nothing to look at. .termwin is position:fixed, so
   nothing they do below the fold can extend the document or raise a scrollbar.

   How far "off the bottom" is depends on where the card is standing and how tall
   the viewport let it be, so samurai.js measures it on contact and hands it over
   as --sam-fall: the card's own distance to the bottom of the screen, plus room
   for the corner the rotation lifts, plus overshoot so the halves are already
   gone rather than arriving exactly as the window snaps. The px fallback is so
   this file still means something read on its own — split() always sets the
   property, and it sets it before either class lands.

   The fall is constant acceleration, not an impression of it.
   cubic-bezier(.333, 0, .667, .333) IS y = t²: x-controls at 1/3 and 2/3 make
   the curve's parameter advance linearly with time, and 0 and 1/3 are then
   exactly the y-controls whose cubic collapses to a square. The piece falls at a
   constant acceleration because that is what the curve computes.

   Cut in two at 61.5% for one reason: rotation shares a keyframe's timing
   function with translation, so on one parabolic segment a half would hang
   almost level through the first two thirds of its drop and then whip round at
   the end. 61.5% is the fall's exact halfway point in time, which on a parabola
   is a quarter of the way down — so putting a stop there lets the tumble advance
   on wall-clock while the drop keeps accelerating. cubic-bezier(.333, .222,
   .667, .556) is the remainder of the SAME parabola renormalised onto [0,1], so
   the join is continuous in position and in speed; the eye cannot find it.

   The lower-right half drifts further out and turns harder — it is the piece
   with nothing above it. */
@keyframes term-piece-a {                                   /* upper-left */
  0%, 13.3% { transform: translate3d(0, 0, 0) rotate(0deg);
              animation-timing-function: cubic-bezier(.05, .85, .25, 1); }
  23%       { transform: translate3d(-11px, -6.9px, 0) rotate(-1.2deg);
              animation-timing-function: cubic-bezier(.333, 0, .667, .333); }
  61.5%     { transform: translate3d(-37.5px, calc(var(--sam-fall, 700px) * .25), 0)
                         rotate(-7.5deg);
              animation-timing-function: cubic-bezier(.333, .222, .667, .556); }
  100%      { transform: translate3d(-64px, var(--sam-fall, 700px), 0) rotate(-14deg); }
}
@keyframes term-piece-b {                                   /* lower-right */
  0%, 13.3% { transform: translate3d(0, 0, 0) rotate(0deg);
              animation-timing-function: cubic-bezier(.05, .85, .25, 1); }
  23%       { transform: translate3d(11px, 6.9px, 0) rotate(1.2deg);
              animation-timing-function: cubic-bezier(.333, 0, .667, .333); }
  61.5%     { transform: translate3d(32.5px, calc(var(--sam-fall, 700px) * .25), 0)
                         rotate(7.5deg);
              animation-timing-function: cubic-bezier(.333, .222, .667, .556); }
  100%      { transform: translate3d(54px, var(--sam-fall, 700px), 0) rotate(14deg); }
}

/* ...and he goes out ahead of his own handiwork, on his feet.
   There used to be a rule here — `.termwin.is-sliced .sam`, an opacity ramp — and
   it existed because he is a child of .termwin, so without something he inherits
   the window's delayed 0s hop: full opacity for the whole 920ms fall and then out
   of existence in one frame, on the same millisecond as a window that is by then
   empty. Fading him was the fix for that. Leaving is a better one, and §10b's
   "and then he leaves" is the whole argument plus the geometry.

   Nothing here replaces it, and that is the point worth writing down: the exit is
   a translate on .sam__rig, so there is no opacity for finish() to have to
   restore and no transition anywhere near .sam that could run backwards and fade
   him IN when the window is reopened mid-fall. One less thing holding a value it
   has to give back. */

/* ── the pill waits its turn ────────────────────────────────────
   It takes the corner the window vacated, and it used to take it while the
   window was still in it. commit() adds is-sliced and then calls close() in one
   synchronous block, so sync() handed the pill `is-shown` on the contact frame
   and it was fully opaque 242ms later with the better part of a second of
   wreckage still to fall. Measured: the lower-right half was over this pill's
   own box until contact+841 on a full window and contact+615 on the folded bar,
   and both halves were still on screen at contact+911.

   On the peek bar it is worse than an overlap, which is where it got noticed:
   the folded bar is 240x38 in exactly this corner, so the pill materialised
   INSIDE the box being cut apart and the lower-right half then tumbled over the
   top of it on the way down.

   The class cannot wait — close() moves focus here on the next frame and you
   cannot focus a visibility:hidden element. The PAINT can. So the delay goes on
   opacity and transform and visibility keeps its 0s: for 920ms the pill is a
   focusable, activatable, correctly-labelled button that simply is not drawn
   yet, and Enter on it in that window reopens the terminal, which is what
   pressing an invisible reopen button ought to do.

   920ms is not a new number. It is the delay on .termwin.is-sliced's own
   opacity hop, and both classes land in the same tick, so the two transitions
   start on the same style flush and the pill begins to rise on the exact frame
   the window stops being drawn. Two boxes, one corner, and no frame with both
   of them in it — which is what the header over .termlaunch has always claimed
   and is now true during a cut as well.

   pointer-events, for the reason .termwin.is-sliced has it: opacity 0 stops
   nothing, so without this line the hold parks an invisible, fully clickable
   reopen button over the corner for the whole fall and a click aimed at the
   section underneath reopens the terminal instead. Keyboard activation is
   unaffected by pointer-events, so the one caller who can tell the button is
   there is the one who can still press it.

   Sibling combinator rather than a class of its own because the condition is
   literally "while the window is in pieces", and #termLaunch is the next
   element after #termWin. It also means the wait cannot outlive the cut: no
   is-sliced, no delay, so a plain close(), a reduced-motion close and a
   samurai-less page all put the pill straight up. 0,4,0 beats the is-shown
   rule's 0,3,1, so none of it needs !important — and a running transition keeps
   the delay it started with, which is why finish() taking is-sliced off at
   contact+990, mid-delay, does not cut the wait short. */
.termwin.is-sliced ~ .termlaunch.is-shown {
  pointer-events: none;
  transition: opacity .26s var(--ease) 920ms, transform .3s var(--ease) 920ms,
              color .2s var(--ease), border-color .2s var(--ease),
              visibility 0s;
}
/* Unless it is holding a focus ring. A keyboard close — Esc — hands this button
   focus, and a focus ring on something nobody can see is a control nobody can
   find, so that caller gets the pill immediately and wears the overlap. A mouse
   click does not match :focus-visible even though it does move focus here, so it
   still gets the handoff.

   It DROPS opacity and transform from the list instead of zeroing their delay,
   and that is the whole trick. The rule above is one frame early: is-shown lands
   in commit()'s tick and close() moves focus on the NEXT frame, so by the time
   anything matches :focus-visible the held transition is already running — and a
   running transition keeps the delay it started with, which is the same spec
   sentence that stops finish() from cutting the wait short. Retiming it does
   nothing; `transition-delay: 0s` here measured as applied, with the pill still
   at opacity 0 seven hundred milliseconds later. Taking a property out of
   transition-property cancels its running transition instead, and a cancelled
   transition leaves the property at the value it was heading for — is-shown's
   opacity 1 and transform none. So the keyboard's pill does not fade in, it is
   simply there, one frame after contact. For a control already wearing a focus
   ring that is the better of the two anyway. */
.termwin.is-sliced ~ .termlaunch.is-shown:focus-visible {
  pointer-events: auto;
  transition: color .2s var(--ease), border-color .2s var(--ease), visibility 0s;
}

/* The pill acknowledges the kill as it arrives, so the window's replacement
   reads as a consequence rather than a coincidence. samurai.js fires this at
   contact+920 to land it on the arrival above; it used to fire at contact+300,
   which spent it in the middle of the fall behind two tumbling halves. */
.termlaunch.is-cut { animation: sam-pill 260ms var(--ease); }
@keyframes sam-pill {
  0%, 100% { border-color: var(--line-2); }
  30%      { border-color: var(--red); color: var(--red); }
}

/* ── where he is not ───────────────────────────────────────────
   Below 861px there is no reserved rail (§18 drops the page's padding and the
   window becomes a full-width strip docked to the bottom edge), so a 56px
   figure in the corner would stand on the last paragraph of every section with
   nothing set aside for him. He exists exactly where the page reserves space
   for him — the same line §18 already draws — and nowhere else.

   The height cut-off is the nav. This comment used to claim 599px was that
   cut-off and that "a flat query needs no clearance arithmetic to stay correct
   when an unrelated clamp changes" — which had it backwards. The flat query is
   exactly what went stale when the window's height clamp changed, and 599 turned
   out to name where the problem STARTS rather than where it ends. Measured
   (.preview-tools/sam-navup.js, which pulls the nav down mid-scroll because that
   is the only moment it can reach him):

       1440x600 open   his head 58px behind the nav
       1440x640 open                39px
       1440x660 open                 4px
       1920x600 open                64px   <- worst width
        861x600 expanded            46px
       1920x700 open                clear by 10px
        861x700 expanded            clear by 28px

   So an OPEN window needs 700px of viewport, not 600. Two things stop that from
   being another flat query.

   First, the nav is not usually there. It rides at translateY(-68px) whenever you
   have scrolled down, which is always true of the position he lives at, so the
   overlap is only real during a scroll-up while the nav slides back down. That is
   a transient, but a head sliding under a blur bar is exactly the artefact this
   section exists to prevent, so it counts.

   Second and decisive: a FOLDED window is fine at every height. Its roof is down
   at the peek bar and he clears by 300-450px. 861x600 arrives folded and is clear
   by 297; expand that same viewport and he is 46px behind the nav. One viewport,
   two answers, and a media query cannot see which — so the gate has to name the
   fold, not just the height. Below 600 he goes regardless: that is a viewport too
   short to hold the window and the nav and him in any configuration.

   Why visibility rather than display, for this one gate only: it can flip DURING
   an interaction, because folding the window creates the room. display: none
   would snap him into existence at the end of a fold, and would restart the
   breath loop from frame 0 while it did it. Fading opacity leaves the loop running
   and lands the appearance inside the fold's own 1070ms of motion, where nothing
   about it reads as a pop. The two queries below never flip mid-interaction, so
   they keep display: none.

   AND THE TRANSITION IS THE ONE §14 SAYS NOT TO ADD. Read the note above the exit
   keyframes: "no transition anywhere near .sam that could run backwards and fade
   him IN when the window is reopened mid-fall". That hazard needs him visible with
   an OPEN window, and in here he never is — open plus short is precisely the state
   this query hides. So the transition cannot catch a reopen: at 700px and up it
   does not exist, and below 700 a reopened window is an open one and he is at
   opacity 0 either way. .preview-tools/sam-fade.js drives that reopen-mid-fall
   case at 1440x640 rather than taking this paragraph's word for it. */
@media (max-width: 860px) { .sam { display: none; } }
@media (max-height: 599px) { .sam { display: none; } }
@media (max-height: 699px) {
  .termwin:not(.is-min) .sam { visibility: hidden; opacity: 0; }
  .sam { transition: opacity 240ms var(--ease); }
}


/* ── 10c the résumé viewer ───────────────────────────────────────
   The first modal on this site, and it is here rather than in §15 with the
   contact furniture because it belongs to the same family as the terminal
   window above: a floating panel with a title bar, a hairline frame and a
   Japanese glyph on the left.

   WHERE IT SITS IN THE STACK. There is no z-index scale in this file, there is
   a ladder, and it reads: 90 the ambience layers, 95 the matrix canvas and the
   samurai's road, 96 the terminal window and its reopen button, 97 the spar,
   100 the nav, 200 the custom cursor, 500 the boot curtain, 999 the skip link.
   180 puts this above the nav — a modal that the nav floats over is not a modal
   — and below the cursor, because the cursor is a cursor and must never be
   underneath the thing it is pointing at. Anything landing between the two in
   future wants a number in that gap, not a bigger one than this.

   HEIGHT IS THE WHOLE LAYOUT PROBLEM. A PDF has no intrinsic height here, so
   the panel is told what height to be and the page inside it gets whatever is
   left after the bar and the foot. That is a column flexbox with `min-height: 0`
   on the middle child — without that one line the iframe refuses to shrink below
   its default 150px and pushes the download row off the bottom of the panel,
   which is the exact bug this feature exists to avoid. */
.rv {
  position: fixed;
  inset: 0;
  z-index: 180;
  display: grid;
  place-items: center;
  /* dvh, so an open panel is not cut off by a mobile browser's retracting
     address bar. The fallback matters on nothing current and costs one line. */
  height: 100vh;
  height: 100dvh;
  padding: clamp(.75rem, 3vw, 2.5rem);
  /* Above the nav means above the nav's shadow too, so the scrim has to cover
     the full viewport and the padding above is inside it. */
}

/* [hidden] has to beat display:grid, and a bare attribute selector does not —
   same specificity, and `hidden` loses on source order. */
.rv[hidden] { display: none; }

.rv__scrim {
  position: absolute;
  inset: 0;
  background: rgba(3, 4, 7, .8);
  /* The blur is what stops the page behind reading as content you could still
     click. It is also the single most expensive thing in this file, which is
     why it is on a 24px radius and not 40. */
  backdrop-filter: blur(6px) saturate(.75);
  -webkit-backdrop-filter: blur(6px) saturate(.75);
  opacity: 0;
  transition: opacity 260ms var(--ease);
}

.rv__panel {
  position: relative;
  display: flex;
  flex-direction: column;
  width: min(100%, 940px);
  height: min(100%, 900px);
  overflow: hidden;
  background: var(--elev);
  border: 1px solid var(--line-2);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow), var(--glow);
  opacity: 0;
  transform: translateY(14px) scale(.985);
  transition: opacity 300ms var(--ease), transform 380ms var(--ease);
}

.rv.is-open .rv__scrim { opacity: 1; }
.rv.is-open .rv__panel { opacity: 1; transform: none; }

/* ── its title bar ── */
.rv__bar {
  display: flex;
  align-items: center;
  gap: .7rem;
  flex: 0 0 auto;
  padding: .62rem .7rem .62rem .95rem;
  background: var(--elev-2);
  border-bottom: 1px solid var(--line);
}

.rv__glyph {
  font-family: var(--f-jp);
  font-size: 1.05rem;
  line-height: 1;
  color: var(--red);
  opacity: .9;
}

.rv__title {
  flex: 1 1 auto;
  min-width: 0;
  font: 500 var(--t-sm)/1.4 var(--f-mono);
  letter-spacing: .04em;
  color: var(--txt-2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.rv__title em {
  font-family: var(--f-jp);
  font-style: normal;
  color: var(--txt-3);
  margin-left: .5rem;
}

.rv__x {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  display: grid;
  place-items: center;
  font: 400 13px/1 var(--f-mono);
  color: var(--txt-3);
  background: none;
  border: 1px solid transparent;
  border-radius: var(--r);
  cursor: pointer;
  transition: color 160ms, border-color 160ms, background-color 160ms;
}
.rv__x:hover, .rv__x:focus-visible {
  color: var(--red);
  border-color: var(--red-soft);
  background: var(--red-soft);
}

/* ── the page ── */
.rv__stage {
  position: relative;
  flex: 1 1 auto;
  /* The line without which the whole panel comes apart — see the note above. */
  min-height: 0;
  /* Light, because a PDF's own pages are white and a black surround makes the
     first page look like it is floating with no edges. This is the one place on
     the site that is not dark, and it is not a theme, it is paper. */
  background: #1a1c22;
}

.rv__frame {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}

/* The apology, and it is the <object>'s own fallback content rather than a layer
   over the top — see index.html for why that distinction is the whole design.
   Which means it has to fill the object's box by itself: a fallen-back <object>
   is just a box with children in it, and left alone those children sit in the
   top-left of an otherwise empty grey field looking like a rendering failure
   rather than an explanation of one. */
.rv__fallback {
  display: grid;
  align-content: center;
  gap: .55rem;
  min-height: 100%;
  padding: clamp(1.2rem, 5vw, 3rem);
  text-align: center;
  background: var(--elev);
  font-size: var(--t-sm);
  color: var(--txt-2);
}
.rv__fallback b { color: var(--txt); font-weight: 500; }
.rv__fb-jp {
  font-family: var(--f-jp);
  font-size: var(--t-lg);
  color: var(--red);
  opacity: .8;
}

/* ── the download row ── */
.rv__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex: 0 0 auto;
  flex-wrap: wrap;
  padding: .8rem .95rem;
  background: var(--elev-2);
  border-top: 1px solid var(--line);
}

.rv__meta {
  font: 400 var(--t-xs)/1.5 var(--f-mono);
  color: var(--txt-3);
  letter-spacing: .03em;
}
.rv__sep { margin: 0 .5rem; opacity: .55; }

.rv__acts {
  display: flex;
  gap: .55rem;
  flex-wrap: wrap;
}

/* Narrow: the metadata line is the first thing to go, and the two buttons take
   the full width side by side so neither is the small one. */
@media (max-width: 560px) {
  .rv__meta { display: none; }
  .rv__foot { justify-content: stretch; }
  .rv__acts { flex: 1 1 auto; }
  .rv__acts .btn { flex: 1 1 0; justify-content: center; padding-inline: .55em; }
  /* And the two decorative arrows go as well, because at 390px they are exactly
     the difference between one line and two: "Open in a tab" wrapping while
     "Download PDF" beside it stays on one line does not read as a tight fit, it
     reads as a broken button. Both are aria-hidden, so nothing is lost. */
  .rv__acts .btn > i { display: none; }
  .rv__acts .btn > span { white-space: nowrap; }
}

/* Short: the bar and the foot are fixed furniture, so on a laptop in landscape
   at 500px tall the page in the middle is what has to give. Nothing to do but
   let it — this is here to say that is intentional, and that the panel must not
   grow a scrollbar of its own, which is what removing `overflow: hidden` above
   would cause. */

@media (prefers-reduced-motion: reduce) {
  .rv__panel { transform: none; transition: opacity 120ms linear; }
  .rv__scrim { transition: opacity 120ms linear; }
}


/* ── 11 about ────────────────────────────────────────────────── */

.about {
  display: grid;
  grid-template-columns: 1.5fr .95fr;
  gap: clamp(2rem, 5vw, 4.5rem);
  align-items: start;
}
.about__text p + p { margin-top: 1.15em; }
.about__text p { color: var(--txt-2); }
.dropcap {
  float: left;
  margin: .05em .4em 0 0;
  font: 700 2.8em/1 var(--f-jp);
  color: var(--red);
}

.about__facts {
  display: grid;
  /* min() so the track floor can never exceed the container — see .certs__list */
  grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
  gap: 1px;
  margin-top: 2.4rem;
  background: var(--line);
  border: 1px solid var(--line);
  border-radius: var(--r);
  overflow: hidden;
}
.about__facts li {
  display: flex;
  flex-direction: column;
  gap: .3em;
  padding: 1em 1.1em;
  background: var(--bg);
}
.about__facts b {
  font: 500 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--txt-3);
}
.about__facts span { font-size: var(--t-sm); color: var(--txt); }
.about__facts .ok { color: var(--grn); }
.about__facts .ok::before {
  content: '';
  display: inline-block;
  width: 7px; height: 7px;
  margin-right: .5em;
  border-radius: 50%;
  background: var(--grn);
  box-shadow: 0 0 8px var(--grn);
  animation: pulse 2s var(--ease-io) infinite;
}
@keyframes pulse { 50% { opacity: .35; } }

/* 3D ID card */
.about__card { perspective: 1000px; }
.about__card-inner {
  transform-style: preserve-3d;
  transition: transform .5s var(--ease);
}
.idcard {
  position: relative;
  padding: 1.5rem 1.4rem 1.2rem;
  background:
    linear-gradient(160deg, var(--card-sheen), transparent 55%),
    var(--elev);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow);
  overflow: hidden;
  /* preserve-3d removed — overflow: hidden already flattens this box, and in
     Chromium that flattening applies to paint but not to hit-testing, so the two
     drift apart and clicks, hover and text selection all land off-target. Here
     it showed up as the middle of .idcard__role answering as .idcard. Nothing in
     this card is clickable so the only real cost was selecting the wrong line of
     text, but it is the same defect that made the project cards' CODE↗ dead, and
     the full write-up is with the fix in §13 .card__inner. Removing it here is
     byte-identical at rest and mid-tilt: the avatar's translateZ(28px) and the
     name's 18px were already doing nothing. The tilt on .about__card-inner is
     unaffected — that element has no overflow and keeps its preserve-3d. */
}
.idcard::before {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg, transparent 40%, var(--card-sheen) 50%, transparent 60%);
  opacity: 0;
  transition: opacity .4s var(--ease);
}
.about__card:hover .idcard::before { opacity: 1; }
.idcard__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 1.4rem;
}
.idcard__label {
  font: 500 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .2em;
  color: var(--txt-3);
}
.idcard__chip {
  width: 26px; height: 19px;
  border-radius: 3px;
  background:
    repeating-linear-gradient(to bottom, transparent 0 4px, rgba(0,0,0,.28) 4px 5px),
    linear-gradient(135deg, #d9b25e, #f3dda4 45%, #b98f3d);
}
.idcard__avatar {
  display: grid;
  place-items: center;
  width: 92px; height: 92px;
  margin-bottom: 1.1rem;
  background: var(--red);
  border-radius: var(--r);
  box-shadow: var(--glow);
  transform: translateZ(28px);
}
.idcard__avatar span {
  font: 700 40px/1 var(--f-jp);
  color: #fff;
}
.idcard__avatar img {
  width: 100%; height: 100%;
  border-radius: var(--r);
  object-fit: cover;
}
.idcard__name {
  font: 700 var(--t-lg)/1.15 var(--f-sans);
  letter-spacing: .05em;
  transform: translateZ(18px);
}
.idcard__role {
  margin-bottom: 1.3rem;
  font: 400 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .12em;
  color: var(--txt-3);
}
.idcard__meta {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: .7rem;
  padding-top: 1.1rem;
  border-top: 1px dashed var(--line-2);
}
.idcard__meta dt {
  font: 500 9px/1.4 var(--f-mono);
  letter-spacing: .13em;
  color: var(--txt-3);
}
.idcard__meta dd {
  font: 500 var(--t-sm)/1.3 var(--f-mono);
  color: var(--grn);
}
.idcard__strip {
  margin-top: 1.2rem;
  font: 400 9px/1 var(--f-jp);
  letter-spacing: .1em;
  color: var(--txt-3);
  opacity: .4;
  white-space: nowrap;
  overflow: hidden;
}


/* ── 12 skills ───────────────────────────────────────────────── */

.skills {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(265px, 100%), 1fr));
  gap: 1.15rem;
}
.skillgroup {
  padding: 1.5rem 1.4rem;
  background: var(--elev);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  transform-style: preserve-3d;
  transition: transform .5s var(--ease), border-color .3s var(--ease);
}
.skillgroup:hover { border-color: var(--line-2); }
.skillgroup h3 {
  display: flex;
  align-items: center;
  gap: .7em;
  margin-bottom: 1.2rem;
  font: 600 var(--t-base)/1 var(--f-sans);
  letter-spacing: .02em;
}
.skillgroup h3 i {
  display: grid;
  place-items: center;
  width: 28px; height: 28px;
  font: 500 14px/1 var(--f-jp);
  font-style: normal;
  color: var(--red);
  border: 1px solid var(--red);
  border-radius: 3px;
  background: var(--red-soft);
}

/* Skill lists. Deliberately unranked — there were percentage meters
   here once, but a self-assigned "Python 82%" is a number no reader can
   check, so it only costs credibility. The projects do that job. */
.skilltags {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
}
.skilltags li {
  padding: .38em .8em;
  font: 400 var(--t-sm)/1.35 var(--f-mono);
  color: var(--txt-2);
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: 5px;
  transition: color .22s var(--ease), border-color .22s var(--ease),
              background .22s var(--ease);
}
.skilltags li:hover {
  color: var(--txt);
  background: var(--elev-2);
  border-color: var(--line-2);
}

.tags {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 1rem;
  margin-top: 2.4rem;
  padding-top: 2rem;
  border-top: 1px solid var(--line);
}
.tags__label {
  font: 500 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--txt-3);
}
.tags ul {
  display: flex;
  flex-wrap: wrap;
  gap: .45rem;
}
.tags li {
  padding: .34em .78em;
  font: 400 var(--t-xs)/1.4 var(--f-mono);
  color: var(--txt-2);
  border: 1px solid var(--line);
  border-radius: 99px;
  transition: color .22s var(--ease), border-color .22s var(--ease),
              transform .22s var(--ease);
}
.tags li:hover {
  color: var(--red);
  border-color: var(--red);
  transform: translateY(-2px);
}


/* ── 13 work cards ───────────────────────────────────────────── */

.work {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
  gap: 1.15rem;
}
.card { perspective: 1000px; }
.card__inner {
  position: relative;
  height: 100%;
  display: flex;
  flex-direction: column;
  background: var(--elev);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  overflow: hidden;
  /* No transform-style: preserve-3d here, and the reason needs writing down,
     because the obvious reading of §07's tilt says it belongs: this element
     rotates, its children carry translateZ, so surely they should sit at
     different depths.

     They cannot. overflow: hidden is a grouping property, which per CSS
     Transforms 2 forces this element flat whatever transform-style says.
     Chromium honours that when PAINTING and ignores it when HIT-TESTING, and
     those two disagreeing is not a cosmetic problem: it moved every hit area
     inside .card__body about 50px up from where it was drawn. A click on the
     middle of CODE↗ landed on .card__inner and did nothing. A click on the
     middle of the title landed on the description. Mouse and touch alike, at
     rest, at every viewport — the most important links on the page were dead at
     their centres, and no screenshot can show that.

     Deleting the declaration costs nothing, because the flattening was already
     happening: with it and without it the card is pixel-identical at rest bar
     sub-pixel antialiasing, and indistinguishable mid-tilt. Measured, not
     assumed — .preview-tools/card-3d.js shoots both at 2x and compares. The
     depth it was supposed to buy was never there to lose: perspective is 1000px
     against child offsets of 14-40px, so the entire effect is a 1-4% scale.

     The tilt itself is untouched. That is a transform on THIS element and still
     takes its perspective from .card; only the children's translateZ flattens,
     which is what was already true.

     Those translateZ values stay. They are inert now, but the thumb glyph
     animates translateZ and scale as one transform on hover, and rewriting that
     pair to drop half of it changes a working transition for no gain. §11's
     .idcard had the same three ingredients and lost the same line. */
  transition: transform .5s var(--ease), border-color .3s var(--ease),
              box-shadow .4s var(--ease);
}
.card:hover .card__inner {
  /* Fallback then blend, per the note on .nav. The mildest of the four: a dropped
     hover border still leaves the lift and the glow, so nothing is lost except
     half a shade. */
  border-color: var(--red);
  border-color: color-mix(in srgb, var(--red) 45%, var(--line));
  box-shadow: var(--shadow), var(--glow);
}
.card__index {
  position: absolute;
  top: .8rem; right: 1rem;
  z-index: 3;
  font: 700 var(--t-sm)/1 var(--f-mono);
  color: var(--txt-3);
  transform: translateZ(30px);
}
.card__thumb {
  position: relative;
  display: grid;
  place-items: center;
  aspect-ratio: 16 / 9;
  background:
    radial-gradient(circle at 30% 20%, var(--red-soft), transparent 60%),
    repeating-linear-gradient(45deg,
      transparent 0 9px, var(--card-sheen) 9px 10px),
    var(--elev-2);
  border-bottom: 1px solid var(--line);
  overflow: hidden;
}
.card__thumb span {
  font: 700 clamp(2.4rem, 6vw, 3.6rem)/1 var(--f-jp);
  color: var(--red);
  opacity: .5;
  transform: translateZ(20px);
  transition: transform .5s var(--ease), opacity .4s var(--ease);
}
.card:hover .card__thumb span { opacity: .85; transform: translateZ(40px) scale(1.06); }
.card__thumb img {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}
.card__body {
  display: flex;
  flex-direction: column;
  flex: 1;
  gap: .8rem;
  padding: 1.35rem 1.3rem 1.4rem;
  transform: translateZ(14px);
}
.card__title {
  font: 600 var(--t-lg)/1.25 var(--f-sans);
  letter-spacing: -.01em;
}
.card__desc {
  flex: 1;
  font-size: var(--t-sm);
  color: var(--txt-2);
}
.card__stack {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
}
.card__stack li {
  padding: .26em .62em;
  font: 400 var(--t-xs)/1.4 var(--f-mono);
  color: var(--grn);
  background: var(--grn-soft);
  border-radius: 3px;
}
.card__links {
  display: flex;
  gap: 1.1rem;
  padding-top: .55rem;
  border-top: 1px solid var(--line);
}
.card__links a {
  display: inline-flex;
  align-items: center;
  gap: .35em;
  font: 500 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--txt-2);
  transition: color .22s var(--ease), gap .22s var(--ease);
}
.card__links a i { font-style: normal; }
.card__links a:hover { color: var(--red); gap: .6em; }

/* Stands in for .card__links when a project has nothing to link to yet —
   an empty links row would just render as a stray horizontal rule. */
.card__note {
  display: flex;
  /* Not align-items: center. It was, and it was fine while there were two cards
     in this grid: the note fitted on one line, and centring a 5px dot against a
     one-line flex item is the same thing as aligning it to that line. A third
     card made the columns narrower, the note wrapped to two lines, and the dot
     drifted to the midpoint BETWEEN them — reading as a bullet belonging to
     neither. flex-start pins it to the top and the margin below drops it back to
     the optical centre of the first line, which is where it looked like it was
     all along. */
  align-items: flex-start;
  gap: .5em;
  padding-top: .55rem;
  border-top: 1px solid var(--line);
  font: 400 var(--t-xs)/1.4 var(--f-mono);
  letter-spacing: .06em;
  color: var(--txt-3);
}
.card__note::before {
  content: '';
  width: 5px; height: 5px;
  border-radius: 99px;
  background: var(--vio);
  flex-shrink: 0;
  /* Half the leading, less half the dot. The 1.4 is this rule's own
     line-height, so the two stay in step if the type changes. */
  margin-top: calc((1.4em - 5px) / 2);
}

.work__more {
  margin-top: 2.4rem;
  font: 400 var(--t-sm)/1 var(--f-mono);
  color: var(--txt-3);
}
.work__more a {
  display: inline-flex;
  align-items: center;
  gap: .35em;
  color: var(--red);
  border-bottom: 1px solid currentColor;
  padding-bottom: 2px;
  transition: gap .22s var(--ease);
}
.work__more a i { font-style: normal; }
.work__more a:hover { gap: .6em; }


/* ── 14 timeline ─────────────────────────────────────────────── */

.timeline {
  position: relative;
  padding-left: 2rem;
  max-width: 76ch;
}
.timeline::before {
  content: '';
  position: absolute;
  top: .55rem; bottom: .55rem; left: 4px;
  width: 1px;
  background: linear-gradient(to bottom, var(--red), var(--line) 70%, transparent);
}
.tl { position: relative; padding-bottom: 2.6rem; }
.tl:last-child { padding-bottom: 0; }
.tl__dot {
  position: absolute;
  top: .5rem; left: -2rem;
  width: 9px; height: 9px;
  background: var(--bg-2);
  border: 1.5px solid var(--red);
  border-radius: 50%;
  transition: background-color .3s var(--ease), box-shadow .3s var(--ease);
}
.tl:hover .tl__dot { background: var(--red); box-shadow: 0 0 0 4px var(--red-soft); }
.tl__when {
  font: 500 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--red);
  margin-bottom: .6rem;
}
.tl__what {
  font: 600 var(--t-lg)/1.3 var(--f-sans);
  margin-bottom: .2rem;
}
.tl__where {
  font: 400 var(--t-sm)/1.5 var(--f-mono);
  color: var(--txt-2);
  margin-bottom: .5rem;
}
.tl__note {
  font-size: var(--t-sm);
  color: var(--txt-3);
  max-width: 62ch;
}

/* Certifications — sits under the timeline rather than inside it. Eleven
   entries as timeline nodes would bury the two jobs that matter more. */
.certs {
  margin-top: 3.2rem;
  padding-top: 2.2rem;
  border-top: 1px solid var(--line);
}
.certs__head {
  display: flex;
  align-items: baseline;
  gap: .6em;
  margin-bottom: 1.3rem;
  font: 600 var(--t-base)/1 var(--f-sans);
}
.certs__head em {
  font: 500 var(--t-sm)/1 var(--f-jp);
  font-style: normal;
  color: var(--red);
}
/* min(320px, 100%), not 320px. The first argument of minmax() is a FLOOR, and
   auto-fit will not shrink a track below it — so at a 320px viewport, where this
   grid's content box is x19..319, a bare 320px floor rendered every one of the
   eleven tiles at x19..339 and gave the document a 339px scrollWidth against a
   320px clientWidth. Nineteen pixels of horizontal scroll on the whole page,
   caused by one number in one track definition.

   Worth knowing how well it hid: no media query is wrong, nothing looks broken
   in a screenshot, and the symptom surfaces as the fixed full-bleed overlays
   measuring 339 too — because a too-wide element expands the layout viewport and
   every `inset: 0` layer then matches the expanded width. Those layers are the
   loudest thing in a scan and none of them is the cause.

   min() takes the floor down to the container when the container is narrower
   than the ideal, and is inert at every width where 320px already fits, so the
   layout above 320 is byte-identical. Same guard on the other three auto-fit
   grids below — .work's 300px floor happened to clear by exactly 1px, which is
   not a margin, it is luck. */
.certs__list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
  gap: .7rem;
}
.certs__list li {
  display: grid;
  grid-template-columns: 64px 1fr;
  gap: 1rem;
  align-items: start;
  padding: .9rem 1rem;
  background: var(--elev);
  border: 1px solid var(--line);
  border-radius: 6px;
  transition: border-color .25s var(--ease), transform .25s var(--ease);
}
.certs__list li:hover {
  border-color: var(--line-2);
  transform: translateY(-2px);
}

/* Cisco's and IBM's badges are bright cyan, teal and magenta — their palettes,
   not this site's. Left at full strength alongside a crimson accent they read
   as pasted in from another page, so they're muted here and go to full colour
   on hover or when a keyboard lands on the row.

   Muted, though, not greyed. The first attempt was grayscale(1) at 46px, and
   it failed for a reason worth recording: these aren't glyphs, they're little
   posters with their own typography, and their identity IS the colour. Strip
   that at that size and all eleven rows lead with the same grey smudge —
   quieter than the badges, but no longer badges. saturate() pulls them toward
   the page without erasing them, and 64px is about where the wordmark and the
   title band start being legible.

   Not opacity on the element, because these are transparent PNGs on a panel a
   shade lighter than the page — fading the whole thing would grey the artwork
   against its own tile. */
.certs__badge {
  width: 64px;
  height: 64px;
  object-fit: contain;
  filter: saturate(.62) brightness(.94);
  transition: filter .28s var(--ease);
}
.certs__list li:hover .certs__badge,
.certs__list li:focus-within .certs__badge { filter: none; }

/* Four of these have no badge art. They used to fall back to a ✓, which at
   64px is a big hollow box that reads as an image that failed to load — so
   instead the tile carries the issuer's short name. It fills the space, and
   it's the one thing a tile in this position should tell you anyway.

   aria-hidden because the issuer is already spelled out in full two lines
   down; a screen reader doesn't need "cisco" twice. */
.certs__badge--mark {
  display: grid;
  place-items: center;
  padding: .2em;
  background: var(--elev-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  font: 500 11px/1.2 var(--f-mono);
  letter-spacing: .06em;
  text-align: center;
  color: var(--txt-3);
  filter: none;
  transition: color .28s var(--ease), border-color .28s var(--ease);
}
.certs__list li:hover .certs__badge--mark,
.certs__list li:focus-within .certs__badge--mark {
  color: var(--txt-2);
  border-color: var(--line-2);
}
.certs__list b {
  display: block;
  margin-bottom: .3em;
  font: 500 var(--t-sm)/1.35 var(--f-sans);
  color: var(--txt);
}
/* Issuer and date on separate lines, not "Issuer · Date" on one. Three
   columns of a 64px tile plus padding leaves about 240px of mono here, and
   the longest pairing is a couple of characters over that — so it wrapped on
   some rows and not others, breaking mid-string after the interpunct. Two
   short lines wrap on none of them. */
.certs__list span {
  display: block;
  font: 400 var(--t-xs)/1.4 var(--f-mono);
  color: var(--txt-3);
}
/* This used to be `opacity: .72`, stacked on top of a colour that is already the
   dim tier — so the date rendered at an effective 2.42:1 and was the largest
   remaining block of sub-AA text on the page, 11 runs of it.
   `--txt-3` is now the AA floor on this surface, which means there is no room
   below it for a quieter sub-tier: anything dimmer than the issuer line fails.
   The date was subordinate to the issuer only by being harder to read, and it
   already sits on its own line, so the ranking survives the loss. If it ever
   needs to read as metadata again, that has to come from size, tracking or
   position — not from taking another 28% off a colour that has none to give. */
.certs__when { letter-spacing: .04em; }

/* PDF / Verify. Underlined only on hover: eleven rows × two links is a lot of
   rule if they're all drawn at rest. */
.certs__act {
  display: flex;
  flex-wrap: wrap;
  gap: .2rem 1rem;
  margin-top: .5rem;
}
.certs__act a {
  display: inline-flex;
  align-items: center;
  gap: .3em;
  font: 400 var(--t-xs)/1 var(--f-mono);
  color: var(--txt-2);
  border-bottom: 1px solid transparent;
  transition: color .2s var(--ease), border-color .2s var(--ease), gap .2s var(--ease);
}
.certs__act a i { font-style: normal; }
.certs__act a:hover,
.certs__act a:focus-visible {
  color: var(--red);
  border-bottom-color: currentColor;
  gap: .5em;
}
.certs__verify {
  margin-top: 1.2rem;
  font: 400 var(--t-xs)/1 var(--f-mono);
  color: var(--txt-3);
}
.certs__verify a {
  display: inline-flex;
  align-items: center;
  gap: .35em;
  color: var(--red);
  border-bottom: 1px solid currentColor;
  transition: gap .22s var(--ease);
}
.certs__verify a i { font-style: normal; }
.certs__verify a:hover { gap: .6em; }


/* ── 15 contact & form ───────────────────────────────────────── */

/* Contact runs tighter than every other section, on purpose.

   It's the only one whose payload is at the *bottom*: clicking "Contact" in
   the nav scrolls the section's top edge under the nav, and with the standard
   --sp-sec lead-in above the heading, a laptop viewport ran out before the
   Send button — a form whose submit you can't see reads as broken. So the
   dead space above the heading is cut and the message box is shorter, which
   is enough to land the whole exchange in one screenful.

   Cut through --sec-pt rather than padding-top so the anchor offset stays in
   step with it. This is already smaller than --sp-anchor, so the min() in .sec
   leaves the landing at zero and contact keeps the exact position it has now. */
.sec--contact { --sec-pt: clamp(3.5rem, 2rem + 4vw, 5.5rem); }
.sec--contact .sec__head { margin-bottom: clamp(1.9rem, 3vw, 2.8rem); }
.sec--contact .field textarea { min-height: 6em; }

.contact {
  display: grid;
  grid-template-columns: 1.45fr .85fr;
  gap: clamp(2rem, 5vw, 4rem);
  align-items: start;
}

.field { display: flex; flex-direction: column; gap: .5em; }
.form__row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
}
.form > * + * { margin-top: 1rem; }

.field label {
  font: 500 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--txt-3);
}
.field input,
.field textarea {
  width: 100%;
  padding: .8em .95em;
  font: 400 var(--t-sm)/1.6 var(--f-mono);
  background: var(--elev);
  border: 1px solid var(--line);
  border-radius: var(--r);
  caret-color: var(--red);
  transition: border-color .25s var(--ease), background-color .25s var(--ease);
}
/* The same iOS focus-zoom as .term__input, and this is where it did the most harm:
   tapping "Your name" magnified the page and left the visitor to pinch back out to
   find Send. Every textarea min-height is in em, so the fields grow with the text. */
@media (hover: none), (pointer: coarse) {
  .field input,
  .field textarea { font-size: max(16px, var(--t-sm)); }
}
.field textarea { resize: vertical; min-height: 7.5em; }
/* Same as .term__input::placeholder — explicit 1, because Firefox dims these by
   default and an omitted value is not the same as a neutral one. The .55 put
   every field's guidance at 1.89:1, on the one part of the page a visitor has to
   read before they can use it. */
.field input::placeholder,
.field textarea::placeholder { color: var(--txt-3); opacity: 1; }
.field input:hover,
.field textarea:hover { border-color: var(--line-2); }
.field input:focus,
.field textarea:focus {
  outline: none;
  border-color: var(--red);
  background: var(--elev-2);
}
/* That focus state is two colours, and a contrast theme replaces both: the fill
   becomes Canvas and the border a system colour, so all that was left of it was
   a 1px border changing shade — black to dark purple in Chromium's emulated
   theme, about 1.4:1. Every other control on the page keeps the 2px outline
   from :focus-visible; under forced-colors the fields get it back, in
   CanvasText for the same reason as the terminal screen's. */
@media (forced-colors: active) {
  .field input:focus,
  .field textarea:focus { outline: 2px solid CanvasText; outline-offset: 2px; }
}
.field.has-err input,
.field.has-err textarea { border-color: var(--red); }
.field__err {
  font: 400 var(--t-xs)/1.4 var(--f-mono);
  color: var(--red);
  min-height: 1.2em;
}

/* honeypot — off-screen, never focusable */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px; height: 1px;
  overflow: hidden;
}

.form__submit { margin-top: 1.3rem; }
.form__submit[disabled] { opacity: .55; pointer-events: none; }
.form__status {
  margin-top: .9rem;
  font: 400 var(--t-sm)/1.5 var(--f-mono);
  min-height: 1.5em;
}
.form__status.is-ok  { color: var(--grn); }
.form__status.is-err { color: var(--red); }
.form__status.is-busy { color: var(--txt-3); }

/* The status line holds controls and not just a sentence in exactly one state:
   stranded() in js/main.js §08, where a visitor whose browser has no mail handler
   gets the address and a copy button instead of a promise nothing kept.

   Flex rather than inline children because a button in a run of text sits on the
   baseline and the descenders fight it; and wrapping, because the sentence plus a
   full email address plus a button is more than this column has on one line at
   any width worth designing for. The gap does the separating, so the JS appends
   the three parts with no punctuation between them. */
.form__status--out {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .55rem .7rem;
}
.form__status--out a { color: var(--red); }

.contact__side { display: flex; flex-direction: column; gap: 1.9rem; }
.contact__block h3 {
  margin-bottom: .7rem;
  font: 500 var(--t-xs)/1 var(--f-mono);
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--txt-3);
}
.contact__mail {
  font: 500 var(--t-base)/1.5 var(--f-mono);
  color: var(--txt);
  word-break: break-all;
  border-bottom: 1px solid var(--red);
  padding-bottom: 2px;
  transition: color .25s var(--ease);
}
.contact__mail:hover { color: var(--red); }
.contact__links { display: flex; flex-direction: column; gap: .55rem; }
.contact__links a {
  display: inline-flex;
  align-items: center;
  gap: .4em;
  font: 400 var(--t-sm)/1 var(--f-mono);
  color: var(--txt-2);
  transition: color .22s var(--ease), gap .22s var(--ease);
}
.contact__links a i { font-style: normal; color: var(--txt-3); }
.contact__links a:hover { color: var(--red); gap: .75em; }
.contact__ascii {
  margin-top: auto;
  font: 400 var(--t-xs)/1.9 var(--f-jp);
  letter-spacing: .14em;
  /* No `opacity: .6` on top of --txt-3. また会いましょう is a sign-off, not
     texture — it is the last thing the contact panel says — and dimming an
     already-dim colour put it at 2.03:1. The generous line-height and tracking
     are what make it feel like a closing flourish; the contrast was never
     carrying that. */
  color: var(--txt-3);
}


/* ── 16 footer ───────────────────────────────────────────────── */

.foot {
  padding-block: 2.2rem;
  border-top: 1px solid var(--line);
  background: var(--bg-2);
}
.foot__in {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  font: 400 var(--t-xs)/1.7 var(--f-mono);
  color: var(--txt-3);
}
.foot__glyph {
  display: inline-grid;
  place-items: center;
  width: 18px; height: 18px;
  margin-right: .5em;
  background: var(--red);
  color: #fff;
  font-family: var(--f-jp);
  border-radius: 2px;
  vertical-align: -.3em;
}
.foot__right a {
  margin-left: .6em;
  color: var(--txt-2);
  border-bottom: 1px solid var(--line-2);
  transition: color .22s var(--ease), border-color .22s var(--ease);
}
.foot__right a i { font-style: normal; }
.foot__right a:hover { color: var(--red); border-color: var(--red); }


/* ── 17 reveal animations ────────────────────────────────────── */

.reveal {
  opacity: 0;
  transform: translateY(26px);
  transition: opacity .75s var(--ease), transform .75s var(--ease);
  transition-delay: var(--rd, 0ms);
}
.reveal.is-in {
  opacity: 1;
  transform: none;
}
/* Never leave content hidden if JS fails to load. Both halves of that matter
   and only one of them used to be here. Unhiding .reveal is pointless while
   §05's curtain is still drawn over it: .boot is fixed, inset 0, opaque, at
   z-index 500, and the only thing that ever clears it is main.js adding
   .is-done. No JS, or a main.js that 404s or throws before that line, and the
   page underneath is perfect and completely invisible — a black screen reading
   AWAITING SUBJECT, forever, with no way out. The scroll lock is not a problem
   here for once: body.is-locked is added by the same function, so it never
   lands either. */
.no-js .reveal { opacity: 1; transform: none; }
.no-js .boot { display: none; }


/* ── 18 responsive ───────────────────────────────────────────── */

/* ── the shell's rail ──────────────────────────────────────────
   The window is pinned to the bottom-right corner and never moves, so rather
   than argue with it the page keeps that corner empty: every section and the
   footer reserve --term-rail on the right.

   Permanently, and that is the whole point. Reserving it only while the window
   was up would reflow the entire column on every open, fold and close — a page
   of text jumping sideways under the cursor, twice per click. A rail that is
   always there is a rail nobody notices.

   Sized for the folded bar (240px), not the open window (600px), because
   folded is what the window is everywhere except its own section — module 09
   in js/main.js does that part. The two numbers meet in §09b.

   The hero is deliberately absent from this list: it isn't a .sec, its copy
   lives in .hero__body, and the shell is stowed above the terminal section
   anyway — so the torii keeps the full width it was composed for. */
@media (min-width: 861px) {
  .sec,
  .foot { padding-right: var(--term-rail); }
}

/* Its own section is the one place the window is full size without being
   asked, so that one reserves the whole 600px instead of the folded 240px.

   Only from 1281px up, and module 09 in js/main.js reads the same number:
   below it, 654px of rail leaves a 290px column, which is not enough page for
   the heading and the eight command chips — so down there the window doesn't
   expand on arrival at all. It stays folded and a chip, or the Open button,
   or the bar itself brings it up. Which is better than the alternative either
   way round: the controls that drive the window are never underneath it. */
@media (min-width: 1281px) {
  .sec--terminal { padding-right: var(--term-rail-full); }
}

/* The rail costs ~290px, so the width at which a two-column section stops
   fitting arrives that much earlier than the viewport number suggests: a
   1280px screen now has a ~990px track, which is what a 1024px screen used to
   give. Hence 1280 rather than 1024 — the breakpoint follows the track, not
   the window. */
@media (max-width: 1280px) {
  .about,
  .contact { grid-template-columns: 1fr; }
  .about__card { max-width: 380px; }
}

/* Not moved with the two above: the rails flank the hero, and the hero has no
   rail to make room for. */
@media (max-width: 1024px) {
  .vrail { display: none; }
}

/* A laptop in a short window. The 300px floor on --term-h is there so the
   shell is usable, but on a 600px-tall viewport it stops being a window on
   the page and starts being most of the page. Below that, give up the floor. */
@media (min-width: 861px) and (max-height: 640px) {
  .termwin { --term-h: 54vh; }
}

/* Same viewport, different problem: a short screen is where the contact
   section stops fitting. Only worth doing while the form and the side column
   are still side by side — once they stack (≤1024px) the section is taller
   than any laptop viewport and scrolling is the honest answer. 1281 rather
   than 1025 for the same reason the stacking breakpoint moved — see the rail
   note at the top of this section. */
@media (min-width: 1281px) and (max-height: 900px) {
  .sec--contact { --sec-pt: clamp(2.6rem, 3vw, 3.4rem); }
  .sec--contact .sec__head { margin-bottom: 1.7rem; }
  .sec--contact .field textarea { min-height: 5em; }
  .sec--contact .form > * + * { margin-top: .8rem; }
  .sec--contact .form__submit { margin-top: 1.05rem; }
}

@media (max-width: 860px) {
  :root { --nav-h: 60px; }

  .nav__burger { display: flex; }
  /* .nav__links leaves the flow below, so its `margin-left: auto` no longer
     pushes the tools over — put it on the tools instead. */
  .nav__tools { display: flex; margin-left: auto; }
  .nav__links {
    position: fixed;
    inset: var(--nav-h) 0 auto 0;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    margin: 0;
    padding: .5rem var(--gut) 1.4rem;
    background: var(--bg);
    border-bottom: 1px solid var(--line);
    box-shadow: var(--shadow);
    opacity: 0;
    visibility: hidden;
    transform: translateY(-12px);
    transition: opacity .3s var(--ease), transform .3s var(--ease), visibility .3s;
  }
  .nav.is-open .nav__links {
    opacity: 1;
    visibility: visible;
    transform: none;
  }
  .nav__links a {
    flex-direction: row;
    align-items: baseline;
    gap: .8em;
    padding: .95em .2em;
    font-size: var(--t-base);
    border-bottom: 1px solid var(--line);
  }
  .nav__links a i { font-size: 11px; }
  .nav__links a::after { display: none; }

  .hero { min-height: auto; padding-top: calc(var(--nav-h) + 4.5rem); }
  .hero__scroll { display: none; }
  /* No room to sit beside the copy at this width — the gate drops behind it
     as texture (scene.js also shifts the model down). */
  .hero__canvas { opacity: .38; }
  .term__hintkey { display: none; }
  .form__row { grid-template-columns: 1fr; }
  .foot__in { flex-direction: column; align-items: flex-start; }

  /* The floating window becomes a docked strip: full width, flush to the
     bottom edge, and collapsed on arrival (main.js does that part). A 32vh
     panel over a 390px-wide hero isn't "present on screen", it's in the way —
     so at this width it's present as a title bar you tap to open. */
  .termwin {
    --term-h: min(56vh, 440px);
    right: 0; left: 0; bottom: 0;
    width: auto;
    transform-origin: 50% 100%;
    padding-bottom: env(safe-area-inset-bottom);
    background: var(--elev);
    border-top: 1px solid var(--line-2);
    box-shadow: 0 -16px 42px -18px rgba(0, 0, 0, .92);
    transform: translateY(100%);
  }
  /* Same specificity as the desktop rule and later in the file, so this wins.
     Which also means the stowed strip slides down off the edge here instead of
     fading 14px, without either value being written twice. */
  body:not(.term-stowed) .termwin.is-open { transform: none; }
  /* The strip's own frame does the work now — the inner card would draw a
     second border and a floating shadow inside a flush-mounted panel. */
  .termwin .term,
  .termwin .term:focus-within {
    border: 0;
    border-radius: 0;
    background: transparent;
    box-shadow: none;
  }
  .termwin.is-min .term__bar { border-bottom-color: transparent; }

  /* Bigger targets for thumbs: 11px dot + 16px inset = 27px, spaced to abut. */
  .term__dots { gap: 16px; }
  .term__dot::after { inset: -8px; }

  .termlaunch {
    right: auto;
    left: 50%;
    bottom: calc(.8rem + env(safe-area-inset-bottom));
    transform: translate(-50%, 10px) scale(.96);
  }
  body:not(.term-stowed) .termlaunch.is-shown { transform: translate(-50%, 0); }

  /* The docked strip permanently covers the last ~46px of the document. */
  .foot { padding-bottom: calc(2.2rem + 46px + env(safe-area-inset-bottom)); }
}

@media (max-width: 560px) {
  .nav__name { display: none; }
  .hero__social { gap: 1.1rem; }
  .sec__title { gap: .3em; }
  .idcard__meta { grid-template-columns: repeat(2, 1fr); }
}

/* Phone widths: give the rotating phrase its own line, always.

   .hero__role is a wrapping flex row with `min-height: 1.4em` — one line
   reserved, wrapping permitted. That is fine when every phrase fits beside
   "I'm into" and fine when none of them do. It is only wrong in between, and in
   between is where the popular phones are: measured at 375, 384, 393 and 402px
   the row fits 15 characters, `ethical hacking.` is 16, so that one phrase wraps
   and the row grows 38-39px — which pushes the lede, both buttons and the social
   row down, then pulls them back when it deletes. Every few seconds, forever.
   406px is the narrowest width that holds all four phrases on one line; below
   about 366px all four wrap, so the row is a stable two lines again.

   A screenshot cannot see this and neither can an overflow check: nothing
   overflows, nothing clips, every frame is individually correct. It is only
   visible as motion, which is why it survived a desktop review.

   `flex-basis: 100%` makes the phrase unable to share a line, so the row is
   exactly two lines at every width here — no jitter and no reserved slack.
   Reserving 3.05em of min-height instead would also stop the movement, but
   would leave 38px of dead space under the short phrases.

   Applied at 430px rather than at the measured 405px on purpose. The boundary is
   a function of font metrics, so a visitor whose browser falls back off the mono
   webface moves it, and 406-430 is exactly where it would land — the widths of
   iPhone Pro Max and 14 Plus. Two lines is also the more coherent result: every
   phone portrait width from 320 to 430 now sets the phrase the same way, instead
   of Pro Max being the one phone that does it differently. There is no cost in
   vertical space at these widths; a 430px screen is 932px tall. */
@media (max-width: 430px) {
  .hero__role .type { flex-basis: 100%; }
}

/* Short and wide: a phone held sideways, or a window someone has squashed.

   --t-hero is `clamp(2.7rem, 1.4rem + 8.5vw, 8.5rem)` — width, and only width.
   Turning a phone sideways swaps the two axes, so the type scales UP on the
   measurement that just stopped being the constraint while the one that now is
   gets no say. At 844x390 the title renders at 94px, the same as on an 860px
   desktop window, inside 390px of screen. Measured result: the title alone eats
   171-333, the lede starts at 436, and the first screen is the wordmark and
   nothing else — 229px of scrolling before "View work" exists. Nothing overflows
   and nothing overlaps, which is why the layout checks all passed it; the failure
   is that the hero stops making its argument.

   So give the title a height term and tighten the vertical rhythm. Every value
   below is a measured one, not a guess: the block is sized so that the eyebrow,
   the title, the rotator, the lede, both buttons AND the social row all land
   inside 390px, which is the shortest landscape phone worth targeting.

   The lede gets a wider measure rather than a smaller font. 46ch is the right
   line length when the page is tall and reading comfort is the only concern; on
   a screen this short the third line costs more than the extra characters do,
   and 72ch of 19px text is 698px inside 774px of available width, so it wraps to
   two lines without shrinking the type anyone has to read.

   No width bound on the query. A short viewport is the problem whether it is
   844px across or 1400px, and the two existing short-screen blocks above are
   both `min-width: 861px`, so nothing here is fighting them. */
@media (orientation: landscape) and (max-height: 480px) {
  .hero {
    padding-top: calc(var(--nav-h) + 1rem);
    padding-bottom: 2rem;
  }
  .hero__title {
    /* 7vh keeps two lines of caps to ~75px at 390px tall, and the ceiling stops
       it outgrowing the width-driven value on a taller landscape screen. */
    font-size: clamp(2rem, 1rem + 7vh, 3.2rem);
    margin-bottom: .5rem;
  }
  .hero__eyebrow { margin-bottom: .5rem; }
  .hero__role    { margin-bottom: .5rem; }
  .hero__lede    { max-width: 72ch; margin-bottom: 1rem; }
  .hero__cta     { margin-bottom: 1rem; }
}


/* ── 18b touch targets ───────────────────────────────────────────
   WCAG 2.5.8 (AA) asks for 24x24 CSS px of tappable area. Three sets of links
   here are mono text at --t-xs or --t-sm on a line-height of 1, which renders
   15-18px tall — fine for a mouse, and about half a fingertip.

   Fixed with an invisible absolutely-positioned pseudo-element rather than with
   padding, and that is the whole reason this block exists as its own thing:
   padding would change the visual size, and every one of these sits in a flex
   row or column whose spacing is part of the design. The pseudo-element grows
   only the HIT area. It is the same idiom §10 already uses on .term__dot, an
   11px dot that a finger meets as 27x29.

   NOT in a media query, and not gated on (hover: none). Target size does not
   depend on viewport width — a 15px link is equally awkward on a large tablet —
   and hover media queries lie about touchscreen laptops.

   The insets are derived from the boxes these links ACTUALLY have, which are
   smaller than they look in a report that measures the anchor including its ↗
   glyph and border: --t-xs resolves to 11.04px and --t-sm to 12.9px, so on a
   line-height of 1 the boxes are 12, 13 and 14px. Sizing the expansion off the
   larger figure is how a first pass at this landed .hero__social on exactly 24px
   — passing, but with nothing in hand, one type change from regressing.

     .hero__social a    14px + 12 = 26
     .certs__act a      12px + 14 = 26
     .contact__links a  13px + 12 = 25
     .card__links a     14px + 12 = 26   (the projects' CODE↗ / LIVE↗ pair)

   The expansion is then bounded by each component's own gap, because two
   overlapping hit areas send the tap to whichever paints last, and for three
   links pointing at three different profiles that is worse than a small target.
   Two gaps had to grow to make room, and both are listed here rather than left
   as unexplained numbers elsewhere:
     .contact__links  .55rem -> .9rem   (8.8px could not hold two 6px halves)
     .certs__act      row .2rem -> .9rem (only matters if PDF↓ / Verify↗ wrap,
                       which they do not today at any width in the sweep — the
                       gap is there so that a future longer label cannot make
                       two rows of links overlap silently)

   .hero__social uses ::before because ::after there is already the animated
   red underline. */
.hero__social a::before,
.certs__act a::after,
.contact__links a::after,
.card__links a::after {
  content: '';
  position: absolute;
  z-index: 0;
}
.card__links a            { position: relative; }
.card__links a::after     { inset: -6px -4px; }
.hero__social a::before   { inset: -6px -4px; }
.certs__act              { row-gap: .9rem; }
.certs__act a             { position: relative; }
.certs__act a::after      { inset: -7px -4px; }
.contact__links          { gap: .9rem; }
.contact__links a         { position: relative; }
.contact__links a::after  { inset: -6px -6px; }


/* ── 19 reduced motion / print ───────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
  }
  .reveal { opacity: 1 !important; transform: none !important; }
  .fx-grain, .fx-scan { display: none; }
  .cursor, .cursor-ring { display: none; }
  .hero__canvas { opacity: .3; }
  .caret, .type::after { animation: none; }

  /* The blanket rule above is not enough for a sprite sheet, and the failure is
     silent: `.001ms` with one iteration still RUNS the animation, and lands on
     its LAST keyframe — which for a cel strip is the far end of the sheet, i.e.
     an empty porthole where the samurai should be standing. He needs the
     animation genuinely off so the cel rests at translate 0, which is idle
     frame 0: a still, standing guard. js/samurai.js checks the same query and
     never fires a cut here, so no beam can appear without him. */
  .sam__cel, .sam__rig { animation: none !important; transform: none !important; }
  .sam__flip { transform: scaleX(-1) !important; }   /* not motion — he still faces the page */
  .term__cut { display: none; }

  /* Belt to samurai.js's brace. It checks this query and never strikes here, so
     neither class can be added and no clone can exist — but the blanket rule
     above rewrites durations and NOT delays, so if one ever did, .is-sliced's
     920ms delay would survive it and hold a dead window on screen for the better
     part of a second. Three lines to make that unreachable from every direction:
     the third is the pill's matching 920ms wait, which would otherwise leave the
     corner empty for a second after a close that was supposed to be instant. */
  .term--ghost { display: none; }
  .termwin.is-sliced { transition-delay: 0s; }
  .termwin.is-sliced ~ .termlaunch.is-shown { transition-delay: 0s; }
}

@media print {
  .nav, .boot, .fx-grain, .fx-scan, .fx-vignette, .fx-matrix,
  .cursor, .cursor-ring, .hero__canvas, .hero__scroll,
  .termwin, .termlaunch, .sec--terminal, .skip-link { display: none !important; }
  /* Empty fields and a Send button are no use on paper. The address, the
     profiles and the resume are all in the column beside it. */
  #contactForm { display: none; }
  body { background: #fff; color: #000; }
  .reveal { opacity: 1 !important; transform: none !important; }
  /* The address only where it leads off the site: "(#work)" means nothing on
     paper, and nor does a relative path with a ?v= on the end. The links that
     already use ::after (the hero's underline, the tap areas in §18b) have it
     absolutely positioned at this same specificity, so it's put back in the
     line here or the URL lands on top of the link. text-transform because the
     hero's links are in capitals and the URL shouldn't be. overflow-wrap
     because a URL only breaks at its hyphens otherwise, and the widest piece
     of a Credly badge's was enough to push its certificate past the edge of
     the column on US Letter. */
  a[href^="http"]::after {
    content: ' (' attr(href) ')';
    position: static;
    width: auto;
    height: auto;
    background: none;
    transform: none;
    font-size: .8em;
    color: #555;
    text-transform: none;
    letter-spacing: normal;
    overflow-wrap: anywhere;
  }
  .sec { padding-block: 1.2rem; page-break-inside: avoid; }
  /* Most sections are longer than a page, so the rule above can't hold and the
     break lands wherever it falls, which was through the middle of a project
     card. These are the pieces that do fit on a page. */
  .card, .tl, .certs__list li, .skillgroup, .idcard { break-inside: avoid; }
  /* On screen the glows and the ID card's drop shadow are light and depth; on
     paper they're a pink or grey smudge round the box. */
  *, *::before, *::after { box-shadow: none !important; }
}
