/* =========================================================================
   Inside the Die — prototype stylesheet
   Deliberately standalone. Nothing here is imported by the live site, and
   this file imports nothing from it. Dark scene, restrained chrome.
   ========================================================================= */

/* Tokens, aligned to the live site rather than approximating it. Names stay
   local to this prototype, but every value below now comes from
   styles/main.css so the two cannot drift apart by eye. */
:root {
  /* Both stage buttons share one width so neither can drift from the other.
     7.5rem is what the forward pill measured when it still carried the word
     'Descend' — the label went, the footprint stayed, because a 46px circle
     beside a 121px pill was the asymmetry doing the work and shrinking it
     would have made the primary action smaller than the secondary one. */
  --nav-btn-w: 7.5rem;

  --void:      #08080b;   /* = site --bg  (was #07080b, a shade off) */
  --panel:     #14141c;   /* = site --paper (was #101319) */
  --line:      rgba(255,255,255,0.08);  /* = the site's top-bar hairline */
  --text:      #fdfdfb;   /* = site --ink */
  --muted:     rgba(253,253,251,0.72);  /* = site --soft-ink */
  --accent:    #6b2fc9;   /* = site --fill: solid, carries white text */
  --accent-lit:#a97bff;   /* = site --accent: text and strokes on the page */
  --fill-hover:#7b3ce0;   /* = site --fill-hover */
  --copper:    #d08a4e;   /* scene-only, no site counterpart */

  /* Geist, matching the site. The old stack named Bricolage Grotesque and this
     page never loaded it, so the whole UI had been rendering in a fallback. */
  --font-ui:   "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}

/* The site's ambient hue: one violet field in the top-left, pinned to the
   VIEWPORT. On the live site this rides body::before; here the scene owns the
   canvas, so it goes on its own layer under everything and above the void.
   Fixed rather than background-attachment, which iOS Safari ignores. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 0;                 /* under #stage (z-index 1), over the page ground */
  pointer-events: none;
  background: radial-gradient(56rem 40rem at 13% -6%,
              rgba(107, 47, 201, 0.26), transparent 64%);
}

* { box-sizing: border-box; }

html { scroll-behavior: auto; }

body {
  margin: 0;
  background: var(--void);
  color: var(--text);
  font-family: var(--font-ui);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

/* ------------------------------------------------------------------ *
   Loader
 * ------------------------------------------------------------------ */
#loader {
  position: fixed; inset: 0; z-index: 90;
  display: grid; place-items: center;
  background: var(--void);
  transition: opacity .6s ease;
}
#loader.done { opacity: 0; pointer-events: none; }
.loader-inner {
  display: flex; align-items: center; gap: .7rem;
  font-family: var(--font-mono); font-size: .8rem;
  letter-spacing: .08em; text-transform: uppercase; color: var(--muted);
}
.spark {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent-lit);
  box-shadow: 0 0 14px 2px var(--accent);
  animation: pulse 1.3s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity:.25; transform:scale(.75);} 50% { opacity:1; transform:scale(1.15);} }

/* ------------------------------------------------------------------ *
   Sticky stage
 * ------------------------------------------------------------------ */
#stage {
  position: fixed; inset: 0;
  z-index: 1;
}
#gl { display: block; width: 100%; height: 100%; }

/* seven stages: one per arrow stop, so the rail, the counter and the arrows
   all agree about where you are */
/* The descent is no longer a scrolling document: it is played leg by leg by the
   arrows, so the page is exactly one viewport tall and never scrolls. The old
   2000vh #scroll-track is gone rather than hidden, because a page that can still
   be scrolled by a trackpad while the animation is under someone else's control
   is a page that fights the user. */
html, body { height: 100%; overflow: hidden; overscroll-behavior: none; }

/* ------------------------------------------------------------------ *
   Chrome — top bar
 * ------------------------------------------------------------------ */
/* ---- the site bar --------------------------------------------------
   Metrics copied from styles/main.css, not eyeballed: 4rem tall, a 1400px
   rail with 40px inset, 80% ground behind a 24px blur, and an 8% white
   hairline. Keep these in step if the site's bar changes. */
/* The bar gets out of the way once the descent starts. It is permanent on
   arrival, because that is when a visitor needs to know whose site this is and
   how to leave it; from the first press of the forward arrow the scene owns the
   viewport and a fixed band of chrome across the top of it is just a border.

   Hovering the top edge brings it back and leaving it sends it away again, so it
   behaves like the browser's own auto-hiding fullscreen chrome — a convention
   people already have.

   `visibility` rides the transition so a tucked bar cannot be tabbed into by
   accident, and `:focus-within` overrides that for anyone who tabs to it
   deliberately, which is the case a pointer-only rule would strand. */
#sitebar {
  position: absolute; top: 0; left: 0; right: 0;
  /* 70 to match .topbar in styles/main.css, and it has to be above the drawer
     (60) rather than below it: the desktop drawer is designed to drop straight
     down OUT FROM BEHIND the bar. At 40 it came down over the top of it and
     covered the wordmark, which reads as a panel that has replaced the page
     rather than one the page has opened. */
  z-index: 70;
  height: 4rem;
  transition: transform .42s cubic-bezier(.2,.7,.3,1), visibility 0s;
  background: color-mix(in srgb, var(--void) 80%, transparent);
  -webkit-backdrop-filter: saturate(1.1) blur(24px);
  backdrop-filter: saturate(1.1) blur(24px);
  border-bottom: 1px solid var(--line);
}
#sitebar.tucked {
  transform: translateY(-100%);
  visibility: hidden;
  transition: transform .42s cubic-bezier(.2,.7,.3,1), visibility 0s .42s;
}
#sitebar.tucked.peek,
#sitebar.tucked:focus-within {
  transform: none;
  visibility: visible;
  transition: transform .34s cubic-bezier(.2,.7,.3,1), visibility 0s;
}

.sitebar__inner {
  height: 100%;
  max-width: 1400px;
  margin-inline: auto;
  padding-inline: 40px;
  display: grid;
  grid-template-columns: auto auto auto 1fr auto;
  align-items: center;
  gap: 1rem;
}
.wordmark {
  justify-self: start;
  display: inline-flex; align-items: center; gap: .625rem;
  /* Two words on two lines doubled the bar's height on a phone. */
  white-space: nowrap;
  font-size: .95rem; font-weight: 600; letter-spacing: -0.025em;
  color: var(--text); text-decoration: none;
}
.wordmark img { width: 26px; height: 26px; object-fit: contain; flex: none; }

/* A hairline between the brand and the page it is showing, then the subject.
   Quiet on purpose: the wordmark is the identity, this is only context. */
.sitebar__page {
  width: 1px; height: 1.1rem;
  background: var(--line);
  justify-self: start;
}
.sitebar__subject {
  justify-self: start;
  font-size: .875rem; color: var(--muted);
  white-space: nowrap;
}

.sitenav { justify-self: end; display: flex; align-items: center; gap: 2rem; }
.sitenav a {
  font-size: .875rem; font-weight: 400;
  color: var(--muted); text-decoration: none;
  transition: color .15s ease;
}
/* Hover is a color lift and nothing else. There is deliberately NO animated
   underline here, even though `.nav a` in styles/main.css draws one on every
   other page.

   That difference is intentional, not an oversight, and this note exists because
   it was already "fixed" once: the bar is a hand copy of the site's (this page
   loads none of main.css), the missing underline read as a piece that had failed
   to come across, and it was added back. It is not wanted. Three text links sit
   in this bar and two of them — the current page and the filled pill — must not
   carry an underline anyway, so the rule only ever reached About, and a single
   underlined item in a row of three reads as an inconsistency rather than an
   affordance. The color change already says the link is live.

   If you are here because you compared this file against main.css and found the
   underline missing: that is the expected diff. Leave it. */
.sitenav a:hover { color: var(--text); }
/* The link to the page you are already on reads as current, not as a way out. */
.sitenav a[aria-current="page"] { color: var(--text); cursor: default; }
/* The filled pill trails the text links, as it does on the site. */
/* Now a <button>, not an <a>, because it opens the drawer rather than going
   anywhere. Buttons take none of the inherited type or the reset above, so the
   font and the border have to be stated or the pill renders in the UA's
   13px system face inside a gray chrome frame. */
.sitenav__pill {
  appearance: none; -webkit-appearance: none;
  border: 0;
  font-family: inherit;
  font-size: .875rem;
  cursor: pointer;
  background: var(--accent);
  color: #fff !important;
  border-radius: 999px;
  padding: .5rem 1rem;
  font-weight: 500 !important;
  line-height: 1.25rem;
  transition: background .18s ease;
}
.sitenav__pill:hover { background: var(--fill-hover); }

.ghlink {
  justify-self: end;
  display: grid; place-items: center;
  width: 2.1rem; height: 2.1rem;
  color: var(--muted);
  transition: color .18s ease;
}
.ghlink svg { width: 1.35rem; height: 1.35rem; fill: currentColor; }
.ghlink:hover { color: var(--text); }

/* ---- the subject strip, on its own row under the bar --------------- */
#chrome {
  position: absolute; top: 4rem; left: 0; right: 0;
  pointer-events: none;
}
.chrome__inner {
  max-width: 1400px;
  margin-inline: auto;
  padding: .95rem 40px;
  display: flex; justify-content: space-between; align-items: center;
  gap: 1rem; flex-wrap: wrap;
}
.chip-id {
  display: flex; align-items: center; gap: .55rem;
  font-size: .92rem; font-weight: 600; letter-spacing: .01em;
}
.chip-id .dot {
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--accent-lit); box-shadow: 0 0 12px var(--accent);
}

.specs {
  display: flex; gap: 1.4rem;
  font-family: var(--font-mono); font-size: .72rem;
  color: var(--muted); letter-spacing: .02em;
}
.specs b { color: var(--text); font-weight: 500; }

/* Narrow screens: the nav links collapse and the pill carries the way back,
   the same trade the site makes at this width. The specs wrap under the chip
   name rather than being dropped — they are the point of the page. */
@media (max-width: 900px) {
  .sitebar__inner { padding-inline: 20px; gap: .75rem; }
  .sitenav a:not(.sitenav__pill) { display: none; }
  .sitebar__page, .sitebar__subject { display: none; }
  .sitenav { gap: 0; }
  .wordmark { font-size: .8rem; gap: .5rem; }
  .wordmark img { width: 22px; height: 22px; }
  /* The pill tightens rather than the wordmark truncating: the brand name is
     worth more than a few pixels of button padding. */
  .sitenav__pill { padding: .45rem .7rem; font-size: .8rem; }
  .ghlink { width: 1.7rem; height: 1.7rem; }
  .ghlink svg { width: 1.15rem; height: 1.15rem; }
  .chrome__inner { padding-inline: 20px; }
  .specs { gap: .9rem; font-size: .64rem; }
}

/* ------------------------------------------------------------------ *
   Caption block
 * ------------------------------------------------------------------ */
#caption {
  position: absolute;
  left: clamp(1rem, 4vw, 2.6rem);
  bottom: clamp(2.4rem, 7vh, 4.4rem);
  max-width: 27rem;
  pointer-events: none;
  isolation: isolate;
}
/* soft scrim so the caption stays legible wherever the die happens to sit */
#caption::before {
  content: "";
  position: absolute;
  inset: -4rem -9rem -5rem -5rem;
  z-index: -1;
  background: radial-gradient(60% 62% at 26% 58%,
              rgba(8,8,11,.94) 0%, rgba(8,8,11,.78) 42%, rgba(8,8,11,0) 100%);
}
/* and a matching one behind the top bar */
#chrome::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 190%;
  z-index: -1;
  background: linear-gradient(180deg, rgba(8,8,11,.85), rgba(8,8,11,0));
}
#chrome { position: absolute; isolation: isolate; }
.cap-index {
  font-family: var(--font-mono); font-size: .72rem;
  letter-spacing: .14em; color: var(--muted); margin-bottom: .5rem;
}
#cap-num { color: var(--accent-lit); }
.cap-of { margin-left: .3rem; }

/* The phone's caption toggle does not exist here. `display: none` rather than a
   visibility or opacity trick, so it contributes no box and cannot change a
   single desktop measurement. The phone scheme turns it back on. */
.cap-toggle { display: none; }

/* Likewise the reveal wrapper: `display: contents` removes the box entirely and
   leaves #cap-body a direct child of #caption for every layout purpose here. */
.cap-reveal { display: contents; }

/* ---- the site's hamburger, republished ------------------------------
   Copied from styles/main.css rather than reinvented, because it is the same
   control doing the same job on every other page: 2.6rem box, hairline edge,
   three 18x2 bars that fold into an X while the drawer is out. This page
   loads no site CSS, so the rule has to live here — the same duplication the
   top bar itself already accepts, and it must be kept in step for the same
   reason.

   `display: none` here and turned on in the phone scheme, exactly as main.css
   does it, so the desktop bar keeps its inline links and filled pill. */
.nav-toggle {
  display: none;
  width: 2.6rem; height: 2.6rem;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 1px solid var(--line);
  border-radius: 10px;
  cursor: pointer;
}
.nav-toggle__bars,
.nav-toggle__bars::before,
.nav-toggle__bars::after {
  content: "";
  display: block;
  width: 18px; height: 2px;
  background: var(--text);
  border-radius: 2px;
  transition: transform .2s cubic-bezier(0.22, 0.61, 0.36, 1),
              opacity .2s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.nav-toggle__bars::before { transform: translateY(-6px); }
.nav-toggle__bars::after  { transform: translateY(4px); }

#caption h2 {
  margin: 0 0 .5rem;
  font-size: clamp(1.55rem, 3.6vw, 2.4rem);
  font-weight: 600; line-height: 1.1; letter-spacing: -0.015em;
}
#caption p {
  margin: 0; color: var(--muted);
  font-size: .97rem; line-height: 1.55; max-width: 24rem;
}
/* the swap animation is driven by a class toggle in JS */
#caption.swap h2, #caption.swap p, #caption.swap .cap-index {
  animation: capIn .55s cubic-bezier(.2,.7,.3,1) both;
}
#caption.swap p { animation-delay: .06s; }
@keyframes capIn { from { opacity:0; transform: translateY(11px);} to { opacity:1; transform:none;} }

/* ------------------------------------------------------------------ *
   Progress rail
 * ------------------------------------------------------------------ */
#rail {
  position: absolute;
  right: clamp(.9rem, 2.6vw, 1.8rem);
  top: 50%; transform: translateY(-50%);
  width: 2px; height: 34vh;
  background: rgba(255,255,255,.12);
  border-radius: 2px;
}
#rail-ticks { position: absolute; inset: 0; }
/* one tick per stage, so the rail says WHERE you are in the descent rather than
   only how far. The lit one is the stage the caption is showing. */
#rail-ticks i {
  position: absolute; left: 50%; transform: translateX(-50%);
  width: 6px; height: 1px;
  background: rgba(255,255,255,.28);
  transition: background .3s ease, width .3s ease, box-shadow .3s ease;
}
#rail-ticks i.on {
  width: 11px; height: 2px;
  background: var(--accent-lit);
  box-shadow: 0 0 8px 1px var(--accent);
}
#rail-fill {
  width: 100%; height: 0%;
  background: linear-gradient(180deg, var(--accent-lit), var(--accent));
  border-radius: 2px;
  box-shadow: 0 0 10px rgba(169,123,255,.55);
}

/* ------------------------------------------------------------------ *
   Scroll hint
 * ------------------------------------------------------------------ */
/* Sits directly ABOVE the nav control it describes, not adrift at the bottom of
   the frame while the control lived in a corner. "the control below" only works
   if it is in fact below. */
#hint {
  position: absolute;
  bottom: calc(clamp(1.1rem, 3.4vh, 2.2rem) + 3.7rem);
  left: 50%; transform: translateX(-50%);
  font-family: var(--font-mono); font-size: .66rem;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted);
  transition: opacity .5s ease;
  pointer-events: none;
  text-align: center;
  text-shadow: 0 1px 6px rgba(8,8,11,.95), 0 0 14px rgba(8,8,11,.8);
}
/* Each line fades on its own, so the click line can be up at the floorplan while
   the keyboard line is long gone. Opacity only: the block stays laid out either
   way, so neither line ever moves when the other leaves. */
#hint span {
  display: block; white-space: nowrap; line-height: 2;
  opacity: 0; transition: opacity .5s ease;
}
#hint span.show { opacity: 1; }
/* The click line is the only sentence on the page that says what a block DOES,
   and it is carrying that alone for anyone who has not moved the mouse yet. The
   keyboard line is a different job — it names a control that is visible three
   centimeters below it — so the two are not the same size any more.

   Brighter as well as bigger. At --muted against a photograph of a die this
   line was competing with the thing it was describing; --text with the same
   shadow behind it wins that without becoming page furniture. */
#hint .hint-click {
  font-size: .86rem;
  color: var(--text);
  letter-spacing: .14em;
}
#hint kbd {
  font-family: inherit; font-size: .95em;
  padding: .12em .38em;
  border: 1px solid rgba(255,255,255,.26);
  border-radius: 4px;
  color: var(--text);
}
.arrow { display: inline-block; animation: bob 1.9s ease-in-out infinite; }
@keyframes bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(4px)} }


/* ------------------------------------------------------------------ *
   Block sheet — title and text on the left, video player on the right, both
   sitting directly on the blurred scene. Freezes the descent while open.

   No solid card: the scene stays visible through a heavy blur, so the block you
   just clicked is still there behind the words rather than replaced by a panel.

   The alignment is the fiddly part. `align-content: center` centers the single
   grid ROW in the viewport, and `align-items: start` then puts both columns at
   the top of that row. The row is as tall as the player, so the title's cap
   height lands level with the top of the player rather than at the top of the
   screen — which is what pulls the whole thing down into the middle.
 * ------------------------------------------------------------------ */
#sheet {
  position: fixed; inset: 0; z-index: 80;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
  align-content: center;
  align-items: start;
  gap: clamp(1.6rem, 4vw, 4.5rem);
  padding: clamp(2.2rem, 6vh, 5rem) clamp(1.6rem, 5vw, 5.5rem);
  background: rgba(5, 6, 9, .58);
  backdrop-filter: blur(22px) saturate(.85);
  -webkit-backdrop-filter: blur(22px) saturate(.85);
  animation: sheetIn .3s cubic-bezier(.2,.7,.3,1) both;
}
#sheet[hidden] { display: none; }
@keyframes sheetIn { from { opacity: 0; } to { opacity: 1; } }

/* --- left: the name, then the text --- */
.sheet-copy {
  /* No measure cap. The column already has a width: the grid gives it the room
     between the sheet's left padding and the gap before the player, and a
     36rem cap left that room half empty on a wide screen while the player
     filled its own column completely. The copy now runs to the player's edge
     and stops there, which is the line the eye already reads as the edge of
     the text. */
  animation: sheetSlide .38s cubic-bezier(.2,.7,.3,1) both;
  /* The sheet centers its row in the window, so a column taller than the
     window clips at BOTH ends and the title is the first thing to go. Three
     paragraphs and a figure under them can reach 857px, and a 768px laptop
     has 676px to give them. So the column may scroll, but only once
     it is taller than the window: the cap is the sheet's own height less its
     vertical padding, and a column that fits never shows a bar. */
  max-height: calc(100vh - 2 * clamp(2.2rem, 6vh, 5rem));
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(253,253,251,.22) transparent;
  /* Room between the text and a bar when one appears, so the bar does not sit
     on the ends of the lines. Taken from the gap, not added to the column. */
  padding-right: .6rem;
  margin-right: -.6rem;
}
@keyframes sheetSlide { from { opacity:0; transform: translateY(14px);} to { opacity:1; transform:none;} }

#sheet-title {
  /* Nudged up by the difference between the line box and the cap height, so the
     letters line up with the player's edge rather than the text box doing. */
  margin: -0.14em 0 1rem;
  font-size: clamp(1.7rem, 3.4vw, 2.6rem);
  font-weight: 600; line-height: 1.08; letter-spacing: -0.02em;
  text-wrap: balance;
}
.sheet-body { color: #c2c9d6; font-size: clamp(.94rem, 1.05vw, 1.02rem); line-height: 1.65; }
.sheet-body p { margin: 0 0 .9rem; }
.sheet-body p:last-child { margin-bottom: 0; }

/* --- the subject's own figure, under the prose it illustrates ---
   The drawings are ink on white, so the figure keeps that white rather than
   being tinted to the panel, on the same argument as the lesson-card thumbnail
   above. The height cap is what keeps the sheet inside a short window: this
   panel does not scroll on desktop, and a wide diagram at full column width is
   tall enough to push the last paragraph off the top. */
.sheet-figure {
  margin: 1.4rem 0 0;
  animation: sheetSlide .38s .08s cubic-bezier(.2,.7,.3,1) both;
}
.sheet-figure[hidden] { display: none; }
.sheet-figure__img {
  display: block;
  /* Sized by the caps rather than by the column, so the height cap shrinks the
     BOX and not just the drawing inside it. At `width: 100%` the box keeps the
     full column width once the cap bites, and the white sits out to the right
     of the diagram as an empty margin. */
  width: auto; height: auto;
  max-width: 100%; max-height: 30vh;
  /* Centered in the column, which only shows on the figures the height cap makes
     narrower than the column: a square diagram pinned left under a 700px column
     reads as a hole in the layout rather than as a picture. The wide ones fill
     the column and centering does nothing to them. */
  margin-inline: auto;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: #fff;
}
/* --- callouts on a figure ---
   The frame shrinks to the picture, so percentages on the overlay and the
   labels are percentages of the picture and nothing else. The SVG's viewBox
   is set to the picture's aspect when it loads, so u,v from the tracer lands
   on the right pixels and an arrowhead sized in user units keeps its shape.
   Shafts are non-scaling strokes, a hairline at any size. Each group is one
   color on pill and arrows alike, so the eye pairs them without reading:
   the page's fill purple, --accent, for the first, and the floorplan's
   Load/Store blue, --callout-blue, for the second; both carry white text and
   both read against the micrograph's amber. The arrowhead fills in index.html
   are the same two written out, since a marker cannot read the page's
   tokens. */
.sheet-figure__frame { --callout-blue: #4a76d9; }
.sheet-figure__frame {
  position: relative;
  display: block;
  width: fit-content;
  margin-inline: auto;
}
.sheet-figure__frame .sheet-figure__img { margin: 0; }
.sheet-figure__overlay {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  overflow: visible;
}
.sheet-callout__arrow {
  stroke: var(--accent); stroke-width: 1.5px;
  vector-effect: non-scaling-stroke;
  marker-end: url(#sheet-arrowhead-0);
}
.sheet-callout__arrow--1 { stroke: var(--callout-blue); marker-end: url(#sheet-arrowhead-1); }
/* The pill's top-center sits on its `at`; the arrow leaves from whichever
   of its measured edges faces the pads, so the shaft never crosses the pill. */
.sheet-callout__label {
  position: absolute;
  left: var(--at-u); top: var(--at-v);
  transform: translate(-50%, 0);
  padding: .18rem .5rem;
  border-radius: 999px;
  font-size: .72rem; font-weight: 600;
  letter-spacing: .06em; text-transform: uppercase;
  white-space: nowrap;
  color: #fff;
  background: var(--accent);
  box-shadow: 0 2px 10px rgba(0,0,0,.45);
}
.sheet-callout__label--1 { background: var(--callout-blue); }

/* Two pictures side by side, each under its own label, in the media column
   under the player. The grid splits that column evenly so the two die shots
   read at one scale, and each is capped to the same 30vh as the single figure
   so the column stays inside a short window. Nested <figure>s carry no margin
   of their own: the outer figure supplies the gap to the player. */
.sheet-figure--pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  align-items: start;
  margin-top: 1.1rem;
}
.sheet-figure__item { margin: 0; min-width: 0; }
.sheet-figure__label {
  margin: 0 0 .45rem;
  font-size: .78rem; font-weight: 600;
  letter-spacing: .08em; text-transform: uppercase;
  color: var(--muted);
  /* The picture under it is centered in its cell by the single figure's
     margin-inline: auto, and a label pinned to the cell's left edge then floats
     off to one side of it. Centered, it sits over the picture it names. */
  text-align: center;
}
/* Die shots are photographs, not ink on white, so the white ground that suits
   the drawings would only frame them in a pale border. */
.sheet-figure--pair .sheet-figure__img { background: #000; }

/* --- lesson cards: the way out to the lesson that teaches this block ---
   Shaped after the home page's `.mtp-card`: thumbnail, a kicker and a title, an
   arrow that slides on hover. Reproduced here in this page's own tokens rather
   than shared, for the same reason the top bar is reproduced — this page loads
   none of the site's CSS. Keep the two in step by eye if the home card changes.

   The thumbnail is a real lesson figure, not an icon. A reader who has seen the
   R-type diagram recognizes where they are being sent before they read the
   title, which is the whole job of the picture. */
.sheet-links {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: .7rem;
  margin-top: 1.6rem;
  animation: sheetSlide .38s .12s cubic-bezier(.2,.7,.3,1) both;
}
.sheet-links[hidden] { display: none; }

.lesson-card {
  display: grid;
  grid-template-columns: 4.4rem 1fr auto;
  align-items: center;
  gap: .8rem;
  padding: .6rem .8rem .6rem .6rem;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: rgba(20, 20, 28, .62);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  text-decoration: none;
  transition: border-color .22s ease, background .22s ease, transform .22s ease;
}
.lesson-card:hover,
.lesson-card:focus-visible {
  border-color: color-mix(in srgb, var(--accent-lit) 46%, transparent);
  background: rgba(28, 24, 42, .78);
  transform: translateY(-2px);
}
.lesson-card:focus-visible { outline: 2px solid var(--accent-lit); outline-offset: 3px; }

/* The figures are light diagrams on white, so they keep their own background
   and sit on a plain surface. Tinting them to match the panel would misrepresent
   the artwork the reader is about to land on. */
.lesson-card__art {
  width: 4.4rem; aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 7px;
  background: #fff;
  border: 1px solid var(--line);
}
.lesson-card__label { display: grid; gap: .15rem; min-width: 0; }
.lesson-card__kicker {
  font-family: var(--font-mono); font-size: .58rem;
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--accent-lit);
}
.lesson-card__title {
  font-size: .92rem; font-weight: 500; color: var(--text);
  line-height: 1.25;
}
.lesson-card__arrow {
  color: var(--muted);
  transition: transform .22s ease, color .22s ease;
}
.lesson-card__arrow svg { width: 18px; height: 18px; display: block;
  fill: none; stroke: currentColor; stroke-width: 2;
  stroke-linecap: round; stroke-linejoin: round; }
.lesson-card:hover .lesson-card__arrow,
.lesson-card:focus-visible .lesson-card__arrow {
  transform: translateX(3px); color: var(--text);
}

/* --- right: the player, blank until a video is dropped in --- */
/* Centered in the row rather than aligned to the top of the copy. The grid row
   is as tall as the taller column and the sheet centers its rows, so this puts
   the player in the middle of the screen. It only moves on the sheets whose copy
   is taller than the player, which is why the title-to-player alignment above is
   still worth its nudge: on the short sheets the two are the same thing. */
.sheet-media { justify-self: stretch; align-self: center;
               animation: sheetSlide .38s .05s cubic-bezier(.2,.7,.3,1) both; }
/* With a pair of pictures under the player this is the tall column, so it
   takes the same cap as the copy: scroll only once it outgrows the window.
   :has() scopes it to the sheets that carry a pair; the others keep the
   player centered on the copy as before. */
.sheet-media:has(.sheet-figure--pair:not([hidden])) {
  max-height: calc(100vh - 2 * clamp(2.2rem, 6vh, 5rem));
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(253,253,251,.22) transparent;
}
.sheet-media video {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #040507;
  border: 1px solid var(--line);
  border-radius: 12px;
  box-shadow: 0 30px 70px -28px rgba(0,0,0,.85);
}
.sheet-media-note {
  margin: .7rem 0 0;
  font-family: var(--font-mono); font-size: .68rem;
  letter-spacing: .12em; text-transform: uppercase; color: var(--muted);
  text-align: center;
}
.sheet-media.has-video .sheet-media-note { display: none; }

.sheet-x {
  position: absolute;
  top: clamp(1rem, 2.4vh, 1.8rem); right: clamp(1rem, 2.4vw, 2rem);
  width: 40px; height: 40px; border-radius: 50%;
  display: grid; place-items: center;
  font-size: 1.6rem; line-height: 1;
  color: var(--text); cursor: pointer;
  background: rgba(10,12,17,.6);
  border: 1px solid var(--line);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  transition: border-color .2s ease, color .2s ease, transform .2s ease;
}
.sheet-x:hover { border-color: var(--accent-lit); color: #fff; transform: scale(1.06); }
.sheet-x:focus-visible { outline: 2px solid var(--accent-lit); outline-offset: 3px; }

/* Stacked on narrow screens: name, text, then the player under them. */
@media (max-width: 900px) {
  #sheet {
    grid-template-columns: 1fr;
    align-content: start;
    gap: 1.4rem;
    padding: 4.2rem clamp(1.1rem, 5vw, 2rem) 2rem;
    overflow-y: auto;
  }
  /* The whole sheet scrolls on a phone, so the column needs no cap of its own:
     a capped column would be a scroll region inside a scroll region. */
  .sheet-copy { max-width: none; max-height: none; overflow: visible;
                padding-right: 0; margin-right: 0; }
  .sheet-media:has(.sheet-figure--pair:not([hidden])) { max-height: none; overflow: visible; }
  /* Callout pills on a phone: the picture is under 100px tall there and a
     pill is a third of its width, so the pills are set smaller and pinned into
     the picture's top corners, first left and second right, rather than
     placed by `at`; centered on `at` they ran off the edges. The arrows start
     from wherever a pill actually is, so they follow on their own. */
  .sheet-callout__label { top: .25rem; font-size: .6rem; padding: .12rem .4rem; transform: none; }
  .sheet-callout__label--0 { left: .25rem; }
  .sheet-callout__label--1 { left: auto; right: .25rem; }
  #sheet-title { font-size: 1.6rem; margin-top: 0; }
  /* The sheet scrolls on a phone, so the figure has room for its full height
     and the desktop cap would only shrink a diagram that is already narrow. */
  .sheet-figure__img { max-height: none; }
}

/* ------------------------------------------------------------------ *
   Fallbacks
 * ------------------------------------------------------------------ */
.fallback {
  position: fixed; inset: 0; display: grid; place-items: center;
  padding: 2rem; text-align: center; color: var(--muted);
}

/* ------------------------------------------------------------------ *
   Responsive
 * ------------------------------------------------------------------ */
@media (max-width: 900px) {
  .specs { display: none; }
  /* The cell pins come off on a phone. In portrait the caption is a full-width
     block across the middle of the screen and the cell sits behind it, so all
     four labels land on top of a paragraph — four floating boxes over running
     text is worse than no labels at all. The caption still names every terminal
     in prose, which is the same trade the keyboard hint makes below: an
     affordance that only works with room to put it in is desktop-only. */
  /* Clears the hint and the centered nav stacked beneath it. */
  #caption { bottom: 8.8rem; max-width: none; right: clamp(1rem,4vw,2.6rem); }
  #caption h2 { font-size: 1.4rem; }
  #caption p { font-size: .88rem; }
  #rail { height: 24vh; }
  /* Needs the #hint prefix: `#hint span { display: block }` above outranks a bare
     class selector, so `.hint-keys { display: none }` alone loses. */
  #hint .hint-keys { display: none; }
  #hint { font-size: .6rem; }
  /* Scaled from the phone's .6rem base by the same ratio the desktop uses, not
     carried over at .86rem — the root steps down at this width and a fixed rem
     here would be proportionally half again as large as it is on a desktop. */
  #hint .hint-click { font-size: .78rem; letter-spacing: .12em; }
  /* There is no corner free at this width, so the credit goes centered under the
     nav. It is one line now, which is what makes that possible: the block that
     used to live here was four lines deep and the disclaimer had to be dropped
     outright. Behind the toggle it survives at this width for the first time,
     and the panel is centered on the viewport rather than hung off a right edge
     that no longer exists. */
  #credit {
    right: auto; left: 50%; transform: translateX(-50%);
    bottom: .35rem; max-width: none; width: 96vw;
    text-align: center; font-size: .56rem; line-height: 1.6;
  }
  .credit-toggle { margin-left: .45em; }
  #credit-panel {
    right: 50%; transform: translateX(50%);
    width: 92vw; font-size: .7rem;
  }
  /* Full-width thumb targets beat a pretty pill on a phone. The vertical
     stacking of nav, hint, caption and credit at this width is not here: it has
     to come after the nav section to win the cascade, so it is at the foot of
     the file. */
  #nav { gap: .5rem; }
  .nav-btn { height: 3.1rem; }
  .nav-back { width: 3.1rem; }
  .nav-fwd { padding: 0 .95rem 0 1.15rem; }
}

/* ------------------------------------------------------------------ *
   Reduced motion — the scene still renders, but the camera settles
   immediately instead of easing, and decorative loops stop.
 * ------------------------------------------------------------------ */
/* No hover, no way back. On a touch device the bar stays put: the whole scheme
   depends on a pointer being able to reach the top edge, and there is no
   equivalent gesture here that would not also fight the scene's own drag. */
@media (hover: none) {
  #sitebar.tucked { transform: none; visibility: visible; }
}

@media (prefers-reduced-motion: reduce) {
  .spark, .arrow { animation: none; }
  .nav-fwd::after, .nav-fwd svg { animation: none; }
  #caption.swap h2, #caption.swap p, #caption.swap .cap-index { animation: none; }
  #sheet, .sheet-card { animation: none; }
  #credit-panel { animation: none; }
  /* The bar still hides and still comes back; it just stops sliding. */
  #sitebar { transition: none; }
  #sitebar.tucked { transition: none; }
}

/* ------------------------------------------------------------------ *
   Credit and disclaimer — bottom right

   One permanent line, plus a toggle that opens the rest.

   The split is by obligation, not by length. Crediting the photograph is the
   condition this page carries, so that line is always on screen and never
   depends on anybody opening anything. The disclaimer about the annotations is
   a note rather than a condition, so it goes behind the toggle — and the moment
   it is behind a toggle it can be set at a size somebody might actually read,
   which at 0.56rem in a permanent corner it never was.

   What that buys: the corner shrinks from a four-line block to a single line, so
   it stops competing with the scene for the whole descent; the long text becomes
   legible instead of merely present; and the phone, which used to drop the
   disclaimer entirely for want of room, now gets all of it.

   Right-aligned into the one corner nothing else claims. The caption owns the
   bottom left, the hint and the nav own the bottom center, the rail owns the
   right edge above it.
 * ------------------------------------------------------------------ */
#credit {
  position: absolute;
  right: clamp(.9rem, 2.6vw, 1.8rem);
  bottom: clamp(1.1rem, 3.4vh, 2.2rem);
  z-index: 5;
  max-width: 26rem;
  text-align: right;
  /* The footer holds a single control now, so it sets no type of its own —
     .credit-toggle sizes itself and the panel sizes its prose. */
  line-height: 1.7;
  color: var(--muted);
  text-shadow: 0 1px 5px rgba(8,8,11,.95), 0 0 12px rgba(8,8,11,.85);
  pointer-events: none;          /* never in the way of a click on the die... */
}
/* A scrim, the same device the caption uses. A text-shadow alone is enough over
   the black opening frames and nowhere near enough at the core stop, where the
   background behind this corner is a fully lit die and the type washed out
   completely. Tighter than it was, because it now wraps one line. */
#credit { isolation: isolate; }
#credit::before {
  content: "";
  position: absolute;
  inset: -1rem -1.6rem -1rem -2.6rem;
  z-index: -1;
  background: radial-gradient(74% 76% at 78% 50%,
              rgba(8,8,11,.88) 0%, rgba(8,8,11,.64) 46%, rgba(8,8,11,0) 100%);
}
#credit a {
  color: rgba(169, 123, 255, .85);
  text-decoration: none;
  border-bottom: 1px solid rgba(169, 123, 255, .3);
  pointer-events: auto;          /* ...except the links themselves */
}
#credit a:hover { color: var(--accent-lit); border-bottom-color: var(--accent-lit); }
#credit p { margin: 0; }

/* The toggle. A word, not a bare glyph: "ⓘ" alone in a corner is a mystery box,
   and the one thing a viewer needs to know before deciding to open it is that
   this is about the ANNOTATIONS rather than about the site. The small badge in
   front of it is what makes it read as a control at this weight. */
.credit-toggle {
  pointer-events: auto;
  display: inline-flex; align-items: center; gap: .34em;
  /* No left margin: nothing precedes it any more. Sized in rem rather than em
     because it no longer inherits a parent line's size — it IS the line. */
  margin-left: 0;
  padding: .38rem .8rem .38rem .5rem;
  font-family: var(--font-ui);
  font-size: .8rem;
  font-weight: 500;
  letter-spacing: 0;
  color: var(--muted);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 999px;
  cursor: pointer;
  transition: color .2s ease, border-color .2s ease, background .2s ease;
}
.credit-toggle:hover,
.credit-toggle[aria-expanded="true"] {
  color: var(--text);
  border-color: var(--line);
  background: rgba(255,255,255,.06);
}
.credit-toggle:focus-visible { outline: 2px solid var(--accent-lit); outline-offset: 2px; }
.credit-i {
  display: grid; place-items: center;
  width: 1.25em; height: 1.25em;
  border-radius: 50%;
  border: 1px solid currentColor;
  font-family: var(--font-mono); font-size: .85em; line-height: 1;
  font-style: italic;
}

/* The panel opens UPWARD out of the line, since the line is already at the
   bottom of the frame. Left-aligned even though the trigger is right-aligned:
   this is two sentences of prose, and prose is read from a straight left edge. */
#credit-panel {
  position: absolute;
  right: 0; bottom: calc(100% + .55rem);
  width: min(30rem, 78vw);
  padding: .95rem 1.05rem 1rem;
  text-align: left;
  font-size: .74rem;
  line-height: 1.62;
  letter-spacing: 0;
  color: #b8c0cd;
  text-shadow: none;
  background: rgba(10, 12, 17, .82);
  border: 1px solid var(--line);
  border-radius: 11px;
  backdrop-filter: blur(14px) saturate(.9);
  -webkit-backdrop-filter: blur(14px) saturate(.9);
  box-shadow: 0 24px 56px -26px rgba(0,0,0,.95);
  pointer-events: auto;
  animation: creditIn .22s cubic-bezier(.2,.7,.3,1) both;
}
#credit-panel[hidden] { display: none; }
/* The processor's numbers, where "About this model" promises them. A dl rather
   than a paragraph because they are label/value pairs, and tabular figures so
   the values line up. */
.model-specs {
  margin: 0 0 .95rem;
  padding: 0 0 .85rem;
  border-bottom: 1px solid var(--line);
  display: grid; gap: .3rem;
}
.model-specs > div { display: flex; justify-content: space-between; gap: 1.5rem; }
.model-specs dt { color: var(--muted); }
.model-specs dd {
  margin: 0; color: var(--text);
  font-family: var(--font-mono); font-size: .92em;
  font-variant-numeric: tabular-nums;
}

#credit-panel p + p { margin-top: .7em; color: rgba(184, 192, 205, .82); }
/* The copyright and trademark notice, set off from the attribution above it by
   the same hairline the specs use, so the panel reads as specs, credits, then
   the site's own claim. */
.credit-legal {
  padding-top: .7em;
  border-top: 1px solid var(--line);
  font-size: .92em;
}
@keyframes creditIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }

/* ------------------------------------------------------------------ *
   Stage navigation — bottom center
   The primary control of the whole page, so it is placed and weighted like one.
   It used to be two hairline chevrons in the bottom-right corner, which is both
   the least-scanned part of the frame and the place a viewer reads as chrome
   rather than as the thing to press.

   Three deliberate choices:
     · CENTERED, so it sits in the natural scan path under the scene, directly
       below the hint that describes it.
     · ASYMMETRIC. Forward is a labeled accent pill, back is a quiet circle.
       The silhouette alone says which way the story runs.
     · A pulse ring on forward until the first press, then it stops for good.
       An affordance that keeps pulsing after it has been understood is noise.

   While a leg is in flight both buttons are disabled, which is also the signal
   that the scene is not interactive yet — nothing is clickable until the camera
   parks.
 * ------------------------------------------------------------------ */
#nav {
  position: absolute;
  left: 50%; transform: translateX(-50%);
  bottom: clamp(1.1rem, 3.4vh, 2.2rem);
  display: flex; align-items: center; gap: .7rem;
  z-index: 6;
}
/* Announced, not drawn. As bare mono text between the two buttons it disappeared
   entirely against the lit die at the core stop, and giving it its own dark chip
   made three chips in a row where the whole point was one obvious control. The
   position is already stated twice on screen, by the caption's 05 / 07 and by the
   lit tick on the rail, so only the screen-reader announcement is load-bearing. */
#nav-count {
  position: absolute;
  width: 1px; height: 1px; margin: -1px;
  padding: 0; border: 0; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
.nav-btn {
  appearance: none; -webkit-appearance: none;
  position: relative;
  height: 2.9rem;
  display: inline-flex; align-items: center; justify-content: center;
  line-height: 1;
  color: var(--text);
  /* A dark fill rather than a light one, plus a blur. The control sits over
     whatever the scene happens to be, and at the core stop that is a full-frame
     brightly lit die: a 5% white wash disappeared into it completely. Tinting
     DOWN reads on both the black opening frames and the bright ones. */
  background: rgba(10,11,15,.68);
  -webkit-backdrop-filter: blur(7px);
  backdrop-filter: blur(7px);
  border: 1px solid rgba(255,255,255,.24);
  border-radius: 999px;
  cursor: pointer;
  transition: background .22s ease, border-color .22s ease,
              box-shadow .22s ease, opacity .22s ease, transform .12s ease;
}
/* Stroked, not filled — a 2px stroke holds its weight at 15px where the glyph
   chevrons did not. */
.nav-btn svg {
  width: 15px; height: 15px; flex: none;
  fill: none; stroke: currentColor;
  stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round;
}
/* Both buttons are the same size now. Direction is carried by the chevron and
   PRIMACY by the fill: forward is the solid brand purple, back is tinted glass.
   Color does the job the old shape difference did. */
.nav-btn { width: var(--nav-btn-w); }
.nav-back { width: var(--nav-btn-w); }

/* --- forward: the hero ---
   SOLID, not the tinted glass the rest of the chrome uses. A 20%-alpha purple
   looked right on the opening black frames and then all but vanished at the core
   stop, where the whole frame is a brightly lit die: the scene came through the
   fill and the white label lost its ground. An opaque pill is the one thing that
   reads identically on every stage of the descent. */
/* The primary action, styled as the site styles its own: a FLAT fill of the
   brand purple with a white label. It was a vertical gradient carrying a
   colored drop-shadow and an inset highlight — three effects doing what one
   solid color does, and none of them appear anywhere on the live site. */
.nav-fwd {
  gap: 0;
  padding: 0;
  background: var(--accent);
  border-color: transparent;
  box-shadow: none;
}
.nav-fwd:hover:not(:disabled) {
  background: var(--fill-hover);
  border-color: transparent;
  box-shadow: none;
}
.nav-back:hover:not(:disabled) {
  background: rgba(169,123,255,.18);
  border-color: var(--accent);
  box-shadow: 0 0 14px 1px rgba(169,123,255,.34);
}
/* [hidden] has to be spelled out HERE, on .nav-btn, not just on the CTA. The
   rule above sets `display: inline-flex`, and any display declaration in an
   author sheet beats the UA sheet's `[hidden] { display: none }` — so setting
   `.hidden = true` in JS hides neither of these controls. The stop-7 swap looked
   correct in a harness that asserted on the `hidden` PROPERTY and wrong on
   screen, where both pills were drawn side by side. Assert on computed display. */
.nav-btn[hidden] { display: none; }

/* --- the hand-off pill at the last stop ---
   Opts out of the fixed `--nav-btn-w` because it is sized by its label rather
   than by matching the back button. */
.nav-cta {
  width: auto;
  padding: 0 1.15rem 0 1.35rem;
  gap: .5rem;
  font-size: .95rem;
  font-weight: 600;
  letter-spacing: .005em;
  color: #fff;
  text-decoration: none;
  white-space: nowrap;
}
.nav-btn:active:not(:disabled) { transform: scale(.93); }
.nav-btn:focus-visible { outline: 2px solid var(--accent-lit); outline-offset: 3px; }
/* In flight, or at either end of the journey. Kept visible rather than hidden so
   the control does not jump around under the cursor. */
.nav-btn:disabled { opacity: .26; cursor: default; }

/* --- the forward arrow's pulse, for the whole descent ---------------
   A ring expanding off the pill and a small nudge on the glyph itself.

   This used to run only until the viewer moved once, on the theory that a cue
   which has been understood is just noise afterwards. That is true of a cue
   which says "start here", and false of this one. The arrow is not an
   introduction to the page; it is the ONLY way through it — there is no scroll
   to fall back on, the descent has seven stops, and a viewer who has read a
   caption and looked away comes back to a still frame with no indication that
   there is more below. The pulse is the answer to "is that everything?", and
   that question is asked at every stop, not only the first.

   Gated on :not(:disabled) rather than on a class, which lands it in exactly the
   right places without any state to keep in sync: the arrow is disabled while a
   leg is playing, while the sheet is open, and at the last stop — so the pulse
   is quiet whenever pressing would do nothing, and running whenever it would. */
.nav-fwd::after {
  content: "";
  position: absolute; inset: -1px;
  border-radius: 999px;
  border: 1px solid var(--accent-lit);
  opacity: 0;
  pointer-events: none;
}
.nav-fwd:not(:disabled)::after { animation: navRing 2.6s ease-out infinite; }
.nav-fwd:not(:disabled) svg { animation: navNudge 2.6s ease-in-out infinite; }
@keyframes navRing {
  0%       { opacity: .85; transform: scale(1); }
  55%,100% { opacity: 0;   transform: scale(1.22); }
}
@keyframes navNudge {
  0%,72%,100% { transform: translateX(0); }
  84%         { transform: translateX(3px); }
}

/* While a leg is playing the scene is not pickable, so the canvas should not
   advertise otherwise. */
#stage.flying #gl { cursor: default; }

/* ------------------------------------------------------------------ *
   Narrow screens: the bottom stack

   Deliberately at the FOOT of the file, after the nav section. The responsive
   block further up is the wrong place for any of this: it sits before `#nav`,
   `#hint` and `#caption` are defined, and at equal specificity the later rule
   wins — so an override written up there against a property the base rule also
   sets is simply dead. (`#nav { gap: .5rem }` up there has never applied.)

   The four offsets are one decision. The credit is a single line now instead of
   two tiny ones, and a line carrying a toggle is wider than the nav pill, so it
   can no longer share a row with it: it takes the bottom edge and everything
   else steps up by the height of that row. Each value is derived from the one
   below it rather than chosen — nav clears the credit, hint clears the nav's
   3.1rem button, caption clears the hint.
 * ------------------------------------------------------------------ */
@media (max-width: 900px) {
  #credit  { bottom: .5rem; }
  #nav     { bottom: 2.4rem; gap: .5rem; }
  #hint    { bottom: 6.1rem; }
  #caption { bottom: 9.6rem; }
}

/* ------------------------------------------------------------------ *
   THE PHONE SCHEME

   The site's phone scheme lives in styles/mobile.css, which this page
   deliberately does not load — see CLAUDE.md, "Two design systems". This
   block is its counterpart, and it follows the same one rule: everything
   here is inside `@media (max-width: 900px)`, so the desktop and laptop
   view of this page cannot be affected by any of it.

   900 is the SITE's mode line, and that is the point. Two design systems is
   deliberate here; two different cutoffs was not. This page ran its own 768
   and 860 until 2026-08-05, so 769-860 was a third rendering — the narrow
   chrome without the phone bar — and the seam between this page and the rest
   of the site moved depending on which one you were looking at.

   The nested 356 / 360 / 400 blocks below are NOT breakpoints in that sense
   and did not move. They are measured content thresholds: the width where a
   string stops fitting beside two 44px targets. A mode line says which
   composition applies; those say when a specific row runs out of room, and
   verify/bar-fit.py is what set them.

   It is at the FOOT of the file for the reason the block above documents:
   equal-specificity overrides only win if they come after the base rule.

   KNOWN GAP: unlike styles/mobile.css this block has no landscape clause, so
   a phone on its side (844 or 932px wide) takes the desktop composition of a
   page that fills the viewport. Not folded in here because it is a behavior
   change rather than a breakpoint move.
 * ------------------------------------------------------------------ */
@media (max-width: 900px) {

  /* ---- The bar ------------------------------------------------------
     Measured at 390px, the "Project Directory" pill rendered 125x54: it
     wrapped onto two lines and made itself taller than the 64px bar
     containing it.

     The cause is a specificity miss in the narrow block above, not a
     missing rule. It sets `.sitenav__pill { font-size: .8rem }`, but
     `.sitenav a` — a class AND an element, so one step more specific —
     also sets font-size, and wins. The pill has been rendering at 14px
     at every width since. Matching the selector shape fixes it; the
     nowrap is the belt, so a future copy change cannot reintroduce a
     two-line pill. */
  .sitenav a.sitenav__pill {
    font-size: .78rem;
    white-space: nowrap;
    padding: .5rem .8rem;
    /* The way back to the rest of the site, and the only nav link left at
       this width once the others collapse. It gets a full target. */
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  /* Both bar controls take a 44px target. The GitHub glyph keeps its
     1.15rem size from the block above — only the region that answers a
     thumb grows, so the bar looks identical. */
  .ghlink {
    width: 44px;
    height: 44px;
  }
  .wordmark {
    min-height: 44px;
  }

  /* Clear the notch. The bar is the full width of the screen, so in
     landscape it runs under the rounded corners and the camera housing
     unless the inset is honored on both edges. */
  .sitebar__inner {
    padding-left: max(20px, env(safe-area-inset-left));
    padding-right: max(20px, env(safe-area-inset-right));
    /* Flex, not the desktop grid. Two of the grid's five children are
       display:none here, which leaves four items for five tracks — and the
       third of them landed in the `1fr` spacer, parking the menu in the
       middle of the bar. A track list only lines up with a fixed number of
       visible children, and this bar no longer has one.

       Flex needs no count: the subject takes the slack and everything after
       it is pushed hard right. */
    display: flex;
    align-items: center;
    gap: .55rem;
  }

  /* ---- The phone bar ------------------------------------------------
     Reads: brand, part number, then the two controls hard right with the
     menu outermost. The narrow block hides the subject and keeps the pill;
     this reverses both — the part number is what the page is ABOUT and
     belongs on the bar, and the pill's destination has moved into the
     menu with the other two links rather than sitting beside it. */
  /* The hairline comes back with the subject it separates. The narrow block
     hides both, and when the subject was restored here the divider was left
     behind, so the bar read `Silicon From Scratch Ryzen 5 9600X` as one
     phrase. `flex: none` because this bar is flex rather than the desktop
     grid: a 1px child with nothing to stop it is the first thing a tight row
     shrinks to nothing, and a divider that is there in the box model and
     invisible on the glass is the worst of both. */
  .sitebar__page {
    display: block;
    flex: none;
    height: .95rem;
  }
  .sitebar__subject {
    display: inline;
    font-size: .7rem;
    white-space: nowrap;
  }
  .sitenav { display: none; }

  /* Right to left: menu outermost, GitHub inside it. The markup has the
     button first so it is the panel's immediate sibling; `order` puts them
     on screen the other way round rather than trading that off. */
  /* The slack sits on the FIRST of the two controls, not on the subject.
     Putting it on the subject worked until the subject was hidden below
     340, where the auto margin went with it and the whole row packed left —
     the menu ended up 66px short of the corner. An auto margin has to live
     on something that is always there. */
  .ghlink     { order: 1; flex: none; margin-left: auto; }
  .nav-toggle { order: 2; flex: none; }

  /* The bar's budget is tight and mostly fixed: two 44px targets cannot be
     traded away, so 88px of it belongs to the controls before any type is
     placed. At 360 the full-size brand and part number overran the padding
     and the menu ended up flush against the screen edge. The type steps
     down; the targets do not. */
  @media (max-width: 400px) {
    .sitebar__inner { gap: .35rem; }
    .wordmark { font-size: .72rem; gap: .4rem; }
    .wordmark img { width: 20px; height: 20px; }
    .sitebar__subject { font-size: .62rem; }
  }

  /* Below this nothing shrinks far enough to hold both strings AND the two
     controls at a size worth reading. The part number goes and the brand
     stays: which site this is outranks which part it is showing, and the
     part number is still named in the About panel.

     356, not the 340 this used to be, because 340 was never where the row
     actually ran out. Measured with verify/bar-fit.py, the wordmark bottoms
     out at 135px and will not shrink past its own text, so the row's slack
     goes negative at 356 and everything between there and 341 was overflowing
     with the subject still on — that band was already broken before the
     divider was added and the divider only moved it about five pixels. The
     cut is now the measured limit rather than a round number. Slack at 360,
     the narrowest width that keeps the subject: +3.9px. */
  @media (max-width: 356px) {
    /* The divider goes with it. It separates the brand from the subject, so
       with the subject gone it is a rule dangling off the end of the wordmark
       and it is still spending the two gaps around itself. */
    .sitebar__subject, .sitebar__page { display: none; }
  }

  /* The hamburger appears and the inline links collapse into the drawer,
     the same swap main.css makes at this width. 44px in px, not the 2.6rem
     base: the root font-size floors at 13px on a phone, which would render
     that box at 34. Straight from styles/mobile.css. */
  .nav-toggle {
    display: inline-flex;
    width: 44px;
    height: 44px;
    border-radius: 12px;
    touch-action: manipulation;
  }
  /* Open, the bars fold into an X. main.js sets aria-expanded on this button
     when the drawer comes out, so the state is already published — this only
     draws it. */
  .nav-toggle[aria-expanded="true"] .nav-toggle__bars { background: transparent; }
  .nav-toggle[aria-expanded="true"] .nav-toggle__bars::before {
    transform: translateY(0) rotate(45deg);
  }
  .nav-toggle[aria-expanded="true"] .nav-toggle__bars::after {
    transform: translateY(-2px) rotate(-45deg);
  }

  /* ---- The bottom stack --------------------------------------------
     Four rows became two. The chain of derived offsets each clearing the
     row beneath it was correct and still cost too much: measured at
     390x844 it took 28% of the screen at stop 1 and 51% at stop 7, so on
     the stops with the most to say the die had less room than the words
     about it.

     Now the nav and the credit SHARE the bottom row — they are a centered
     control and a right-aligned one, so they never contended for the same
     space, only for the same stack — and the hint moves to the top, under
     the bar. It is an instruction rather than content, it retires for good
     once a sheet has been opened, and it was holding a permanent row open
     at the bottom for a line that is transient.

     The home indicator is still a floor under everything on the bottom
     row, so both members carry the inset. */
  /* One baseline for all three. The nav's buttons are 46px against the
     credit's 44, so equal `bottom` values are what puts their bottom edges
     on the same line — matching the taller one's offset would sit it 2px
     low against its neighbors. */
  #credit  { bottom: calc(.75rem + env(safe-area-inset-bottom)); }
  #nav     { bottom: calc(.75rem + env(safe-area-inset-bottom)); }

  /* Everything sits on ONE row: stage block, back, forward, credit. The
     caption shares the buttons' baseline rather than standing above them,
     so its bottom offset is now theirs. It is taller than they are and
     grows upward, which is what puts the stage number over the title with
     the title's last line level with the arrows. */
  #caption { bottom: calc(.75rem + env(safe-area-inset-bottom)); }

  /* Above the die instead of below it. `bottom: auto` is required — the
     base rule sets a bottom, and a top alone would not displace it. */
  #hint {
    top: calc(5.2rem + env(safe-area-inset-top));
    bottom: auto;
  }

  /* The arrows leave the center. The row reads stage block, back, forward,
     credit from the left, so the nav is anchored off the credit's corner
     rather than off the middle of the screen — that keeps the gap between
     the arrows and the ⓘ constant while the space the stage block gets
     absorbs the difference between one screen width and another.

     The base rule centers with left: 50% and a translate, so BOTH have to
     be unset; a right alone would lose to the left already there. */
  #nav {
    left: auto;
    transform: none;
    right: calc(var(--edge) + var(--info-w) + var(--row-gap));
  }

  /* ---- The nav has to clear the corner ------------------------------
     The credit chip claims the bottom-right, and the nav is centered, so
     the two collide once the nav gets wide enough. It does exactly once:
     at stop 7 the forward pill is swapped for the "Start Building" CTA,
     which is sized by its label. Measured overlap at that stop: -4px at
     390 wide and -18px at 360. Every other stop clears easily, which is
     why this was invisible until the last screen.

     Both halves give something up. --nav-btn-w is the shared width that
     keeps the two buttons from drifting apart; 7.5rem was set when the
     forward button still read "Descend" and is a lot of pill for a back
     arrow on a phone. The CTA keeps its weight and loses a little size.

     Sized so the nav's half-width plus the chip and its insets fits the
     narrowest phone in circulation, not just the one being tested on. */
  :root { --nav-btn-w: 4.6rem; }
  .nav-cta {
    padding: 0 .85rem 0 1rem;
    gap: .4rem;
    font-size: .85rem;
  }

  /* 320 is still a shipping width — the small iPhone SE and a long tail of
     budget Androids — and it is the one that does not clear: the nav is
     centered, so the room either side of it shrinks at half the rate the
     screen does, and the chip's corner does not move. One more step down
     for the CTA and the back pill closes it. Nested rather than widened,
     because at 360 and up the larger sizes fit and are the better read. */
  @media (max-width: 360px) {
    :root { --nav-btn-w: 3.9rem; }
    .nav-cta {
      padding: 0 .7rem 0 .8rem;
      font-size: .78rem;
    }
  }

  /* CLOSED the caption is the row's first cell, so it may only have the
     room the buttons leave it. That room is not a constant: at stop 7 the
     forward arrow is replaced by the "Start Building" CTA, which is sized
     by its label, and the nav grows by ~70px. A percentage tuned to the
     other six stops overlaps at that one.

     So it is measured. scene.js keeps --cap-room on :root from the nav's
     real left edge, via a ResizeObserver on the nav, and the fallback is
     the conservative case rather than the common one — if the property
     never arrives, a narrow caption is a worse-looking correct layout, and
     a wide one is a broken one.

     OPEN it stops being a cell and becomes a panel across the full width,
     above the row. That is the only way the body gets a readable measure
     on a phone, and it costs nothing: the buttons are below it, and the
     block grows upward from a pinned bottom edge. */
  #caption {
    left: max(1.15rem, env(safe-area-inset-left));
    /* `right` stays auto in BOTH states and the width is carried entirely by
       max-width, because `right: auto -> 12px` and `max-width: none` are not
       animatable — they snap. That snap was most of what read as rigid: the
       block jumped to full width on the first frame while only its height
       eased. Two lengths interpolate, so the box now widens as it rises. */
    right: auto;
    max-width: var(--cap-room, 38%);
    transition: max-width .52s var(--cap-ease),
                bottom    .52s var(--cap-ease);
  }
  /* Opening lifts the whole block clear of the buttons. Sharing their
     baseline is right for the closed row, where the block is a cell beside
     them; it is wrong the moment it becomes a panel, because the panel
     grows from that same pinned bottom edge.

     6.1rem, not 4.6. The panel's hairline is drawn by ::before, which is
     inset .9rem BELOW the caption's own box, so the line paints ~14px lower
     than the element measures — a clearance that reads as 12px in the DOM
     was 2px of overlap on screen. The offset has to clear the button row
     plus the scrim's own overhang, not just the box.

     Declared AFTER .cap-cramped, and it has to be: `#caption.cap-open` and
     `.cap-cramped #caption` are both one id and one class, so nothing but
     source order separates them, and open must win when both apply. */

  /* The measured fallback: when the buttons leave the first cell less than a
     readable measure, the stage block takes its own line above them instead
     of being squeezed into the row. Same block, one row higher. In practice
     this is stop 7, where the forward arrow becomes "Start Building". */
  .cap-cramped #caption {
    max-width: var(--cap-full);
    bottom: calc(4.6rem + env(safe-area-inset-bottom));
  }

  #caption.cap-open {
    max-width: var(--cap-full);
    bottom: calc(6.1rem + env(safe-area-inset-bottom));
  }

  /* Two lines of title, so a long one cannot push the block up the screen
     from inside the narrow column. Nothing is lost: the full title is one
     of seven fixed strings and the longest is "The Floorplan Beneath". */
  #caption h2 {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
  }
  #caption.cap-open h2 { -webkit-line-clamp: 999; }

  /* ---- The caption is two states ------------------------------------
     Closed, the bottom-left carries the stage number and the stage title
     and nothing else. Opening raises that pair and the body appears
     underneath it.

     The rise is free: the block is anchored by its BOTTOM, so a body that
     grows from zero height displaces the title upward on its own. There is
     no second animation to keep in step with the first, and nothing has to
     know how tall the text is going to be.

     `pointer-events` is re-enabled because the base rule turns it off for
     the whole caption so clicks reach the die behind it — right at every
     width where the caption is not itself a control. */
  #caption    { pointer-events: auto; }
  /* Sized for the narrow column it now lives in, not for the full width it
     used to have. It goes back up when the caption opens and takes the
     whole width, where a 1rem heading over a full-measure paragraph would
     stop reading as a heading at all. */
  /* The heading grows as the block opens rather than at the first frame.
     font-size interpolates, so this costs one property; left alone it was a
     step change landing on top of a 0.52s move, which reads as the text
     having been replaced rather than having grown. */
  #caption h2 {
    font-size: 1.02rem; line-height: 1.18; margin-bottom: 0;
    transition: font-size .52s var(--cap-ease);
  }
  #caption.cap-open h2 { font-size: 1.3rem; line-height: 1.16; }
  .cap-index { margin-bottom: .25rem; font-size: .66rem; }

  /* The two states. max-height rather than height, because the natural
     height is unknown and differs per stage; the value is a ceiling the
     text is allowed to grow into, not a size it is set to.

     THE CEILING IS WHAT KEEPS IT OFF THE BUTTONS. The block cannot grow
     downward — its bottom edge is pinned above the nav row — so the only
     thing it can collide with is the top bar, and 42vh cannot reach it
     from here. Anything longer scrolls inside the paragraph. */
  /* A grid row from 0fr to 1fr, not a max-height from 0 to a ceiling.

     max-height was the rigid part. The ceiling has to be a guess large
     enough for the longest stage, so on every shorter one the transition
     spends its first frames covering distance the text does not occupy and
     arrives early — the copy appears to snap in and then the animation is
     simply over. 1fr resolves to the content's OWN height, so the easing
     curve is the height the reader actually sees, and every stage takes the
     same time regardless of how much it has to say.

     min-height: 0 on the paragraph is the part that is easy to miss: a grid
     item's automatic minimum size is its content, which pins the row open
     and defeats the whole thing. */
  .cap-reveal {
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    transition: grid-template-rows .52s var(--cap-ease),
                margin-top        .52s var(--cap-ease),
                opacity           .3s  ease;
    margin-top: 0;
  }
  #cap-body {
    min-height: 0;
    overflow: hidden;
    font-size: .84rem;
    line-height: 1.5;
    max-width: none;
    /* No scrollbar, ever. It appeared for the length of the reveal: the
       open state turns on overflow-y the instant the class lands, while the
       grid row is still at 0, so the text overflows a box that is opening
       under it and Chromium draws a bar for the whole 0.52s.

       Suppressing the bar rather than delaying overflow, because the delay
       needs a transitionend listener and this needs none, and because the
       ceiling it belongs to is essentially never reached: the longest stage
       measures 284px against a 42vh cap of 354. The bar was only ever a
       transition artifact of a box that could not overflow once settled. */
    scrollbar-width: none;
  }
  #cap-body::-webkit-scrollbar { width: 0; height: 0; }
  #caption.cap-open .cap-reveal {
    grid-template-rows: 1fr;
    margin-top: .55rem;
    opacity: 1;
  }
  /* The ceiling survives, moved onto the paragraph, where it now only ever
     caps a stage long enough to need it rather than shaping the animation
     for all of them. */
  #caption.cap-open #cap-body {
    max-height: 42vh;
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* Open, the caption stops being a line of type over the scene and becomes
     a surface. The base scrim is a soft radial wash, which is enough behind
     a title and nowhere near enough behind eight lines of body copy sitting
     on a fully lit die — measured on the core stop, the paragraph ran over
     pale blue metal at roughly the same value as the text.

     Only in the open state: closed, the wash is right and a hard-edged
     panel under two lines would read as a card that had appeared for no
     reason. */
  /* The surface fades up with the move.

     The panel's hairline is an INSET BOX-SHADOW, not a border. A transparent
     1px border was the obvious way to keep the edge from resizing
     mid-transition, and it drew a faint dark seam across the die: the closed
     scrim is a very large box carrying a soft radial gradient, and giving it
     a border makes the browser paint that gradient out to a border-box edge
     it was fading well short of. Bisected — removing the radius changed
     nothing, removing the border removed the line.

     A box-shadow is never part of the box. It cannot resize anything, it
     costs no layout, and it animates from transparent the same way. */
  #caption::before {
    border-radius: 16px;
    box-shadow: inset 0 0 0 1px transparent;
    transition: inset .52s var(--cap-ease),
                background .42s ease,
                box-shadow .42s ease;
  }
  #caption.cap-open::before {
    inset: -.85rem -.9rem -.9rem -.9rem;
    border-radius: 16px;
    background: linear-gradient(to top,
                color-mix(in srgb, var(--void) 94%, transparent),
                color-mix(in srgb, var(--void) 82%, transparent));
    box-shadow: inset 0 0 0 1px var(--line);
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
  }

  /* ---- The toggle ---------------------------------------------------
     Sits on the index line, after "01 / 07", so the closed state reads as
     one row: number, rule, control. Labeled rather than a bare chevron —
     at this size an unlabeled caret beside a title is as likely to be
     read as decoration as as a control, and the label is two words. */
  .cap-toggle {
    display: inline-flex;
    align-items: center;
    gap: .3rem;
    margin-left: .2rem;
    padding: 0 .4rem;
    /* 44 on BOTH axes. Icon-only it measured 26 wide, which passes the eye
       and fails a thumb; the label states re-widen past this on their own,
       so it is a floor rather than a size. */
    min-width: 44px;
    justify-content: center;
    /* px, not rem: this is a target for a thumb and the root font-size
       clamps down on a phone.

       The negative block margins are what let a 44px target sit on an 18px
       line without becoming an 18px line that is 44px tall. The button
       keeps its full height for the thumb and contributes the row's own
       height to the layout, so the stage number stays directly above the
       stage title with nothing wedged between them. */
    height: 44px;
    margin-block: -13px;
    font-family: var(--font-mono);
    font-size: .6rem;
    letter-spacing: .12em;
    text-transform: uppercase;
    color: var(--accent-lit);
    background: transparent;
    border: 0;
    cursor: pointer;
    touch-action: manipulation;
  }

  /* The label is on in both states, so the closed control reads "READ MORE ⌃"
     the way the open one reads "CLOSE ⌄".

     It was hidden in the row layout because the words wrapped inside a ~142px
     cell, and the wrap landed between the stage number and the title — the
     one pairing this row exists to keep together. `nowrap` is what makes
     keeping it safe: the line can no longer break, so the only thing that can
     go wrong is the row being too narrow, and that already has an answer in
     .cap-cramped. The type is a step down from the open state's to buy the
     room back. */
  .cap-index { white-space: nowrap; }
  .cap-toggle {
    font-size: .55rem;
    letter-spacing: .08em;
    gap: .25rem;
    padding: 0 .35rem;
  }
  #caption.cap-open .cap-toggle { font-size: .6rem; letter-spacing: .12em; }
  .cap-toggle svg {
    width: 13px; height: 13px;
    fill: none; stroke: currentColor; stroke-width: 2.4;
    stroke-linecap: round; stroke-linejoin: round;
    transition: transform .4s cubic-bezier(.2,.7,.3,1);
  }
  /* The chevron points up while closed, because up is what pressing it
     does. It turns over once the body is open and the press would put it
     back. */
  #caption.cap-open .cap-toggle svg { transform: rotate(180deg); }

  /* ---- The credit becomes a chip ------------------------------------
     Hard into the corner and given a surface of its own. As bare muted
     type it was the quietest thing on screen and sat in the one corner
     nothing else claims, which is a poor combination for the control that
     holds the die's provenance and the "these are approximations"
     disclaimer.

     "About this model" is ~150px of label and the nav occupies 141px to
     249px on a 390px screen, so the label could not stay on this row. The
     badge does the naming instead, at 44px with a lit glyph on a panel.

     font-size: 0 on the button rather than a rule on the label, because
     the label is a bare text node with no element of its own to hide. The
     accessible name is unchanged — the text is still in the DOM. */
  /* ---- The corner is fixed, and everything measures off it -----------
     One set of numbers for the whole row. The arrows' offset is derived
     from the ⓘ's, so the gap between them is a constant that cannot drift
     when either value is retuned. */
  :root {
    --edge:     max(.75rem, env(safe-area-inset-right));
    --info-w:   44px;
    /* Wider than the .7rem it started at: at that distance the arrows read
       as part of the same cluster as the ⓘ, and they are separate controls
       doing unrelated jobs. */
    --row-gap:  1.35rem;
    /* The caption's open width: the viewport less both insets, as a LENGTH
       rather than `none`, because only two lengths interpolate. */
    --cap-full: calc(100vw - max(1.15rem, env(safe-area-inset-left))
                           - max(1.15rem, env(safe-area-inset-right)));
    /* One curve for every property in the disclosure, so nothing arrives
       ahead of anything else. The site's own --ease. */
    --cap-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  }

  /* The ⓘ was NOT fixed to the corner, and the reason is three properties
     this block never unset. At <=900px the credit is deliberately a
     centered banner — `right: auto; left: 50%; transform: translateX(-50%);
     width: 96vw` — from when it was a line of text under the nav. Setting
     `right` alone does nothing against that: with left, width and right all
     specified the box is over-constrained and CSS DISCARDS `right`.

     So the button rode the right edge of a centered box capped at 26rem,
     which put it at (viewport - min(96vw, 416)) / 2 from the corner — 6px
     at 320, 9px at 430, 62px at 540. It slid with the screen because it
     was never anchored to the corner at all.

     Unsetting left, transform and width lets the box shrink-wrap the
     button, and then `right` is the only thing positioning it. */
  #credit {
    left: auto;
    transform: none;
    width: auto;
    right: var(--edge);
    bottom: calc(.75rem + env(safe-area-inset-bottom));
  }

  /* The panel has to be re-anchored for the same reason. It is centered on
     the credit's box by `right: 50%` and a translate, which put it in the
     middle of the viewport while that box WAS the viewport. Against a
     44px box it would hang off the right edge instead. Aligned to the
     ⓘ's own right edge now, opening leftward across the screen. */
  #credit-panel {
    right: 0;
    transform: none;
    width: min(92vw, 30rem);
  }
  /* The badge keeps the look it has everywhere else: a ringed italic i in
     the muted ink, no surface of its own. Only the label goes, and only
     because "About this model" is ~150px that this row has nowhere to put.

     font-size: 0 on the button rather than a rule on the label, because
     the label is a bare text node with no element of its own to hide. That
     collapses the badge too, since it is sized in em, so .credit-i has to
     re-establish a font-size — its width, height, ring and italic all
     derive from that one value and come straight from the base rule. The
     accessible name is unchanged; the text is still in the DOM. */
  .credit-toggle {
    font-size: 0;
    gap: 0;
    padding: 0;
    width: 44px;
    height: 44px;
    justify-content: center;
  }
  .credit-i { font-size: .84rem; }

  /* Press feedback on the two controls that carry the whole page. The
     scene is driven entirely by these, so they should acknowledge a
     touch the instant it lands rather than when it lifts. */
  .nav-btn:active {
    transform: scale(0.96);
    transition: transform 90ms ease-out;
  }

  /* The brand purple, not iOS blue, for the tap flash. */
  html {
    -webkit-tap-highlight-color: color-mix(in srgb, var(--accent) 22%, transparent);
  }
  .nav-btn,
  .sitenav a,
  .credit-toggle {
    touch-action: manipulation;
  }
}

/* ------------------------------------------------------------------ *
   PROJECT DIRECTORY DRAWER — the tokens it reads
   This page loads styles/project-directory.css but NOT styles/main.css, so
   the drawer arrives with its rules and none of the custom properties they
   resolve against. The handful it needs are republished here at the site's
   DARK theme values, since this page is always dark.

   Scoped to the drawer's own roots rather than :root on purpose. These names
   overlap the site's, not this page's, and dropping them on :root would put a
   second --fill and a second --ink in scope for the scene's own chrome.
   JS moves all three of these elements to <body>, so scoping here still
   reaches everything inside them by inheritance.

   Add a var() to project-directory.css and it has to be added here too.
 * ------------------------------------------------------------------ */
.toc__menu,
.toc-backdrop,
.navsheets {
  --bg:          #08080b;
  --ink:         #fdfdfb;
  --soft-ink:    rgba(253, 253, 251, 0.72);
  --hairline:    rgba(253, 253, 251, 0.14);
  --fill:        #6b2fc9;
  --fill-hover:  #7b3ce0;
  --accent-deep: #d4bbff;
  --radius-sm:   10px;
  --shadow-lift: 0 2px 6px rgba(0, 0, 0, 0.35),
                 0 16px 40px rgba(0, 0, 0, 0.50);
  --ease:        cubic-bezier(0.22, 0.61, 0.36, 1);
  --font-display: "Geist", system-ui, -apple-system, sans-serif;
  --font-mono:   "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
  --space-2:     1rem;
  --space-3:     1.5rem;
  --space-4:     2rem;
  --space-5:     3rem;
}
/* main.css carries this one as `.theme-dark .toc__menu`. There is no
   .theme-dark class on this page, so the flat near-black surface and the
   quieter edge are restated rather than inherited. */
.toc__menu {
  background: var(--bg);
  border-left: 1px solid rgba(255, 255, 255, 0.08);
}
