/* ═══════════════════════════════════════════════════════════════════════════
   lessons — the reading skin.

   Deliberately not css/buttercup.css: that file is the machine, and gets
   inlined into one page by build.mjs. These are plain documents, so they carry
   their own copy of the palette and nothing else. Same colours, same tube.
   ═══════════════════════════════════════════════════════════════════════════ */

:root {
  color-scheme: light dark;

  --ink:        light-dark(#4a3610, #ffd873);
  --ink-hot:    light-dark(#1c1203, #fff6d4);
  --ink-dim:    light-dark(#7d6528, #d3a955);
  --ink-faint:  light-dark(#9c8449, #907338);
  --paper:      light-dark(#fdf3d5, #191204);
  --paper-2:    light-dark(#f8e9c1, #241a09);
  --paper-3:    light-dark(#f2ddaa, #30230e);
  --rule:       light-dark(#d3ba82, #6a5122);
  --good:       light-dark(#2f6b26, #a6e078);
  --bloom:      light-dark(#fffbea, #3a2b0e);
  --edge:       light-dark(#eddfb4, #0d0903);
  --veil-line:  light-dark(rgba(0,0,0,.05), rgba(0,0,0,.20));
  --veil-edge:  light-dark(rgba(0,0,0,.10), rgba(0,0,0,.50));

  --mono: "Berkeley Mono", "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas,
          "DejaVu Sans Mono", monospace;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--paper);
  background-image:
    radial-gradient(120% 60% at 50% 0%, var(--bloom) 0%, var(--paper) 55%, var(--edge) 100%);
  background-attachment: fixed;
  color: var(--ink-dim);
  font: 15px/1.65 var(--mono);
}

/* Scanlines and vignette, same dressing as the harness — decoration only. */
.crt {
  position: fixed; inset: 0; z-index: 9; pointer-events: none;
  background:
    repeating-linear-gradient(to bottom,
      var(--veil-line) 0 1px, rgba(0,0,0,0) 1px 3px),
    radial-gradient(130% 100% at 50% 50%,
      rgba(0,0,0,0) 60%, var(--veil-edge) 100%);
  mix-blend-mode: multiply;
}

.sheet {
  position: relative; z-index: 1;
  max-width: 44rem; margin: 0 auto; padding: 1rem 1rem 5rem;
}

/* ── title bar ────────────────────────────────────────────────────────────── */

.titlebar {
  display: flex; align-items: baseline; gap: .8rem; flex-wrap: wrap;
  border: 2px solid var(--rule); border-bottom-width: 3px;
  background: linear-gradient(var(--paper-3), var(--paper-2));
  padding: .5rem .7rem; margin-bottom: 2rem;
}
.titlebar a.home {
  color: var(--ink-hot); font-weight: 600; letter-spacing: .04em;
  text-decoration: none;
}
.titlebar a.home:hover { text-decoration: underline; text-underline-offset: 3px; }
.titlebar .where {
  color: var(--ink-faint); font-size: .72rem; letter-spacing: .2em;
  text-transform: uppercase; margin-left: auto;
}
.lamps { display: inline-flex; gap: .3rem; }
.lamps i {
  width: .55rem; height: .55rem; border: 1px solid var(--rule); border-radius: 50%;
  background: var(--paper); display: block;
}
/* the power lamp, on the same 8s clock as the diagrams below it */
.lamps i:first-child { animation: lamp 8s ease-in-out infinite; }
@keyframes lamp {
  0%, 100% { background: var(--paper); box-shadow: none }
  50%      { background: var(--good);
             box-shadow: 0 0 .35rem var(--good) }
}
/* (frozen state for this lives with the diagrams, under `data-motion="off"`) */

/* ── prose ────────────────────────────────────────────────────────────────── */

article header { border-bottom: 1px dashed var(--rule); padding-bottom: 1rem; }
h1 {
  color: var(--ink-hot); font-size: 1.5rem; line-height: 1.3;
  margin: 0 0 .5rem; letter-spacing: -.01em;
}
/* the heading carries its own rule out to the margin — cheaper than an <hr>
   and it keeps the section breaks obvious at a glance while scrolling */
h2 {
  display: flex; align-items: center; gap: .7rem;
  color: var(--ink-hot); font-size: 1rem; letter-spacing: .12em;
  text-transform: uppercase; margin: 2.5rem 0 .75rem;
}
/* `0 2rem` basis so the rule is always drawn, even next to the longest
   heading, rather than collapsing to nothing */
h2::after { content: ""; flex: 1 0 2rem; border-top: 1px dashed var(--rule); }
time, .kicker {
  display: block; color: var(--ink-faint);
  font-size: .74rem; letter-spacing: .2em; text-transform: uppercase;
}
p { margin: 1rem 0; }
strong { color: var(--ink-hot); font-weight: 600; }
em { color: var(--ink); font-style: italic; }
a { color: var(--ink); text-decoration: underline; text-underline-offset: 2px; }
a:hover { color: var(--ink-hot); }
code {
  color: var(--ink); background: var(--paper-2);
  border: 1px solid var(--rule); padding: 0 .25rem;
}
pre {
  border: 1px dashed var(--rule); border-left: 3px solid var(--rule);
  background: var(--paper-2);
  padding: .7rem .8rem; overflow-x: auto; color: var(--ink);
  font-size: .82rem; line-height: 1.5;
}
pre code { border: 0; padding: 0; background: none; }
ul { padding-left: 1.2rem; }
li { margin: .4rem 0; }
li::marker { color: var(--ink-faint); }
hr { border: 0; border-top: 1px dashed var(--rule); margin: 2.5rem 0; }

blockquote {
  margin: 1.5rem 0; padding: .1rem 0 .1rem 1rem;
  border-left: 3px solid var(--rule); color: var(--ink);
}

table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { border: 1px solid var(--rule); padding: .35rem .5rem; text-align: left; }
th { color: var(--ink-hot); background: var(--paper-2); font-weight: 600; }
/* reference tables: the left column is a short label and the right is prose, so
   fix the split rather than letting the prose squeeze the labels into stacks */
table.ref { table-layout: fixed; }
table.ref :is(th, td):first-child { width: 10.5rem; }
table.ref td { vertical-align: top; }
@media (max-width: 34rem) { table.ref :is(th, td):first-child { width: 7rem; } }

/* ── animated diagrams ────────────────────────────────────────────────────────
   Five figures, all pure CSS on one shared 8s clock so they read as the same
   machine ticking. No JS, no SVG, no images — a diagram that needs a build step
   is a diagram nobody updates. Every figure also has a legible static state,
   kept in the `data-motion="off"` block at the end of this section, so the
   motion is never load-bearing — but it is on by default for everyone,
   including OS Reduce Motion. See that block for why. */

.diag {
  position: relative;
  margin: 1.8rem 0; padding: 1.1rem .9rem .7rem;
  border: 1px dashed var(--rule); background: var(--paper-2);
  /* graph paper, one cell per 14px, faint enough to read as texture rather
     than as a grid you are meant to count */
  background-image:
    linear-gradient(var(--veil-line) 1px, rgba(0,0,0,0) 1px),
    linear-gradient(90deg, var(--veil-line) 1px, rgba(0,0,0,0) 1px);
  background-size: 14px 14px, 14px 14px;
}
/* corner ticks: the panel reads as an instrument face, not a div */
.diag::before, .diag::after {
  content: ""; position: absolute; width: .5rem; height: .5rem;
  border: 1px solid var(--ink-faint); pointer-events: none;
}
.diag::before { top: -1px; left: -1px; border-width: 2px 0 0 2px; }
.diag::after  { top: -1px; right: -1px; border-width: 2px 2px 0 0; }
.diag figcaption {
  margin-top: .9rem; padding-top: .6rem; border-top: 1px dashed var(--rule);
  font-size: .74rem; line-height: 1.5; color: var(--ink-faint);
}
.diag figcaption b { color: var(--ink); font-weight: 600; }

/* ‥ figure 1: the loop ‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥
   Four stations in a ring. The middle column is only as wide as an arrow, so
   the two 1fr columns carry the boxes. Source order runs left-to-right by row,
   which is also reading order for a screen reader with the labels unhidden. */

.ring {
  display: grid; grid-template-columns: 1fr auto 1fr;
  align-items: center; justify-items: center; gap: .35rem;
}
.node {
  position: relative; width: 100%;
  border: 1px solid var(--rule); background: var(--paper);
  padding: .5rem .55rem; text-align: center;
  font-size: .76rem; line-height: 1.4; color: var(--ink);
}
.node b {
  display: block; margin-bottom: .15rem;
  color: var(--ink-hot); font-size: .64rem;
  letter-spacing: .16em; text-transform: uppercase;
}
.node code { font-size: .72rem; }
/* the station light: an inset ring that flares as the pulse arrives */
.node::after {
  content: ""; position: absolute; inset: -3px; opacity: 0;
  border: 2px solid var(--good); pointer-events: none;
}
/* two animations per station on one delay: the ring flares outside the box,
   the box itself warms. Split so each can carry its own easing. */
@keyframes station {
  0%        { opacity: 0; inset: 1px }
  6%        { opacity: 1; inset: -4px }
  11%       { opacity: 1; inset: -3px }
  26%, 100% { opacity: 0; inset: -3px }
}
@keyframes stationlit {
  0%, 26%, 100% { background: var(--paper);   box-shadow: none }
  4%, 20%       { background: var(--paper-3);
                  box-shadow: 0 0 0 1px var(--good) inset }
}
/* both shorthands first — a shorthand resets `animation-delay`, so the
   per-station delays below must be the last word on the subject */
.ring .node { animation: stationlit 8s linear infinite; }
.ring .node::after { animation: station 8s ease-out infinite; }
.ring .n1, .ring .n1::after { animation-delay: -0.2s; }
.ring .n2, .ring .n2::after { animation-delay:  1.8s; }
.ring .n3, .ring .n3::after { animation-delay:  3.8s; }
.ring .n4, .ring .n4::after { animation-delay:  5.8s; }
/* station 3 is the one the lesson is about — it stays marked when unlit */
.ring .n3 { border-left: 3px solid var(--good); }

/* the wires. `.h` runs across the middle column, `.v` down a 1fr column. */
.wire { position: relative; color: var(--ink-faint); font-size: .66rem; }
/* wire labels sit outside the flow, so the middle column has to be wide enough
   to hold the longest of them (`tool_result`) or they run under the boxes */
.wire i {
  position: absolute; font-style: normal; font-size: .58rem;
  letter-spacing: .06em; white-space: nowrap; color: var(--ink-faint);
}
/* the track. A gradient rather than a dashed border, because only a gradient
   can march: `background-position` animates, `border-style` does not. */
.wire::before { content: ""; position: absolute; }
@keyframes ants { to { background-position: 8px 8px } }
.wire::before { animation: ants 1.1s linear infinite; }
/* the two return legs flow the other way, so their ants do too */
.wire.w3::before, .wire.w4::before { animation-direction: reverse; }
/* the pulse. Hidden by default so it does not park on a wire during its
   animation-delay — each travel keyframe fades it in for its own leg only. */
.wire::after {
  content: "▸"; position: absolute; opacity: 0; color: var(--good);
  font-size: .8rem; line-height: 1;
  text-shadow: 0 0 .4rem var(--good);
}

.wire.h { width: 5rem; height: 1.4rem; }
.wire.h::before {
  top: 50%; left: 0; right: 0; height: 1px;
  background: repeating-linear-gradient(90deg,
    var(--rule) 0 4px, rgba(0,0,0,0) 4px 8px);
}
.wire.h::after  { top: 50%; transform: translate(-50%, -50%); }
.wire.h i { bottom: -.15rem; left: 50%; transform: translateX(-50%); }
@keyframes runx {
  0%   { left: 0;    opacity: 1 }
  14%  { left: 100%; opacity: 1 }
  14.01%, 100% { left: -2rem; opacity: 0 }
}
@keyframes runx-back {
  0%   { left: 100%; opacity: 1 }
  14%  { left: 0;    opacity: 1 }
  14.01%, 100% { left: 120%; opacity: 0 }
}
.wire.w1::after { animation: runx 8s linear infinite 0.6s; }
/* the return leg travels right-to-left, so it points the way it is going */
.wire.w3::after { content: "◂"; animation: runx-back 8s linear infinite 4.6s; }

.wire.v { width: 100%; height: 2.1rem; }
.wire.v::before {
  left: 50%; top: 0; bottom: 0; width: 1px;
  background: repeating-linear-gradient(180deg,
    var(--rule) 0 4px, rgba(0,0,0,0) 4px 8px);
}
.wire.v::after  { left: 50%; transform: translate(-50%, -50%); }
.wire.v i { top: 50%; transform: translateY(-50%); left: calc(50% + .6rem); }
@keyframes runy {
  0%   { top: 0;    opacity: 1 }
  14%  { top: 100%; opacity: 1 }
  14.01%, 100% { top: -2rem; opacity: 0 }
}
.wire.w2::after { content: "▾"; animation: runy 8s linear infinite 2.6s; }
@keyframes runy-back {
  0%   { top: 100%; opacity: 1 }
  14%  { top: 0;    opacity: 1 }
  14.01%, 100% { top: 120%; opacity: 0 }
}
.wire.w4::after {
  content: "▴"; animation: runy-back 8s linear infinite 6.6s;
}
.wire.w4 i { left: auto; right: calc(50% + .6rem); }
.ring .gap { width: 100%; }

/* ‥ figure 2: the message tape ‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥
   The same four turns as the ring, but as the array you are actually building.
   Rows land one at a time on the shared clock, then the tape wipes and refills. */

.tape { list-style: none; margin: 0; padding: 0; counter-reset: turn; }
.tape li {
  margin: 0 0 .3rem; padding: .4rem .55rem .4rem 1.9rem;
  position: relative; border: 1px solid var(--rule); background: var(--paper);
  font-size: .74rem; line-height: 1.45; color: var(--ink);
  opacity: .16;
}
.tape li::before {
  counter-increment: turn; content: counter(turn);
  position: absolute; left: .55rem; top: .4rem;
  color: var(--ink-faint); font-size: .66rem;
}
.tape b {
  color: var(--ink-hot); font-weight: 600;
  font-size: .64rem; letter-spacing: .14em; text-transform: uppercase;
}
.tape .k { color: var(--good); }
.tape li:nth-child(3) { border-left: 3px solid var(--good); }
/* rows land from the left, flash as they land, then hold */
@keyframes arrive {
  0%      { opacity: .16; transform: translateX(-.5rem);
            border-color: var(--rule); background: var(--paper) }
  5%      { opacity: 1;   transform: translateX(.12rem);
            border-color: var(--good); background: var(--paper-3) }
  11%     { opacity: 1;   transform: none;
            border-color: var(--good); background: var(--paper-3) }
  22%, 92% { opacity: 1;  transform: none;
            border-color: var(--rule); background: var(--paper) }
  100%    { opacity: .16; transform: translateX(-.5rem);
            border-color: var(--rule); background: var(--paper) }
}
.tape li { animation: arrive 8s cubic-bezier(.2,.9,.3,1) infinite; }
.tape li:nth-child(1) { animation-delay: 0.2s; }
.tape li:nth-child(2) { animation-delay: 2.0s; }
.tape li:nth-child(3) { animation-delay: 4.0s; }
.tape li:nth-child(4) { animation-delay: 6.0s; }
/* the id contract, shown rather than asserted: the id in turn 2 and the id in
   turn 3 flare together at the moment turn 3 lands. This is the one thing in
   the figure the caption is about, so it gets the only colour. */
.tape .id { color: var(--good); }
@keyframes match {
  0%, 46%, 62%, 100% { background: var(--paper-2); border-color: var(--rule) }
  50%, 58%           { background: var(--good);    border-color: var(--good);
                       color: var(--paper) }
}
.tape .id { animation: match 8s linear infinite 0.2s; }

/* ‥ figure 3: schema in, arguments out ‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥
   Left is what you wrote; right types itself out, because the arguments are
   generated text and it helps to feel that. `steps()` counts characters, so the
   `ch` width and the step count must match the string — 17 of each here. */

.pair { display: grid; grid-template-columns: 1fr auto 1fr; gap: .6rem;
        align-items: center; }
.pair .side { min-width: 0; align-self: stretch; }
.pair .side > b {
  display: block; margin-bottom: .3rem;
  color: var(--ink-hot); font-size: .64rem;
  letter-spacing: .16em; text-transform: uppercase;
}
.pair pre { margin: 0; font-size: .72rem; }
/* the crossing: schema in, arguments out. Ticks over on the shared clock so
   the eye is pushed rightwards just before the typing starts. */
.pair .cross {
  align-self: stretch; position: relative; width: 1.4rem;
  display: grid; place-items: center;
  font-size: 1.05rem; line-height: 1; color: var(--ink-faint);
  animation: shove 8s ease-in-out infinite;
}
/* the seam between the two panels, broken in the middle so the arrow sits in
   the gap rather than on top of the line */
.pair .cross::before {
  content: ""; position: absolute; left: 50%; top: .2rem; bottom: .2rem;
  width: 1px; background: linear-gradient(180deg,
    var(--rule) 0 42%, rgba(0,0,0,0) 42% 58%, var(--rule) 58% 100%);
}
/* colour only — the seam is part of this element, so moving it would drag the
   line with the arrowhead */
@keyframes shove {
  0%, 44%, 100% { color: var(--ink-faint); text-shadow: none }
  8%, 34%       { color: var(--good); text-shadow: 0 0 .45rem var(--good) }
}
.pair .out {
  /* content-box so the 17ch is the text alone — padding and the caret border
     must not eat into the count, or the last characters never appear */
  box-sizing: content-box;
  overflow: hidden; white-space: nowrap; width: 17ch;
  border-right: .5rem solid var(--good);
  animation: typeout 8s steps(17, end) infinite, caret .9s step-end infinite;
}
@keyframes typeout { 0% { width: 0 } 55%, 100% { width: 17ch } }
@keyframes caret { 0%, 100% { border-right-color: var(--good) }
                   50% { border-right-color: transparent } }

/* ‥ the same panel pair, both sides moving ‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥
   Lesson 1's figure 1, where the whole point is the exchange rather than one
   generated string. The messages light in the order you assemble them, the
   arrow ticks over on `shove`, and then the reply arrives as separate chips —
   because that is what a stream hands you. Same 8s clock, so the beats read
   left to right: 0.2s, 1.0s, arrow, 2.4s onwards. */

/* the rule sits in the <pre>'s own left padding — negative margin against
   matching padding — so lighting a message never shifts the code sideways or
   pushes the longest line into the panel edge */
.pair .msgs .msg {
  display: block; opacity: .25;
  border-left: 2px solid transparent;
  margin-left: -.45rem; padding-left: calc(.45rem - 2px);
  animation: msgin 8s linear infinite;
}
/* the hold runs long — both messages stay lit while the reply comes back,
   because the array you sent is still the request that is being answered */
@keyframes msgin {
  0%        { opacity: .25; border-left-color: transparent }
  6%, 74%   { opacity: 1;   border-left-color: var(--good) }
  86%, 100% { opacity: .25; border-left-color: transparent }
}
.pair .msgs .msg:nth-of-type(1) { animation-delay: 0.2s; }
.pair .msgs .msg:nth-of-type(2) { animation-delay: 1.0s; }

/* a chip that *arrives* rather than fading up in place: same beats and same
   border flare as `chip`, plus a fifth of a rem of travel. Used where the chips
   are a stream — tokens handed to you one at a time — because the small
   movement is what makes seven of them read as arrivals instead of a row
   switching on. `chip` itself is left alone: figure 5's calls are simultaneous. */
@keyframes chipin {
  0%        { opacity: .2; border-color: var(--rule);
              transform: translateY(-.2rem) }
  6%, 74%   { opacity: 1;  border-color: var(--good); transform: none }
  86%, 100% { opacity: .2; border-color: var(--rule);
              transform: translateY(-.2rem) }
}

/* the reply, in pieces (`chipin`, the travelling form of figure 5's chip) */
.pair .toks {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: .3rem;
}
.pair .toks li {
  border: 1px solid var(--rule); background: var(--paper);
  padding: .18rem .38rem; font-size: .7rem; color: var(--ink); opacity: .2;
  animation: chipin 8s cubic-bezier(.2,.9,.3,1) infinite;
}
.pair .toks li:nth-child(1) { animation-delay: 2.4s; }
.pair .toks li:nth-child(2) { animation-delay: 2.7s; }
.pair .toks li:nth-child(3) { animation-delay: 3.0s; }
.pair .toks li:nth-child(4) { animation-delay: 3.3s; }
.pair .toks li:nth-child(5) { animation-delay: 3.6s; }
.pair .toks li:nth-child(6) { animation-delay: 3.9s; }
.pair .toks li:nth-child(7) { animation-delay: 4.2s; }

/* ‥ figure 4: the syllabus ladder ‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥
   Six rungs, easiest at the top. A green line climbs the track and each rung
   lights as it is passed, then the whole thing winks out top-down and refills —
   the point being that this is a climb with an order, not a menu. */

.ladder {
  position: relative; list-style: none; counter-reset: rung;
  margin: 0; padding: 0 0 0 1.6rem;
}
.ladder::before, .ladder::after {
  content: ""; position: absolute; left: .42rem; top: .5rem; width: 0;
  /* behind the rungs, so the climbing line never covers a number */
  z-index: 0;
}
.ladder::before { bottom: .5rem; border-left: 1px dashed var(--rule); }
/* the climb. Height is a percentage of .ladder's padding box, so the 1rem
   deduction is the top and bottom insets the dashed track also leaves. */
.ladder::after { height: 0; border-left: 2px solid var(--good); }
@keyframes climb { 0% { height: 0 } 78%, 100% { height: calc(100% - 1rem) } }
.ladder::after { animation: climb 8s linear infinite; }

.ladder li {
  position: relative; z-index: 1; margin: 0 0 .55rem; padding-left: .3rem;
  font-size: .78rem; line-height: 1.45; color: var(--ink);
  opacity: .3;
}
.ladder li::before {
  counter-increment: rung; content: counter(rung);
  position: absolute; left: -1.55rem; top: .1rem;
  width: 1rem; height: 1rem; line-height: 1rem; text-align: center;
  font-size: .6rem; color: var(--paper); background: var(--good);
}
.ladder .t { color: var(--ink-hot); font-weight: 600; }
.ladder .m {
  display: block; color: var(--ink-faint); font-size: .72rem;
}
@keyframes lightup { 0% { opacity: .3 } 6%, 97% { opacity: 1 } 100% { opacity: .3 } }
.ladder li:nth-child(1) { animation: lightup 8s linear infinite 0.1s; }
.ladder li:nth-child(2) { animation: lightup 8s linear infinite 1.3s; }
.ladder li:nth-child(3) { animation: lightup 8s linear infinite 2.5s; }
.ladder li:nth-child(4) { animation: lightup 8s linear infinite 3.7s; }
.ladder li:nth-child(5) { animation: lightup 8s linear infinite 4.9s; }
.ladder li:nth-child(6) { animation: lightup 8s linear infinite 6.1s; }
/* a four-rung ladder — lesson 1's load sequence — shares the clock but has to
   spread over four beats instead of six, or the line goes on climbing past
   rungs that are not there while the last one has been lit for two seconds.
   Delays re-spaced to land the fourth rung where `climb` tops out (78%, 6.24s). */
.ladder:has(> li:nth-child(4):last-child) li:nth-child(1) { animation-delay: 0.15s; }
.ladder:has(> li:nth-child(4):last-child) li:nth-child(2) { animation-delay: 2.15s; }
.ladder:has(> li:nth-child(4):last-child) li:nth-child(3) { animation-delay: 4.15s; }
.ladder:has(> li:nth-child(4):last-child) li:nth-child(4) { animation-delay: 6.15s; }

/* ‥ figure 5: three calls, one message ‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥
   The rule that costs people the most speed for the least reason. Three chips
   light in the assistant's turn, three results drop into a single bracketed
   box, and the box seals green — one message, not three. Same 8s clock. */

.fan { display: grid; gap: .45rem; }
.fan .cap {
  color: var(--ink-faint); font-size: .62rem;
  letter-spacing: .16em; text-transform: uppercase;
}
.fan .calls, .fan .bundle ul { list-style: none; margin: 0; padding: 0; }
.fan .calls { display: grid; grid-template-columns: repeat(3, 1fr); gap: .4rem; }
.fan .calls li, .fan .bundle li {
  border: 1px solid var(--rule); background: var(--paper);
  padding: .3rem .4rem; font-size: .7rem; color: var(--ink);
  text-align: center; opacity: .2;
}
.fan .calls code { font-size: .68rem; border: 0; background: none; padding: 0; }
@keyframes chip {
  0%        { opacity: .2; border-color: var(--rule) }
  6%, 74%   { opacity: 1;  border-color: var(--good) }
  86%, 100% { opacity: .2; border-color: var(--rule) }
}
.fan .calls li { animation: chip 8s linear infinite; }
.fan .calls li:nth-child(1) { animation-delay: 0.3s; }
.fan .calls li:nth-child(2) { animation-delay: 0.7s; }
.fan .calls li:nth-child(3) { animation-delay: 1.1s; }

/* the funnel: three lines converging into one, drawn with borders */
.fan .funnel {
  height: 1.5rem; position: relative;
  border-bottom: 1px solid var(--rule);
  /* 1/6 either side puts the outer legs under the outer chips' centres */
  margin: 0 16.6%;
}
.fan .funnel i {
  position: absolute; top: 0; bottom: 0; width: 0;
  border-left: 1px solid var(--rule);
}
.fan .funnel i:nth-child(1) { left: 0 }
.fan .funnel i:nth-child(2) { left: 50% }
.fan .funnel i:nth-child(3) { left: 100% }
.fan .funnel::after {
  content: "▾"; position: absolute; left: 50%; bottom: -.75rem;
  transform: translateX(-50%); color: var(--good); font-size: .8rem;
  line-height: 1; opacity: 0; text-shadow: 0 0 .4rem var(--good);
  animation: drop 8s linear infinite 1.8s;
}
@keyframes drop { 0% { opacity: 0 } 4%, 12% { opacity: 1 } 16%, 100% { opacity: 0 } }

.fan .bundle {
  border: 1px solid var(--rule); background: var(--paper-2);
  padding: .45rem .5rem; animation: seal 8s linear infinite;
}
@keyframes seal {
  0%, 44%   { border-color: var(--rule);
              box-shadow: none }
  52%, 86%  { border-color: var(--good);
              box-shadow: 0 0 0 1px var(--good) }
  92%, 100% { border-color: var(--rule); box-shadow: none }
}
.fan .bundle > b {
  display: block; margin-bottom: .35rem;
  color: var(--ink-hot); font-size: .62rem;
  letter-spacing: .16em; text-transform: uppercase;
}
.fan .bundle ul { display: grid; gap: .3rem; }
.fan .bundle li { text-align: left; }
.fan .bundle .k { color: var(--good); }
@keyframes land {
  0%        { opacity: .2; transform: translateY(-.4rem) }
  5%, 80%   { opacity: 1;  transform: none }
  90%, 100% { opacity: .2; transform: translateY(-.4rem) }
}
.fan .bundle li { animation: land 8s cubic-bezier(.2,.9,.3,1) infinite; }
.fan .bundle li:nth-child(1) { animation-delay: 2.4s; }
.fan .bundle li:nth-child(2) { animation-delay: 2.8s; }
.fan .bundle li:nth-child(3) { animation-delay: 3.2s; }

/* ‥ figure 6: the stream ‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥
   What `for await` actually hands you: not an answer, a queue of fragments.
   The chips light one at a time along the top (borrowing `chip` from figure 5)
   and the line below grows out of them, because the string is yours to keep. */

.stream { display: grid; gap: .5rem; }
.stream .cap {
  color: var(--ink-faint); font-size: .62rem;
  letter-spacing: .16em; text-transform: uppercase;
}
.stream .chunks {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: .3rem;
}
.stream .chunks li {
  border: 1px solid var(--rule); background: var(--paper);
  padding: .2rem .4rem; font-size: .7rem; color: var(--ink); opacity: .2;
  animation: chipin 8s cubic-bezier(.2,.9,.3,1) infinite;
}
.stream .chunks li:nth-child(1) { animation-delay: 0.3s; }
.stream .chunks li:nth-child(2) { animation-delay: 0.9s; }
.stream .chunks li:nth-child(3) { animation-delay: 1.5s; }
.stream .chunks li:nth-child(4) { animation-delay: 2.1s; }
.stream .chunks li:nth-child(5) { animation-delay: 2.7s; }
.stream .chunks li:nth-child(6) { animation-delay: 3.3s; }
.stream .chunks li:nth-child(7) { animation-delay: 3.9s; }
/* the accumulator. Same content-box trick as figure 3: the `ch` width is the
   text alone, and the step count must equal the character count — 34 of each. */
.stream .line {
  border: 1px solid var(--rule); background: var(--paper-2);
  padding: .4rem .5rem; overflow: hidden;
}
.stream .line span {
  display: block; box-sizing: content-box;
  overflow: hidden; white-space: nowrap; width: 34ch;
  font-size: .74rem; color: var(--ink);
  border-right: .5rem solid var(--good);
  animation: growline 8s steps(34, end) infinite, caret .9s step-end infinite;
}
@keyframes growline { 0%, 4% { width: 0 } 62%, 100% { width: 34ch } }

/* ‥ figure 7: the VRAM budget ‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥
   Three loads against one card. The bar box *is* the card, so 100% is the
   whole of it — which makes the third row's overflow the picture rather than a
   sentence. Each segment carries its own `--w`, in percent of the card. */

.gauge { display: grid; gap: .55rem; }
.gauge .row {
  display: grid; grid-template-columns: 7rem 1fr 5.2rem;
  gap: .5rem; align-items: center;
}
.gauge .row > b {
  color: var(--ink-hot); font-weight: 600; font-size: .66rem;
  letter-spacing: .1em; text-transform: uppercase;
}
/* clipped, so a load that does not fit is visibly cut off at the card's edge */
.gauge .bar {
  position: relative; height: 1.35rem; display: flex; overflow: hidden;
  border: 1px solid var(--rule); background: var(--paper);
}
.gauge .bar i {
  flex: 0 0 var(--w); font-style: normal; font-size: .58rem;
  line-height: 1.35rem; white-space: nowrap; overflow: hidden;
  padding: 0 .25rem; color: var(--paper); background: var(--good);
  animation: fillbar 8s cubic-bezier(.3,.9,.3,1) infinite;
}
/* the conversation's own cache, in a second colour: it grows after the load,
   and it is the half people forget to budget for */
.gauge .bar i.kv { background: var(--ink-faint); animation-delay: .5s; }
@keyframes fillbar { 0% { flex-basis: 0 } 24%, 92% { flex-basis: var(--w) }
                     100% { flex-basis: 0 } }
/* the verdict is the reading of the bar, so it waits for the bar: in at 30%,
   just after the load and its cache have settled, out with everything else */
.gauge .v {
  font-size: .62rem; letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-faint);
  animation: verdict 8s linear infinite;
}
@keyframes verdict {
  0%, 26%   { opacity: 0 }
  32%, 90%  { opacity: 1 }
  96%, 100% { opacity: 0 }
}
/* the failure row: the overspill is hatched rather than filled, and the
   verdict is the only hot text in the figure */
.gauge .row.over .kv {
  background: repeating-linear-gradient(45deg,
    var(--ink-faint) 0 3px, rgba(0,0,0,0) 3px 6px);
  color: var(--ink);
}
.gauge .row.over .v { color: var(--ink-hot); }
/* the failure, put on the clock rather than left as a static label: the card's
   own edge takes the flare at the instant the overspill reaches it, and the
   verdict stutters once on the way in — a device that was lost, not a bar that
   filled up. Same 8s cycle, no colour introduced, nothing that flashes twice. */
.gauge .row.over .bar { animation: rim 8s linear infinite; }
@keyframes rim {
  0%, 26%   { border-color: var(--rule);   box-shadow: none }
  32%, 90%  { border-color: var(--ink-hot);
              box-shadow: inset 0 0 0 1px var(--ink-hot) }
  96%, 100% { border-color: var(--rule);   box-shadow: none }
}
.gauge .row.over .v { animation: verdict-lost 8s steps(1, end) infinite; }
@keyframes verdict-lost {
  0%, 26%   { opacity: 0 }
  32%       { opacity: 1 }
  35%       { opacity: 0 }
  38%, 90%  { opacity: 1 }
  96%, 100% { opacity: 0 }
}

/* ‥ figure 8: the context wall ‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥
   Drawn to scale on purpose. The whole track is a hosted window; the sliver at
   the left is the one you get in a tab. The fill grows and stops dead at it. */

.scale { display: grid; gap: .35rem; }
.scale .track {
  position: relative; height: 1.6rem;
  border: 1px solid var(--rule); background: var(--paper);
}
/* 8192 of 200000 is 4.1%, and it is meant to look like that */
.scale .track i {
  position: absolute; left: 0; top: 0; bottom: 0; width: 4.1%;
  background: var(--good);
  animation: fillwall 8s cubic-bezier(.2,.9,.3,1) infinite;
}
@keyframes fillwall { 0% { width: 0 } 55%, 100% { width: 4.1% } }
.scale .wall {
  position: absolute; left: 4.1%; top: -2px; bottom: -2px; width: 2px;
  background: var(--good); opacity: .35;
  animation: wallhit 8s linear infinite;
}
@keyframes wallhit {
  0%, 50%   { opacity: .35; box-shadow: none }
  58%, 76%  { opacity: 1;   box-shadow: 0 0 .45rem var(--good) }
  84%, 100% { opacity: .35; box-shadow: none }
}
.scale .legs {
  display: flex; justify-content: space-between; gap: .5rem;
  color: var(--ink-faint); font-size: .62rem; letter-spacing: .04em;
}
.scale .legs .l { color: var(--ink); }

@media (max-width: 34rem) {
  .pair { grid-template-columns: 1fr; }
  /* label, bar and verdict each get their own line rather than three cramped
     columns — the bar is the part that has to stay wide */
  .gauge .row { grid-template-columns: 1fr; gap: .2rem; }
  .scale .legs { flex-direction: column; }
  /* stacked: the arrow turns to point down the page and the seam goes away */
  .pair .cross {
    width: auto; height: 1.2rem; transform: rotate(90deg);
    animation: none; color: var(--good);
  }
  .pair .cross::before { display: none; }
  .wire.h { width: 1.8rem; }
  .wire i { display: none; }
  .node { font-size: .7rem; }
  .fan .calls { grid-template-columns: 1fr; }
  .fan .funnel { display: none; }
}

/* Motion off: freeze every figure in its finished state. The pulse parks on the
   wire, all four stations stay lit, the tape is full, the JSON is complete, and
   the ladder is climbed.

   NOT wired to `prefers-reduced-motion` any more, on purpose. These figures are
   the explanation, not decoration around it — a reader with Reduce Motion set
   at the OS level was getting the frozen picture and no diagram, which is the
   wrong trade for a lesson whose whole point is a loop going round. The motion
   is slow (one 8s cycle), small, and never flashes, so it stays on for
   everyone. Put `data-motion="off"` on <html> to get the frozen state back. */
[data-motion="off"] .diag *,
[data-motion="off"] .diag *::before,
[data-motion="off"] .diag *::after,
[data-motion="off"] .lamps i { animation: none !important; }
[data-motion="off"] .lamps i:first-child { background: var(--good); }
[data-motion="off"] .node::after { opacity: 1; }
[data-motion="off"] .ring .node { background: var(--paper-3); }
[data-motion="off"] .pair .cross { color: var(--good); }
[data-motion="off"] .fan .calls li,
[data-motion="off"] .fan .bundle li { opacity: 1; }
[data-motion="off"] .fan .bundle { border-color: var(--good); }
[data-motion="off"] .fan .funnel::after { opacity: 1; }
[data-motion="off"] .ladder li { opacity: 1; }
[data-motion="off"] .ladder::after { height: calc(100% - 1rem); }
[data-motion="off"] .tape li { opacity: 1; }
[data-motion="off"] .pair .out { width: 17ch; border-right-color: var(--good); }
[data-motion="off"] .pair .msgs .msg { opacity: 1;
                                       border-left-color: var(--good); }
[data-motion="off"] .pair .toks li { opacity: 1; }
[data-motion="off"] .wire::after { opacity: 1 }
[data-motion="off"] .wire.h::after { left: 50% }
[data-motion="off"] .wire.v::after { top: 50% }
[data-motion="off"] .stream .chunks li { opacity: 1; }
[data-motion="off"] .stream .line span { width: 34ch;
                                         border-right-color: var(--good); }
[data-motion="off"] .gauge .bar i { flex-basis: var(--w); }
[data-motion="off"] .gauge .v { opacity: 1; }
[data-motion="off"] .gauge .row.over .bar { border-color: var(--ink-hot); }
[data-motion="off"] .scale .track i { width: 4.1%; }
[data-motion="off"] .scale .wall { opacity: 1; }

/* ── the screen ───────────────────────────────────────────────────────────────
   A video embed, wearing the same dashed panel the figures wear so it reads as
   part of the page rather than a hole punched through it. `aspect-ratio` on the
   frame means no padding-box trick and no width/height to keep in sync — the
   iframe fills whatever column it lands in. Unlike the figures, this one does
   need the network, so it is only ever an extra: everything it shows is also
   written out below it. */

.tube .screen {
  aspect-ratio: 16 / 9;
  border: 1px solid var(--rule); background: var(--edge);
}
.tube .screen iframe { display: block; width: 100%; height: 100%; border: 0; }

/* ── tips ─────────────────────────────────────────────────────────────────────
   The aside for the thing that saves an hour. Kept visually quieter than a
   figure and louder than a paragraph, because it is read out of order. */

.tip {
  margin: 1.5rem 0; padding: .6rem .8rem;
  border: 1px solid var(--rule); border-left: 3px solid var(--good);
  background: var(--paper-2);
}
.tip > b {
  display: block; margin-bottom: .25rem;
  color: var(--ink-hot); font-size: .62rem;
  letter-spacing: .18em; text-transform: uppercase;
}
.tip p { margin: 0; font-size: .85rem; color: var(--ink); }
.tip p + p { margin-top: .5rem; }

/* ── the question list ────────────────────────────────────────────────────────
   A definition list read out of order, one question at a time, so each pair is
   ruled off rather than indented. The question is the loud half. */

.faq { margin: 1.5rem 0 0; }
.faq dt {
  color: var(--ink-hot); font-weight: 600;
  border-top: 1px dashed var(--rule); padding-top: .8rem;
}
.faq dd { margin: .35rem 0 1.1rem; font-size: .9rem; }

/* ── the index list ───────────────────────────────────────────────────────── */

.log { list-style: none; padding: 0; margin: 0; }
.log li { border-bottom: 1px dashed var(--rule); padding: 1rem 0; margin: 0; }
.log a { color: var(--ink-hot); font-weight: 600; text-decoration: none; }
.log a:hover { text-decoration: underline; text-underline-offset: 3px; }
.log p { margin: .35rem 0 0; font-size: .88rem; }

/* ── call to action ───────────────────────────────────────────────────────── */

.cta {
  display: inline-block; margin-top: .5rem;
  border: 2px solid var(--good); color: var(--paper); background: var(--good);
  padding: .35rem .9rem; text-decoration: none;
  font-weight: 600; letter-spacing: .1em; text-transform: uppercase;
  font-size: .8rem;
}
.cta:hover { color: var(--paper); filter: brightness(1.1); }

/* ── signup ───────────────────────────────────────────────────────────────────
   The same stack the harness wears on its boot notice — label, field, button,
   note, one per row, sigil out in the margin — restated here because these
   documents do not load css/buttercup.css. Kept a copy on purpose: the two can
   drift without either page breaking.

   HOUSE RULE for every page in this directory, the archive index included:
   the form appears TWICE — `.signup.lede` at the top (inside the article
   header, right under the standfirst) and `.signup` in the `.subscribe`
   section at the foot. Two ids, `signup-lede` and `signup`, so the labels
   stay unambiguous; one `<iframe name="signup-sink">` at the foot serves both.
   signup.js binds every `form.signup` it finds, so adding a copy needs no JS
   change. New posts start from an existing page rather than from scratch. */

.subscribe { margin: 3rem 0 0; border-top: 1px dashed var(--rule); padding-top: 1.5rem; }
/* the index's last entry already closed with a dashed rule; one is enough */
.log + .subscribe { border-top: 0; padding-top: 0; }
.subscribe > h2 { margin-top: 0; }

.signup {
  display: grid; grid-template-columns: auto minmax(0, 22rem);
  gap: .35rem .6rem; justify-content: start; margin: .8rem 0;
}
.signup .sigil { grid-row: 1; align-self: baseline;
  color: var(--ink-hot); font-weight: 700; }
.signup > :not(.sigil) { grid-column: 2; }
.signup .slabel { color: var(--ink-dim); font-size: .7rem; letter-spacing: .2em; }
/* the button sizes to its word rather than the field above it */
.signup button {
  justify-self: start; margin-top: .15rem; cursor: pointer;
  font: inherit; font-size: .68rem; letter-spacing: .12em; text-transform: uppercase;
  color: var(--ink); background: var(--paper-3);
  border: 1px solid var(--rule); padding: .18rem .5rem;
}
.signup button:hover { background: var(--ink); color: var(--paper); }
.signup input[type="email"] {
  font: inherit; color: var(--ink-hot); background: var(--paper);
  border: 1px solid var(--rule); padding: .35rem .5rem; min-width: 0;
}
.signup input[type="email"]:focus-visible { outline: 2px solid var(--ink); outline-offset: -1px; }
.signup input[type="email"]::placeholder { color: var(--ink-faint); }
/* the honeypot: off-screen rather than `display:none`, which the crude bots
   skip and the thorough ones read as a tell. Nothing here changes the layout. */
.signup .trap {
  position: absolute; left: -9999px; top: auto; width: 1px; height: 1px;
  overflow: hidden; border: 0; padding: 0;
}
.signup .note, .signup .thanks {
  grid-column: 2 / -1; margin: 0; font-size: .74rem; color: var(--ink-faint);
}
.signup .thanks { display: none; color: var(--good); }
/* sent: the fields have done their job, so the row collapses to the receipt */
.signup[data-sent="1"] .sigil, .signup[data-sent="1"] .slabel,
.signup[data-sent="1"] input, .signup[data-sent="1"] button,
.signup[data-sent="1"] .note { display: none; }
.signup[data-sent="1"] .thanks { display: block; grid-column: 1 / -1; }

/* the lede copy of the form — the one that used to be a green CTA button, so
   its button keeps the CTA's colour and weight. Everything else is the same. */
.signup.lede { margin: 1.2rem 0 .2rem; }
.signup.lede button {
  border: 2px solid var(--good); background: var(--good); color: var(--paper);
  font-size: .8rem; letter-spacing: .1em; padding: .35rem .9rem;
}
.signup.lede button:hover {
  background: var(--good); color: var(--paper); filter: brightness(1.1);
}
/* when a green CTA already sits above the form, that link is the loud thing on
   the page — the subscribe button steps back to the quiet outlined style. */
.cta + .signup.lede { margin-top: 1.4rem; }
.cta + .signup.lede button {
  border: 1px solid var(--rule); background: var(--paper-3); color: var(--ink);
  padding: .3rem .8rem; filter: none;
}
.cta + .signup.lede button:hover {
  background: var(--ink); color: var(--paper); filter: none;
}

@media (max-width: 40rem) {
  /* narrow: the field takes whatever is left rather than holding 22rem */
  .signup { grid-template-columns: auto minmax(0, 1fr); }
}

footer.foot {
  margin-top: 3rem; border-top: 2px solid var(--rule); padding-top: 1rem;
  color: var(--ink-faint); font-size: .8rem;
}

@media (max-width: 600px) {
  body { font-size: 14px; }
  .titlebar .where { display: none; }
}
