/* ============================================================================
   CANVAS — photographic grounds that HOLD while content moves over them.
   Art direction v3, ledger V3-1.
   ----------------------------------------------------------------------------
   THE PROBLEM THIS REPLACES. v2 put photographs in the document flow as framed
   figures between blocks of text. Every page was therefore block, rule, block,
   rule, and the photography was punctuation rather than ground. The founder's
   verdict was "a mechanically built static site that doesn't flow".

   THE PRIMITIVE. A .canvas is two layers:

     .canvas-bg    position: sticky — the photograph, held for the section
     .canvas-flow  the type, scrolling over it, pulled up over the held image

   Scrolling into a canvas, the image arrives and STOPS. Panels of type move
   across it. At the end of the section the image releases and the next ground
   takes over. That is the "invisible scrolling canvas" — the reader never sees
   a mechanism, only an image that refuses to leave until the story is done.

   ⛔ IT IS position: sticky, NOT A SCROLL LIBRARY, AND THAT IS THE WHOLE POINT.
   Addendum A rules out Locomotive/Lenis for this audience by name: district IT
   and special-education directors on managed Windows, using keyboard paging and
   Ctrl+F to find a WAC citation. Scroll hijacking breaks find-in-page, keyboard
   paging and screen-reader navigation. Sticky is native scroll — the page moves
   exactly as every other page moves, Ctrl+F works, and there is no wheel
   handler anywhere in this file's behaviour.

   ⛔ AND IT SURVIVES reduced-motion FOR FREE. Sticky positioning is not an
   animation, so a reader with prefers-reduced-motion still gets the held image
   and the layered composition — they lose only the gradient wipe. The pinned
   layout is not gated on motion, which is the opposite of the usual pinned-
   section trap where turning motion off destroys the page.
   ========================================================================= */

/* ── THE HORIZON DESCENT, MADE REAL ───────────────────────────────────────
   tokens.css has declared zenith → sky → surface → deep since day one and
   NOTHING USED THE SEQUENCE — pages were one flat --sky. The descent is the
   site's one structural metaphor and it existed only as four variable names.
   A canvas declares where it sits in the descent and the ground follows. */
/* ⛔ clip, NOT hidden. The lateral arrivals start 2.6rem outside the column,
   which at the frame edge is outside the VIEWPORT, and an uncontained overflow
   there is a horizontal scrollbar on every deck page. `overflow: hidden` would
   contain it and would also make this element a scroll container — which
   breaks `position: sticky` on .canvas-bg, the held photograph, one child
   down. `overflow-x: clip` clips without creating a scroll container, and is
   the reason the other axis can stay `visible` at all: with `hidden`, setting
   one axis forces the other to `auto`. */
.canvas { position: relative; isolation: isolate; overflow-x: clip; overflow-y: visible; }
.canvas-zenith  { --ground: var(--zenith);  --ground-next: var(--sky); }
.canvas-sky     { --ground: var(--sky);     --ground-next: var(--surface); }
.canvas-surface { --ground: var(--surface); --ground-next: var(--deep); }
.canvas-deep    { --ground: var(--deep);    --ground-next: var(--deep); }
.canvas { background: linear-gradient(180deg, var(--ground, var(--sky)) 0%,
                                              var(--ground-next, var(--sky)) 100%); }

/* ── LAYER 1 — the held photograph ────────────────────────────────────────
   svh, not vh: on mobile the URL bar collapses and 100vh is taller than the
   visible viewport, so a "full-screen" canvas is cropped by exactly the chrome
   height and the composition's focal point sits under the browser UI. */
.canvas-bg {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
  z-index: 0;
}
.canvas-bg img,
.canvas-bg picture {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* ⚠ The cap in layout.css must NOT apply here. A canvas ground is the one place
   a photograph is allowed to fill the viewport, because it is BEHIND type
   rather than being read as an image — softness at the edge of a ground is
   invisible where the same softness in a plate is the whole subject. The
   fidelity harness still records the real ratio; C18's ceiling is a rule about
   images the reader LOOKS AT. */
.canvas-bg .photo { max-width: none; margin-inline: 0; height: 100%; }
.canvas-bg .photo > picture { width: 100%; max-width: none; aspect-ratio: auto; height: 100%; }

/* A canvas ground has NO fixed aspect: it fills whatever the held frame is.
   Named explicitly rather than left undefined so a reader grepping the class
   finds a rule and not a silence. */
.photo-canvas { aspect-ratio: auto; }

/* ── THE VEIL — and it is a GRADIENT, not a flat wash ─────────────────────
   Type sits on this. A flat 25% veil is the v2 treatment and it is why the
   photographs looked like they were behind frosted glass everywhere equally.
   Here the veil is darkest where the type lands and clears where it does not,
   so the photograph is actually visible in the parts of the frame nobody is
   reading over. Contrast is MEASURED per composition — see V3-7; the veil is
   tuned to a measurement, never to taste. */
.canvas-veil {
  position: absolute; inset: 0; pointer-events: none;
  background:
    linear-gradient(100deg,
      color-mix(in srgb, var(--sky) 92%, transparent) 0%,
      color-mix(in srgb, var(--sky) 78%, transparent) 34%,
      color-mix(in srgb, var(--sky) 34%, transparent) 68%,
      color-mix(in srgb, var(--sky) 20%, transparent) 100%),
    linear-gradient(180deg,
      color-mix(in srgb, var(--sky) 55%, transparent) 0%,
      transparent 22%,
      transparent 74%,
      color-mix(in srgb, var(--ground-next, var(--sky)) 88%, transparent) 100%);
}
/* The canvas hands off to the next ground rather than ending on a seam. */
.canvas-veil::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 22svh;
  background: linear-gradient(180deg, transparent, var(--ground-next, var(--sky)));
}

/* ── LAYER 2 — the type, moving over the held image ───────────────────────
   The negative pull is what puts the flow ON the image rather than after it.
   Its magnitude is exactly one viewport, so the first panel starts at the top
   of the held frame. */
.canvas-flow {
  position: relative;
  z-index: 1;
  margin-top: -100svh;
}
/* ── THE PANEL IS SCROLL DISTANCE; THE INNER IS WHAT YOU READ ──────────
   The outer carries height and nothing else. The inner is sticky, so it holds
   at the top of the viewport while the reader scrolls through that height, and
   fades in and out at the ends of it. The effect is writing that ARRIVES,
   stays while you read it, and clears for the next — rather than a column of
   text sliding past a photograph.

   ⚠ THE HOLD IS panel height MINUS viewport height, and that arithmetic is
   the whole design. At 160svh the inner pinned for 60svh — measured, about 540px
   at a 900px viewport — which is over before the reader has finished the first
   sentence. 220svh gives 120svh of genuine hold: the type arrives, stays put
   through a real stretch of scrolling, and clears. Longer than that and the
   page stops responding to the scroll at all, which reads as broken. */
.canvas-panel {
  min-height: 220svh;
  /* 🪤 ch IS RELATIVE TO THE PANEL'S OWN font-size, not to the display type
     inside it. At 62ch the panel was ~620px while the h1 was 106px, so the
     headline broke every 5 characters and ran off the bottom of the viewport.
     A poster measure has to be set against the DISPLAY size, so it is rem. */
  max-width: var(--panel-w, 44rem);
}
/* Alternating side is art direction, not decoration: it walks the reader's eye
   across the frame so the photograph is read as a place rather than a backdrop. */
/* THE INNER — pinned, and the only thing with padding or a measure. */
.canvas-panel-in {
  position: sticky;
  top: 0;
  height: 100svh;
  display: grid;
  align-content: center;
  padding: clamp(2rem, 6vh, 5rem) clamp(1.25rem, 5vw, 4.5rem);
  /* clearance for the two fixed corner controls, which travel over every
     section — without it the last line sits under the wordmark */
  padding-bottom: 6rem;
  max-width: var(--panel-w, 44rem);
}
.canvas-panel-r .canvas-panel-in { margin-left: auto; text-align: left; }
.canvas-panel-c .canvas-panel-in { margin-inline: auto; text-align: center; max-width: 48ch; }

/* A canvas that is one statement rather than a sequence — the flow is a single
   viewport tall, so the image holds only as long as the statement is on screen. */
/* A canvas that is one statement rather than a sequence holds for a single
   viewport: there is nothing to cross-fade TO, so extra distance is just a
   pause with no second beat at the end of it. */
.canvas-single .canvas-flow { margin-top: -100svh; }
.canvas-single /* ⭐ CLAMPED. `min-height: 100svh` meant a taller window produced a taller
   panel holding the SAME content, so the emptiness scaled with the display.
   Measured on /programs:

     1440x810  (what I had been testing)   panel  810px   density 48 91 32 91 63%
     1235x1300 (the founder's window)      panel 1300px   density 22 94 17 94 34%

   Five-sixths empty on the worst beat, and invisible to me because every
   viewport I tested was landscape. A panel is a composition, not a container
   that should grow without limit — past about 900px the extra height is not
   breathing room, it is a gap.

   ⛔ ONE VARIABLE, BECAUSE TWO ELEMENTS DEPEND ON IT. .canvas-panel-in needs a
   DEFINITE height (the plate fitter's minmax(0,1fr) track resolves against it,
   and has silently stopped working twice when it did not). Clamping the outer
   and leaving the inner at 100svh would make them disagree at exactly the
   window sizes this fix is for. */
/* ⭐ SHRINK-WRAPPED, WITH ONE EXCEPTION THAT IS NOT NEGOTIABLE.
   A prose beat is now as tall as its prose plus a rhythm, so a two-sentence
   beat gets a short screen instead of a full one holding 75% air. Measured on
   /programs at 1235x1300 before: 22 / 94 / 17 / 94 / 34%.

   ⛔ A BEAT CARRYING A PLATE KEEPS A DEFINITE HEIGHT. .canvas-panel-in is the
   containing block the plate fitter's `minmax(0, 1fr)` track resolves against,
   and an `fr` track needs a DEFINITE height — not `auto`, not `min-height`.
   That exact substitution has silently disabled the plate bound twice in this
   project, both times shipping tables that overflowed their panel with nothing
   red anywhere. So the shape decides: :has(.plate-fit) keeps --deck-h, prose
   shrink-wraps. Derived from the content, not from a list of page names.

   The floor is 52vh rather than 0 for the same reason the conductor's is 46vh:
   the photograph behind these panels is sticky, and sticky travel is the
   parent's height minus the pinned child's. Collapsing every beat to two lines
   would leave the image nothing to be held through. */
/* ⛔ NO VIEWPORT-RELATIVE FLOOR. I first set `min-height: 52vh` here to protect
   the sticky photograph's travel, and it made the problem WORSE: 52vh is 676px
   on a 1300px window, so a two-sentence beat still floated in most of a screen
   and density fell to 9-36%. A vh floor recreates the exact defect being fixed,
   because it scales with the display just as 100svh did.

   The travel constraint is on the SECTION, not the beat. .canvas-bg is sticky
   inside section.canvas, so the travel is the canvas's total height minus one
   viewport — and a canvas holds five to ten beats. No individual beat has to be
   tall for the photograph to be held; they only have to add up, which they do
   by a wide margin. Measured after this change, below.

   What remains is rhythm in rem, so the separation between beats is the same
   physical distance on every display rather than a fraction of it. */
.canvas-panel {
  padding-block: clamp(2rem, 4vh, 4rem);
}
.canvas-panel:has(.plate-fit) {
  min-height: var(--deck-h);
  padding-block: 0;
}
.canvas-single .canvas-panel-in { position: static; height: auto; min-height: 100svh; }

/* ── THE WATERLINE AS A RECURRING EVENT ───────────────────────────────────
   Addendum C: exactly ONE horizon per viewport, only ever a 1px rule. In v2 it
   appeared once, under the nav, and the site's central structural idea was
   therefore invisible. Here a canvas may carry one, at the optical horizon of
   its own photograph. */
.canvas-line {
  position: absolute; left: 0; right: 0; top: var(--horizon, 62%);
  height: 0; border-top: var(--hair) solid var(--waterline);
  opacity: .55; z-index: 1; pointer-events: none;
}
@media (min-resolution: 2dppx) { .canvas-line { border-top-width: var(--hair-strong); } }

/* ── GRADIENT-WIPE REVEAL ─────────────────────────────────────────────────
   v2's reveal was fade-up-on-entry, applied uniformly, which is the single most
   generic motion on the web. This wipes the panel in along the narrative axis
   using a mask, so type arrives the way light moves across water rather than by
   changing its opacity.

   ⛔ THE HIDDEN START STATE LIVES ONLY INSIDE @supports. A browser without
   scroll-driven animation must render the finished state. This is the failure
   mode Addendum A names explicitly: elements hidden by default, animation never
   runs, page appears blank. */
/* ── ARRIVE, HOLD, CLEAR ─────────────────────────────────────
   The previous rule faded each CHILD in as it entered the viewport, which is
   the ordinary scroll-reveal every site has: text appears once and then travels
   up the page like any other column. What is wanted is the opposite — the type
   holds still and the SCROLL changes what is written.

   So the animation is on the pinned inner and is driven by the panel's own
   progress: in over the first fifth, held for the middle, out at the end. The
   next panel's inner is already fading in as this one leaves, so they
   cross-fade in the same place.

   ⛔ THE HIDDEN STATE LIVES ONLY INSIDE @supports. A browser without
   scroll-driven animation renders every panel at full opacity, stacked in
   normal flow — long, but complete and readable. Hiding by default and relying
   on the animation to reveal is the failure mode Addendum A names by name. */
.canvas-panel-in { /* default: fully arrived. Do not add opacity:0 here. */ }

/* ⛔ THE SCROLL-WELDED REVEAL THAT WAS HERE IS GONE. It ran
   `animation-timeline: view()` over `cover 0% → 100%`, opacity only, with no
   timing function — so progress was a straight-line function of scroll offset
   and the transition played at whatever rate the input moved. Measured: a
   100svh panel in a 100svh viewport has a 200svh cover range, keyframes at
   0/30/70/100 put the fade-in across the first 60svh and the fade-out across
   the last 60svh, and one snap is 100svh — half a panel's range. The result
   was a 20svh window of mutual transparency, linear, at the mercy of the
   gesture. Dragging the scrollbar read as a dimmer switch.

   Replaced by the time-based arrivals below. See assets/reveal.js for why the
   clock had to leave the scroll, and for the no-JS fallback. */

/* ── THE ARRIVALS ────────────────────────────────────────────────
   ⭐ DIRECTION IS NOT DECORATION — IT IS READ OFF THE COMPOSITION. Every panel
   already declares a side, because alternating the column across the frame is
   art direction that "walks the reader's eye across the photograph so it is
   read as a place rather than a backdrop". A panel therefore arrives FROM the
   side it occupies. Measured across the built site, 55 beats:

     left ................. 31   drifts in from the left
     right .................. 7   drifts in from the right
     centre, prose .......... 9   soft reveal — scale and fade, no travel
     centre, carrying a plate 8   soft reveal, damped: no scale

   ⭐ AND EVERY PLATE IS ON A CENTRE PANEL — 8 of 8, derived, not arranged. So
   nothing carrying a data table ever travels sideways, and the question of
   whether a table should skate across the frame never arises. A plate is also
   the one thing that must not SCALE: resizing a table mid-arrival reads as the
   figures changing. It gets the lift and the fade and nothing else.

   ⛔ THE TRANSFORM IS ON THE INNER, NEVER ON .canvas-panel — that element
   carries scroll-snap-align, and transforming a snap target invites the snap
   position and the painted position to disagree. The inner is a plain static
   block in the deck and is free to move. */
@media (prefers-reduced-motion: no-preference) {
  html.reveal-ready .canvas-panel-in {
    opacity: 0;
    will-change: opacity, translate, scale;
    /* EXIT: a fade, and only a fade. Travelling out as well as in doubles the
       motion per beat and reads as busy rather than composed. The geometry is
       reset with a delay equal to the fade, so it snaps back to its entry
       position only once it is already invisible — which is what lets the same
       two rules do both directions of travel with no second state class. */
    transition:
      opacity 130ms linear,
      translate 0ms linear 130ms,
      scale 0ms linear 130ms;
  }

  /* ⛔ :where() BECAUSE THE RESTING STATE MUST NEVER OUT-RANK THE ARRIVED ONE.
     These were written as plain descendant selectors, and the plate variant
     — `.canvas-panel-c:has(.plate-fit) .canvas-panel-in`, four classes, because
     :has() contributes the specificity of its argument — beat the arrival rule
     `.canvas-panel-in.is-here` at three. So .is-here was applied, opacity rose
     to 1, and the translate NEVER RESET: every plate panel on the site sat
     permanently 12.8px low. Measured on the guard's first run: 8 panels across
     5 pages, all of them the plate class, and nothing else could see it because
     a 12.8px offset looks like a design choice.

     :where() contributes zero, so all four variants sit at the same
     specificity and SOURCE ORDER decides — which is what a cascade of
     progressively narrower variants should have been doing from the start. The
     arrived rule outranks all of them. */
  /* left is the default side and emits no modifier class */
  html.reveal-ready .canvas-panel-in                              { translate: -2.6rem 0; }
  html.reveal-ready :where(.canvas-panel-r) .canvas-panel-in      { translate: 2.6rem 0; }
  html.reveal-ready :where(.canvas-panel-c) .canvas-panel-in      { translate: 0 1rem; scale: 0.972; }
  /* the damped centre: a plate lifts, and does not resize */
  html.reveal-ready :where(.canvas-panel-c:has(.plate-fit)) .canvas-panel-in {
    translate: 0 .8rem; scale: 1;
  }

  /* ARRIVED. cubic-bezier(.16,1,.3,1) is a long-tailed ease-out: most of the
     distance is covered early and the last few pixels take their time, which is
     what reads as settling into place rather than sliding to a stop. */
  html.reveal-ready .canvas-panel-in.is-here {
    opacity: 1;
    translate: 0 0;
    scale: 1;
    /* In unison with the children above, and with the conductor route. */
    transition:
      opacity 160ms cubic-bezier(.16, 1, .3, 1),
      translate 164ms cubic-bezier(.16, 1, .3, 1),
      scale 164ms cubic-bezier(.16, 1, .3, 1);
  }

  /* The children still resolve in sequence inside the arrival, so a panel lands
     as a composition rather than as one rectangle appearing. Transition-delay,
     not a second animation — one mechanism owns the reveal. */
  /* ⭐ THE ACCELERATED PORT — the low-cost option from the blueprint, and it
     costs NO new JavaScript. The deck has no line-splitter and does not get one:
     splitting prose into spans needs an aria-label wrapper on every element to
     keep its screen-reader name, and on 55 beats that is a lot of DOM for a
     stagger. The paragraphs ARE the ensemble here.

     ⛔ rotateX NEEDS A PERSPECTIVE OR IT IS A VERTICAL SQUASH, and it is set
     per element rather than on a shared ancestor — a page-level perspective
     makes the distortion depend on how far an element sits from the vanishing
     point, so lines near the edge shear while the middle looks right.

     Timing, from the conductor: 150ms transitions on an 18ms stagger. The old
     values were 460ms on a 65-90ms stagger, which is why an arrival read as a
     list assembling rather than a paragraph landing. */
  html.reveal-ready .canvas-panel-in > * {
    opacity: 0;
    transform: perspective(700px) rotateX(45deg) translateY(.6rem);
    transform-origin: 50% 100%;
    transition: opacity 120ms linear, transform 0ms linear 120ms;
  }
  html.reveal-ready .canvas-panel-in.is-here > * {
    opacity: 1;
    transform: perspective(700px) rotateX(0deg) translateY(0);
    /* 150ms was tuned against an 18ms stagger; at 150ms apart the blocks
       need a travel the eye can follow, or each one still pops. */
    transition:
      opacity 420ms cubic-bezier(.16, 1, .3, 1),
      transform 520ms cubic-bezier(.16, 1, .3, 1);
  }
  /* ⛔ 18ms IS NOT A STAGGER, AND THE COMMENT ABOVE ALREADY SAID SO.
     It reads "arithmetically in unison, perceptually invisible, which is why
     the pages still read as text that was simply there" — written when the
     LINE tempo was fixed to 55ms, and the element tempo was left at 18ms in
     the same edit. Four blocks finishing inside 72ms is one event.
     ⭐ 150ms IS THE POINT AT WHICH A HEADING LANDS BEFORE ITS PARAGRAPH.
     Founder direction: "the parent text reveals first and the remaining text
     reveals progressively". The first child carries no delay — it IS the
     parent — and everything under it follows in order. Capped at the sixth so
     a long panel does not take two seconds to finish assembling. */
  html.reveal-ready .canvas-panel-in.is-here > :nth-child(2)   { transition-delay: 150ms; }
  html.reveal-ready .canvas-panel-in.is-here > :nth-child(3)   { transition-delay: 300ms; }
  html.reveal-ready .canvas-panel-in.is-here > :nth-child(4)   { transition-delay: 450ms; }
  html.reveal-ready .canvas-panel-in.is-here > :nth-child(5)   { transition-delay: 580ms; }
  html.reveal-ready .canvas-panel-in.is-here > :nth-child(n+6) { transition-delay: 700ms; }

  /* ── LINES ───────────────────────────────────────────────────
     ⭐ THE TEMPO IS THE MENU'S, DELIBERATELY. The rail staggers eleven items at
     55ms over a 380/440ms transition and reads as something unfolding. The
     deck's element-level stagger was 18ms over 150ms — arithmetically "in
     unison", perceptually invisible, which is why the pages still read as text
     that was simply there. Same numbers as the rail, so the two surfaces move
     the same way and the site has one motion language.

     ⛔ THE HIDDEN STATE IS ON .is-split, WHICH ONLY split.js ADDS. No script,
     no fonts, reduced motion — the class is never set and the prose is exactly
     what the server sent. A rule that hid .sp-line unconditionally would blank
     every headline on the site the first time the script 404'd.

     The outer clips and the inner moves, so a line rises out of its own mask
     rather than sliding over the line above it. */
  html.reveal-ready .is-split .sp-line { display: block; overflow: hidden; }
  html.reveal-ready .is-split .sp-line-in {
    display: block;
    opacity: 0;
    translate: 0 1.05em;
    transition: opacity 380ms var(--e-out, cubic-bezier(.16,1,.3,1)),
                translate 440ms var(--e-out, cubic-bezier(.16,1,.3,1));
  }
  html.reveal-ready .canvas-panel-in.is-here .is-split .sp-line-in {
    opacity: 1;
    translate: 0 0;
    transition-delay: calc(var(--i, 0) * 55ms);
  }
  /* A split element animates its LINES; it must not also animate as a block,
     or the two stack and the whole thing slides while its lines rise. */
  html.reveal-ready .canvas-panel-in > .is-split { opacity: 1; transform: none; transition: none; }

  /* the emphasis and italics the walk carried through the regrouping */
  .sp-w.hl    { font-weight: 500; }
  .sp-w.is-em { font-style: italic; }
  .sp-w.is-b  { font-weight: 600; }
}
@keyframes hold-and-clear {
  0%   { opacity: 0; }
  18%  { opacity: 1; }
  74%  { opacity: 1; }
  100% { opacity: 0; }
}
@keyframes settle {
  from { translate: 0 .85rem; }
  to   { translate: 0 0; }
}
@keyframes wipe-in {
  from {
    opacity: 0;
    translate: 0 .75rem;
    -webkit-mask-image: linear-gradient(100deg, #000 -28%, transparent 0%);
            mask-image: linear-gradient(100deg, #000 -28%, transparent 0%);
  }
  to {
    opacity: 1;
    translate: 0 0;
    -webkit-mask-image: linear-gradient(100deg, #000 128%, transparent 156%);
            mask-image: linear-gradient(100deg, #000 128%, transparent 156%);
  }
}

/* ── STYLIZED TYPE ON A CANVAS ────────────────────────────────────────────
   Advertisement-grade means art-directed per composition, not one scale applied
   everywhere. What stays fixed is the EMPHASIS MECHANISM: a monospaced fragment
   inside a display line. Addendum A Part A calls the typeface switch "the single
   most transferable idea in the reference" and the exact fix for tinting the
   second clause of every headline — it reads as a citation, a code, a term of
   art, which is native to this subject. Colour is never the emphasis. */
.canvas-panel .d0 {
  font-family: var(--f-display);
  font-weight: 400;
  /* Sized so the longest statement on the site sets in FOUR lines inside the
     panel measure above and still clears the viewport with the rule and lede
     under it. 7.4vw was 106px at 1440 and overflowed by roughly a line. */
  font-size: clamp(2.15rem, 4.5vw, 4.35rem);
  line-height: 0.98;
  letter-spacing: -0.022em;
  text-wrap: balance;
  margin: 0;
}
.canvas-panel .d0 .mono {
  font-family: var(--f-data);
  font-size: 0.62em;
  letter-spacing: -0.01em;
  /* optical: the mono sits small, so lift it onto the display baseline */
  display: inline-block;
  transform: translateY(-0.06em);
}
.canvas-panel .lede {
  font-size: clamp(1.02rem, 1.35vw, 1.28rem);
  line-height: 1.55;
  max-width: 46ch;
  color: color-mix(in srgb, var(--star) 88%, transparent);
  margin: 1.4rem 0 0;
}
/* A single hairline under a statement, in the accent that MEANS the thing being
   claimed. Never decorative — the accent is semantic everywhere on this site. */
.canvas-rule { border: 0; border-top: var(--hair) solid var(--mira);
               width: 4.5rem; margin: 1.75rem 0 0; opacity: .8; }

/* ── NARROW VIEWPORTS ─────────────────────────────────────────────────────
   Below the breakpoint a held image plus overlaid type is a bad trade: the
   image is small, the type is large, and the two fight. The canvas becomes an
   image followed by its type — ordinary, legible, and honest about the space. */
@media (max-width: 700px) {
  /* ⚠ HISTORICAL, AND KEPT BECAUSE THE HAZARD IT DESCRIBES IS INSTRUCTIVE.
     This rule makes the held image a banner above the type. Under the old
     mandatory snap the page then jumped straight past it on load — measured at
     390x844, scrollY 523 on arrival, the image 507px above the viewport, every
     mobile visitor landing on a blank screen below the photograph.
     Snap is retired site-wide now, so the banner is simply the top of a
     document and the reader arrives on it. The note stays because "a layout
     that is not a deck was still being driven by the deck's mechanic" is the
     class of defect, and it outlived the specific mechanic. */
  .canvas-bg { position: relative; height: 62svh; }
  .canvas-flow { margin-top: 0; }
  .canvas-panel { min-height: 0; padding-block: clamp(2rem, 8vh, 3.5rem); max-width: none; }
  .canvas-panel-r, .canvas-panel-c { margin-left: 0; margin-inline: 0; text-align: left; }
  .canvas-veil { background:
    linear-gradient(180deg, color-mix(in srgb, var(--sky) 30%, transparent),
                            color-mix(in srgb, var(--ground-next, var(--sky)) 96%, transparent)); }
  .canvas-veil::after { height: 12svh; }
}


/* ══ A PANEL THAT CARRIES A PLATE ═══════════════════════════════════════════
   Direction 2026-08-14: the matrices and charts must be bounded inside the
   visible canvas at desktop AND mobile. The mechanism already existed and was
   being used for prose only — .canvas-panel-in is `height: 100svh` and sticky,
   so ANYTHING inside it is bounded to exactly one viewport by construction.
   A plate becomes a panel, and the bounding stops being a per-plate problem.

   What a plate needs that prose does not is the full width: the 44rem measure
   that makes a paragraph readable would squeeze a five-column matrix into a
   column. */
.canvas-panel-wide { max-width: none; }
.canvas-panel-wide .canvas-panel-in {
  max-width: none;
  width: 100%;
  padding-inline: clamp(1rem, 4vw, 3.5rem);
  padding-bottom: 5.5rem;      /* clearance for the two fixed corner controls */
  /* 26d4 minmax(0, 1fr), AND THE ZERO IS THE ENTIRE FIX.
     .canvas-panel-in is a grid with `align-content: center`, so its row track
     is AUTO — sized to its content. A child's `max-block-size: 100%` resolves
     against that track, so "100 percent" evaluated to the content's own height
     and bounded nothing at all. Measured: .plate-fit reported clientHeight
     3746 inside an 810px panel while dutifully reporting max-block-size: 100%.

     A `1fr` track alone does not fix it either — flexible tracks have an
     automatic minimum of min-content, which for a 3746px table is 3746px. The
     minimum has to be stated as 0 explicitly. This is the same trap as
     `min-height: 0` on a flex child, in the grid spelling. */
  grid-template-rows: minmax(0, 1fr);
  align-content: stretch;
}

/* ── THE BOUND ─────────────────────────────────────────────────────────────
   26d4 THIS RULE IS THE GUARANTEE, AND IT HOLDS WITH SCRIPTING OFF.
   max-block-size + overflow clip means a plate physically cannot extend past
   the panel, whatever its natural height and whether or not fit.js ever runs.
   The script only decides whether the excess is SCALED (readable, whole) or
   CLIPPED (cropped) — it never decides whether the page overflows. */
.plate-fit {
  block-size: 100%;
  max-block-size: 100%;
  min-block-size: 0;
  inline-size: 100%;
  overflow: clip;
  display: grid;
  align-content: center;
  justify-items: center;
}
.plate-fit-in {
  inline-size: 100%;
  transform: scale(var(--fit, 1));
  transform-origin: center center;
  /* No transition. A plate that eases into its size on every resize reads as
     the layout settling rather than as a fixed object, and on orientation
     change it animates a scale the reader never asked to watch. */
}
/* ══ FRAME SEQUENCING INSIDE A BOUNDED PLATE ═══════════════════════════════
   ⛔ THE FAILURE THIS SOLVES, AND WHY NO COUNTING GATE COULD SEE IT.
   `.plate-fit` is a grid with `grid-auto-flow: row`. Give it two frames and
   they become two ROWS — stacked vertically, the second clipped by
   `overflow: clip` — while the provenance marker count still adds up perfectly.
   A green lint over a broken screen. Same class as the caret that never
   rendered while every suite passed.

   ⭐ THE SOLVE IS THE GRID ITSELF, NOT A WORKAROUND. Every frame is placed in
   the SAME CELL (`grid-area: 1/1`), which is the idiomatic way to overlay
   siblings without absolute positioning — and it keeps everything `.plate-fit`
   already does: `align-content: center`, the `--fit` scale transform, and
   intrinsic sizing. Absolute positioning would have discarded all three.

   ⭐ AND IT MAKES THE FAILURE MEASURABLE, which is the point. Overlaid, the
   container is as tall as the TALLEST frame. Stacked, it is as tall as their
   SUM. `_test-frame-sequence.cjs` asserts the former, so a regression to row
   flow fails on a number rather than on somebody noticing.

   `visibility` rather than `display`, deliberately: a hidden frame still sizes
   the grid cell, so the plate is the height of the tallest frame for the whole
   sequence and does not jump as frames change.

   🪤 NEVER HIDE BY DEFAULT (Addendum A). Without `html.seq-ready` — script off,
   or the sequencer not yet run — the frames stay in ROW FLOW and every one is
   readable in document order. The un-sequenced state is a legible document,
   not a blank plate.

   ⭐ KEYED PER PLATE, NOT PER DOCUMENT. `.is-seq-on` is added by seq.js only
   while that plate is on screen, so the RESTING state — off screen, script off,
   reduced motion — is row flow with every frame legible. A sequence must never
   rest on one frame with the rest hidden: a reader who scrolls away and back
   would find a frozen fragment of a story. Same resting semantics as
   mira-demo.js, which shows every turn when it leaves. */
.plate-fit[data-frames].is-seq-on > .plate-fit-in[data-frame] {
  grid-area: 1 / 1;
  visibility: hidden;
}
.plate-fit[data-frames].is-seq-on > .plate-fit-in[data-frame].is-frame-on {
  visibility: visible;
}

/* The plate itself stops being a document element with margins and becomes the
   content of a frame. */
.plate-fit-in > .plate {
  margin: 0;
  max-inline-size: none;
}
/* ⛔ THIS RULE WAS HERE AND IT WAS THE BUG. It read:
       .plate-fit-in .plate, .plate-fit-in .plate * { overflow: visible; }
   on the reasoning that a horizontally scrolling box inside a pinned viewport
   is a trap with no affordance. That reasoning is right about an ACCIDENTAL
   scroller and completely wrong about a designed one.

   Every plate stylesheet already carries a responsive layout, addressed by
   container queries against the `plate` container that layout.css declares —
   five in matrix.css, three in privacy.css, two each in journey.css and
   interop.css, one each in catalog.css and ruler.css. The matrix's narrow
   layout is a scroll-snapping carousel of programme cards, each with its stage
   labels restored via ::before. It is a good design and it predates me.

   Forcing overflow:visible turned that carousel's scroller off and left the
   flex row to spill off the side of the phone — which is precisely the
   "bleeds outside cells, text overlaps" the founder reported. I did not fail to
   build a mobile layout; I broke the one that was there.

   Nothing overrides a plate's overflow now. The no-trap requirement is met by
   the plate laying out correctly at the width it is given, which is the rule
   immediately below. */

/* ⛔ THE PLATE GETS THE FULL WIDTH OF ITS BOX, AND THIS IS WHAT MAKES THE
   CONTAINER QUERIES CORRECT. .plate-fit is a grid with justify-items:center, so
   its child was shrink-to-fit — measured 206px inside a 374px box on a phone.
   The plate declares container-type:inline-size, so 206px is the width every
   @container rule in every plate stylesheet was being asked about: the layouts
   were resolving against a width 45% narrower than the space available, and
   then fit.js scaled the result down again on top of that.
   Stretch, not centre. The plate sees the real width; its own breakpoints do
   the rest. */
.plate-fit { justify-items: stretch; }
.plate-fit-in { inline-size: 100%; min-inline-size: 0; }

/* ── REDUCED MOTION ────────────────────────────────────────────────────────
   The bound and the scale are LAYOUT, not motion — a plate that fits is not an
   animation, and turning it off under `reduce` would hand exactly the readers
   who need it most the 4.6-screen version. Only the FADE is motion, and that is
   governed by the canvas panel rules that already exist. */

/* ── NARROW ────────────────────────────────────────────────────────────────
   On a phone a plate gets the width back that the desktop reserves for the
   composition, because the scale factor is doing the fitting and every
   millimetre of width is one less notch of shrink. */
@media (max-width: 700px) {
  .canvas-panel-wide .canvas-panel-in {
    padding-inline: .5rem;
    padding-bottom: 4.5rem;
  }
}


/* ══ A PLATE IN A PANEL IS NOT A PLATE IN A DOCUMENT ════════════════════════
   26d4 THE BIGGEST SINGLE CAUSE OF THE OVERFLOW WAS OUR OWN OLD MECHANISM.
   journey.css gives every milestone `min-height: 58svh` — correct for the
   .plate-seq treatment it was written for, where each note pinned and held its
   own stretch of scroll. Inside a fitted panel it means seven notes demand four
   viewports before a single word is measured: the journey plate reported 2697px
   natural, and ~2368px of that was empty reserved space.

   The panel now DOES the sequencing, so the plate must stop trying to. Every
   rule below strips a document-flow affordance that the panel supersedes —
   reserved scroll height, sticky offsets, page margins. None of it is styling
   preference; each one is a mechanism that has been replaced and would
   otherwise run twice. */
.plate-fit-in .plate-seq .jr-note { min-height: 0; }
.plate-fit-in .plate-seq .jr-note-in,
.plate-fit-in .plate-seq .plate-head {
  position: static;
  opacity: 1 !important;
  animation: none !important;
}
.plate-fit-in .jr-notes { display: grid; gap: .5rem; }

/* Page-flow margins inside a centred, scaled frame are dead space on both
   sides of the thing being read. */
.plate-fit-in .plate > :first-child { margin-top: 0; }
.plate-fit-in .plate > :last-child { margin-bottom: 0; }

/* ⛔ THE TYPE GETS BIGGER BEFORE IT GETS SCALED, AND THE REASON IS NOT
   CIRCULAR. Scaling a plate by s multiplies every rendered length by s, so
   raising the base font alone buys nothing — natural height grows, the scale
   drops, the rendered size is where it started. What DOES buy legibility is
   that a plate's height is only partly type: padding, gaps, rules, legend
   swatches and the axis are fixed lengths that do NOT grow with font-size. So a
   bump to the type alone shifts the ratio of text-to-chrome in the type's
   favour, and the same scale renders larger letters.
   Measured: base 11px at 0.72 rendered 7.9px, which failed the 9px floor. */
.plate-fit-in .plate { font-size: 1.18em; }

/* ── MORE ROOM ─────────────────────────────────────────────────────────────
   A plate panel does not need the prose panel's breathing space; it needs the
   pixels. Reclaimed 137px of the 810 at a laptop viewport — which is the
   difference between the district matrix fitting and not. */
.canvas-panel-wide .canvas-panel-in {
  padding-block: clamp(.75rem, 2.2vh, 1.75rem) 3.25rem;
}
@media (max-width: 700px) {
  .canvas-panel-wide .canvas-panel-in { padding-block: .5rem 3rem; }
}


/* 🪤 THE WIDEST THING IN THE JOURNEY PLATE WAS INVISIBLE.
   .plate-alt is the text equivalent a screen reader reads instead of the
   drawing — visually hidden, correctly, by clipping. Clipping hides PAINT; it
   does not take the element out of layout, so its 1588px table was still
   setting the plate's scrollWidth. Measured: journey reported 2252px natural
   width in a 1328px box, ratio 0.59, and 1588 of those pixels were a table
   nobody can see.

   Inside a fitted panel it goes absolute, which removes it from the parent's
   scroll extent while leaving it in the accessibility tree exactly as before.
   The visual plate then measures its own width — 1328, ratio 1.00 — and the
   whole scale budget goes to the height, where the content actually is. */
.plate-fit-in .plate-alt {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  inset-block-start: 0;
  inset-inline-start: 0;
}


/* ══ THE VEIL GOES NEUTRAL ══════════════════════════════════════════════════
   26d4 THE THIRD PLACE THIS TINT WAS HIDING, AND THE LARGEST.
   .canvas-veil was built from `var(--sky)` — #0A1024, a blue-black — laid over
   the photograph at up to 92% where the type sits. On the landing plate the
   same mistake appeared as an opacity gap and a #01030A wash; here it is the
   whole treatment, on ten pages, over every photograph on the site.

   The founder has objected to colour casting three times and has now supplied
   a frame with instructions not to alter it. A veil is not a grade, but at 92%
   it is doing the same thing to the same pixels, and "it is a CSS layer, not an
   encode" is a distinction only the person who wrote it cares about.

   So the veil is NEUTRAL BLACK. It still darkens exactly as much where the type
   lands — the contrast measurements the veil was tuned to are about LUMINANCE
   and are unchanged — and it no longer decides what colour the photograph is.

   ⚠ ONE EXCEPTION, AND IT IS STRUCTURAL: the bottom hand-off keeps
   var(--ground-next). That gradient is not over the photograph, it is the seam
   where the canvas meets the next section's background, and it has to arrive at
   that background's actual colour or the join becomes a visible band. */
.canvas-veil {
  background:
    linear-gradient(100deg,
      rgba(0,0,0,.88) 0%,
      rgba(0,0,0,.72) 34%,
      rgba(0,0,0,.30) 68%,
      rgba(0,0,0,.16) 100%),
    linear-gradient(180deg,
      rgba(0,0,0,.50) 0%,
      transparent 22%,
      transparent 74%,
      color-mix(in srgb, var(--ground-next, #000) 88%, transparent) 100%);
}


/* ══ A PLATE IN A BOUNDED PANEL EXPANDS ON DEMAND, NOT BY DEFAULT ═══════════
   matrix.css opens every cell's detail at <=639px, with the reason: "on touch
   there is no hover: show every detail rather than hiding content behind an
   interaction the device cannot perform."

   That is right in a scrolling document and inverted inside a pinned panel.
   Measured at 390px: the expanded carousel is 1083px of the panel's 788, so the
   whole plate is scaled to 0.55 and every character lands at 5.6px. Showing all
   the content produced a plate on which NONE of it can be read.

   And the premise is not quite true either. Hover is unavailable on touch; TAP
   is not. Every cell already carries tabindex="0", and matrix.css already
   expands on :focus-within — a tap focuses the cell and the detail opens. So
   the content is one gesture away rather than absent, and the gesture is one
   the device can perform.

   ⛔ WHICH MEANS IT NEEDS AN AFFORDANCE. Content behind an unmarked
   interaction is content nobody finds; that is the real form of the concern the
   original comment was reaching for. Each collapsed cell now carries a visible
   marker, and it disappears once the cell is open. */
@container plate (max-width: 639px) {
  .plate-fit-in .matrix-detail { grid-template-rows: 0fr; }
  .plate-fit-in td.matrix-cell:focus-within .matrix-detail,
  .plate-fit-in td.matrix-cell:focus-visible .matrix-detail,
  .plate-fit-in td.matrix-cell:active .matrix-detail { grid-template-rows: 1fr; }

  .plate-fit-in td.matrix-cell .matrix-label { position: relative; padding-right: 1.4rem; display: block; }
  .plate-fit-in td.matrix-cell .matrix-label::after {
    content: '+';
    position: absolute; inset-inline-end: 0; inset-block-start: 0;
    font-family: var(--f-data);
    color: var(--waterline);
    opacity: .8;
  }
  .plate-fit-in td.matrix-cell:focus-within .matrix-label::after { content: '2'; }

  /* The carousel is the one horizontal scroller that is DESIGNED — it snaps,
     it has card edges, and the cards are obviously side by side. It is not the
     accidental overflow the fit rules exist to prevent, so it keeps its
     scroller and gets a scrollbar that can be seen. */
  .plate-fit-in .matrix-scroll { scrollbar-width: thin; padding-bottom: .35rem; }
}

/* The legend is marginalia and it is LONG — 540px at 390px wide, against a
   788px panel. It belongs with the last card, once, not on all three panels.
   Handled at build time (build-site.js lastOnly); this is the note that says
   where to look. */


/* ══ THE SMALLEST TYPE IN A FITTED PLATE ════════════════════════════════════
   ⛔ THE PLATE-WIDE `font-size: 1.18em` ABOVE DID NOT REACH THE LABELS THAT
   MATTER, and that is why the founder was still reading 6.6px text.
   --m-tick is declared in REM, so it is anchored to the root and a bump on the
   plate's own font-size does nothing to it. Measured in the matrix: .cite
   10.12px, .tick / .matrix-dens / .matrix-work / .badge-label all 11px — every
   one of them a rem token, all untouched, all multiplied by the fit scale.
   At 0.65 that is 6.6px on screen.

   Raised here, in the fitted context only, so the document-flow rendering of
   the same plates is unchanged.

   ⚠ AND IT IS A FIXED BUMP, NOT A SCALE-COMPENSATED ONE. The tempting version
   is `max(var(--m-tick), calc(9.5px / var(--fit)))`, which would guarantee the
   floor at any scale. It is a feedback loop: fit.js measures natural height at
   --fit:1, the type then grows once a scale is applied, the content becomes
   taller than what was measured, and the bound it just computed is wrong — or
   ResizeObserver re-fires and the two chase each other. A fixed bump cannot
   oscillate. The floor is then earned by keeping the SCALE high, which is what
   splitting is for. */
.plate-fit-in {
  --m-tick: 0.8125rem;   /* 13px, from 11 */
}
.plate-fit-in .cite { font-size: 0.8125rem; }
.plate-fit-in .badge-label { font-size: 0.8125rem; }


/* ══ THE PANEL IS ONE VIEWPORT ══════════════════════════════════════════════
   ⛔ 220svh WAS THE TAX AND IT IS GONE.
   The old panel reserved 220svh so its sticky inner could hold still for
   120svh while the reader travelled through the rest. The holding worked; the
   travelling was the cost. Measured across eight pages: 2.2 screens of
   scrolling per beat, 63 screens for 28 beats, roughly 510 wheel notches to
   read the site.

   A panel is now exactly one viewport and a scroll-snap stop, so one flick
   moves one beat and the type CROSS-FADES in place instead of rising from the
   bottom of the screen. Same three beats, three screens instead of seven.

   min-height rather than height: if a beat ever outgrows the viewport it stays
   scrollable rather than being trapped by mandatory snapping. _test-beats.cjs
   measures that at three viewport sizes and fails the build. */
/* ⭐ SHRINK-WRAPPED. `min-height: 100svh` meant a taller window produced a
   taller panel holding the SAME content, so the emptiness scaled with the
   display. Measured on /programs at 1235x1300: 22 / 94 / 17 / 94 / 34%.

   ⛔ THIS IS THE RULE THAT GOVERNS, AND I EDITED THE WRONG ONE FIRST. Four
   separate rules set .canvas-panel's height in this file; the earlier ones are
   dead against this, which comes later and wins on source order. My first
   shrink-wrap landed near the top and changed nothing — density came back
   BYTE-IDENTICAL, which is what exposed it. When a change produces identical
   numbers, the edit did not take.

   ⛔ AND NO VIEWPORT-RELATIVE FLOOR. A `min-height: 52vh` attempt made it worse
   — 676px on a 1300px window — because a vh floor recreates the exact defect:
   it scales with the display. The travel the sticky photograph needs belongs to
   the SECTION (canvas height minus one viewport, across five to ten beats), not
   to any single beat. */
/* ⛔ min-height: 0 EXPLICITLY, AND IT IS NOT REDUNDANT. The first
   `.canvas-panel` rule in this file still declares `min-height: 220svh` from
   the original long-scroll design. The rule that used to sit here overrode it
   with 100svh; deleting that override without neutralising what it was
   overriding brought 220svh back, and density fell from 22% to 4% with panels
   at 2860px on a 1300px window. Removing an override is not the same as
   removing a constraint. */
.canvas-panel { min-height: 0; padding-block: clamp(2rem, 4vh, 4rem); }

/* A beat carrying a plate keeps a definite height: .canvas-panel-in is the
   containing block the fitter's minmax(0,1fr) track resolves against, and that
   substitution has silently disabled the plate bound twice already. */
.canvas-panel:has(.plate-fit) { min-height: var(--deck-h); padding-block: 0; }

/* 🪤 THE INNER NEEDS A DEFINITE HEIGHT, AND `min-height` IS NOT ONE.
   .plate-fit is bounded by a `minmax(0, 1fr)` grid track inside this element.
   A fr track resolves against a DEFINITE container height; against `height:
   auto; min-height: 100svh` it falls back to content, so the plate bound
   silently stopped working the moment the panel got shorter — the /pilot status
   figures went straight back to 978px in an 810px box. This is the same defect
   as the original `max-block-size: 100%` against an auto track, arriving from
   the other direction. */
/* The inner keeps a definite height ONLY where a plate needs one to resolve
   against; elsewhere it follows its content, which is the whole point. */
.canvas-panel-in { height: auto; min-height: 0; position: static; }
.canvas-panel:has(.plate-fit) .canvas-panel-in { height: var(--deck-h); }

/* ⛔ AND THE INNER'S OWN PADDING IS MOST OF WHAT IS LEFT. The base rule sets
   `clamp(2rem, 6vh, 5rem)` top and `6rem` bottom — 78px + 96px on a 1300px
   window, on top of the panel's own 104px. On a beat holding 200px of prose
   that is 278px of padding around 200px of content: 42%, and it reads as the
   gap it is.

   The 6rem bottom existed as clearance for the fixed corner controls "without
   it the last line sits under the wordmark". That clearance is now the scrim
   in beacon.css, which dims content behind the chrome at every width — so the
   padding was solving a problem twice, once with space and once with a
   gradient. The space is the half that shows.

   A full-height beat (one carrying a plate) keeps the original breathing room;
   only shrink-wrapped prose tightens. */
.canvas-panel:not(:has(.plate-fit)) .canvas-panel-in {
  padding-block: clamp(1rem, 2.5vh, 2rem);
  padding-bottom: clamp(1.25rem, 3vh, 2.5rem);
}

/* ⛔ AND THE MOBILE OVERRIDE HAS TO GO — IT PREDATES THE DECK.
   `@media (max-width: 700px)` above sets `.canvas-panel { min-height: 0;
   padding-block: clamp(2rem, 8vh, 3.5rem) }`, which was right when a panel was
   a block in a scrolling document. On a deck it adds 56px top and bottom to an
   element that must be EXACTLY one viewport: measured 956px against an 844px
   screen, on every panel of every page, which under mandatory snapping is 112px
   of every beat that cannot be reached. The inner already carries the padding
   the content needs. */
@media (max-width: 700px) {
  /* Narrow viewports shrink-wrap too — the same argument and more sharply,
     because a phone is the tallest display relative to its width. Plates keep
     their box. */
  .canvas-panel { padding-block: clamp(1.5rem, 5vh, 3rem); }
  .canvas-panel:has(.plate-fit) { min-height: 100svh; padding-block: 0; }
}

/* ⛔ THE RANGE RE-TUNING THAT WAS HERE IS GONE WITH THE MECHANISM. It moved
   the scroll-driven fade window from a 220svh panel to a 100svh one. The
   arrival no longer has a scroll window to tune — it has a duration. */

/* A single-statement canvas keeps its own arithmetic — it was already one
   viewport and has nothing to cross-fade to. */
/* ⛔ AND canvas-single KEEPS ITS FULL HEIGHT, SO IT MUST NOT ALSO TAKE THE
   SHRINK-WRAP PADDING. A single-statement canvas is deliberately one viewport
   tall; the padding-block added for shrink-wrapped beats stacks ON TOP of that
   min-height, which made /about and /documents 875px in an 810px viewport and
   failed the "every beat fits its viewport" check. A beat taller than the
   screen is exactly what that check exists to catch, and it caught it. */
.canvas-single .canvas-panel { min-height: 100svh; padding-block: 0; }

/* ══ SECTIONS GET THE SAME ARRIVAL AS PANELS ════════════════════════
   ⛔ ONLY DECK PANELS WERE EVER OBSERVED. reveal.js queried
   `.canvas-panel-in` and nothing else, so every surface built from plain
   <section class="sect"> — which is what /about and /documents are, and what
   any non-deck page would be — scrolled flat.
   reveal.js now marks those columns with data-reveal and observes them too.
   ⚠ EXCEPT THE WHITE PAPERS, by founder direction: a reference document is
   read straight through and staging its paragraphs would fight that. The
   exclusion is a body class, so it is visible in the markup rather than
   encoded in a selector nobody can find. */
@media (prefers-reduced-motion: no-preference) {
  html.reveal-ready [data-reveal] > * {
    opacity: 0;
    transform: translateY(1.05rem);
    transition: opacity 120ms linear, transform 0ms linear 120ms;
  }
  html.reveal-ready [data-reveal].is-here > * {
    opacity: 1;
    transform: none;
    transition:
      opacity 420ms cubic-bezier(.16, 1, .3, 1),
      transform 520ms cubic-bezier(.16, 1, .3, 1);
  }
  html.reveal-ready [data-reveal].is-here > :nth-child(2)   { transition-delay: 150ms; }
  html.reveal-ready [data-reveal].is-here > :nth-child(3)   { transition-delay: 300ms; }
  html.reveal-ready [data-reveal].is-here > :nth-child(4)   { transition-delay: 450ms; }
  html.reveal-ready [data-reveal].is-here > :nth-child(5)   { transition-delay: 580ms; }
  html.reveal-ready [data-reveal].is-here > :nth-child(n+6) { transition-delay: 700ms; }
}

/* ══════════════════════════════════════════════════════════════════════════
   THE PINNED STAGE — beats cross-fade in place; nothing slides past an edge.
   ──────────────────────────────────────────────────────────────────────────
   ⛔ WHAT THIS RESTORES, AND WHY IT HAD TO BE REBUILT RATHER THAN UNCOMMENTED.
   Founder 2026-08-16, with 12 screenshots: "when you scroll and it moves
   between beats, it doesn't show scrolling in from top or bottom, the beat is
   faded into full frame centered ... you don't have these edge contaminated
   views."

   That mechanism was BUILT (commit 74ecd427, "text holds in place and
   cross-fades") and is still written at lines 137-160 of this file — sticky
   inner, height 100svh, align-content: center. It was cancelled by later rules
   in two steps, and the second step was justified by a measurement error:

     · density was computed as composition-span / PANEL height, and the panel
       was 220svh of SCROLL DISTANCE. The reader never sees 220svh; they see
       the pinned 100svh frame. Re-measured 2026-08-16 on /programs at
       1235x1300: span/PANEL 36-74%, span/FRAME 18-39%. The metric divided
       content by scroll distance.
     · and it counted the PHOTOGRAPH as emptiness. On a canvas page the
       photograph is the ground the type sits on — "photographs become ground,
       type moves over them" is the design, not a gap to be closed.

   ⛔ BUT SIMPLY RESTORING STICKY DOES NOT MEET THE BRIEF, and this is the part
   the original design never solved. A sticky inner of height 100svh inside a
   parent of height H travels for a FULL SCREEN before it pins — that entry
   travel IS "scrolling in from the bottom". The original hid it behind a fade,
   which means a screen of scrolling where the beat is invisible.

   So the beats come OUT OF FLOW instead. The canvas becomes a scroll track,
   one screen per beat; a single pinned stage holds the frame; and every beat is
   absolutely positioned inside it, centered, at opacity 0. Scrolling changes
   WHICH beat is opaque, never where any of them sits. Travel distance is zero
   by construction, so there is no edge to leak past — the guarantee is
   structural rather than a fade that has to outrun the geometry.

   ⛔ GATED ON html.reveal-ready, WHICH IS SET BY JAVASCRIPT. Absolute stacking
   with no script would paint every beat on top of every other one. Without JS
   — or under prefers-reduced-motion, which reveal.js returns on before setting
   the class — none of this applies and the page stays in normal flow: long,
   readable, complete. That is Addendum A's rule and it is why the whole block
   sits behind the class rather than in the base cascade.
   ═════════════════════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: no-preference) {

  /* The track. --beats is set per canvas by reveal.js from the panels it
     actually finds, so a page that gains a beat gains a screen of scroll with
     nothing to keep in step. The +1 is the exit: the last beat needs a screen
     of track to hold through, or it unpins the instant it arrives. */
  html.reveal-ready .canvas-stage {
    height: calc((var(--beats, 1) + 1) * 100svh);
    position: relative;
  }

  /* The frame. One per canvas, pinned for the whole track. */
  html.reveal-ready .canvas-stage .canvas-flow {
    position: sticky;
    top: 0;
    height: 100svh;
    margin-top: -100svh;          /* keeps the photograph behind, as before */
    overflow: hidden;             /* the structural guarantee: nothing leaves */
  }

  /* The beats, stacked. inset:0 gives every one the same frame, so a beat
     cannot be a different size from its neighbour and cannot be half on
     screen. align-content:center is the fix the density pass never tried —
     the air the old full-height panels pooled at the BOTTOM is now split
     evenly above and below, which is matting rather than a gap. */
  html.reveal-ready .canvas-stage .canvas-panel {
    position: absolute;
    inset: 0;
    min-height: 0;
    max-width: none;
    display: grid;
    align-content: center;
    padding-block: 0;
    pointer-events: none;         /* only the arrived beat takes the pointer */
  }
  /* 🪤 :has(), BECAUSE reveal.js MARKS THE INNER, NOT THE PANEL. This was
     written as .canvas-panel.is-here first and never matched anything, so the
     arrived beat stayed pointer-events:none and every link inside it was dead
     to the mouse. The same mistake broke the beacon's active dot in the same
     change — one class, two readers, both assuming the wrong element. */
  html.reveal-ready .canvas-stage .canvas-panel:has(.canvas-panel-in.is-here) { pointer-events: auto; }

  /* The measure and the side composition stay exactly as they were — the
     panel is full-bleed now, so the column that was the panel's max-width
     becomes the inner's, and -l / -r / -c keep placing it across the frame. */
  /* ⛔ DO NOT SET max-width HERE. I did, as `var(--panel-w, 44rem)`, and it was
     the most expensive line in this block. Specificity:
       mine                              html.reveal-ready .canvas-stage .canvas-panel-in   (0,3,1)
       the wide-panel rule it outranked  .canvas-panel-wide .canvas-panel-in                (0,2,0)
     So every WIDE panel — which is every panel carrying a plate — was silently
     squeezed to a 44rem reading measure. Nothing looked broken; the plate simply
     wrapped more and grew taller, and /mira's went 492px → 862px inside an 810px
     frame, then scaled to 0.55 and rendered 6.9px type.
     🪤 The tell was that it did not read as a WIDTH bug at all. Three of the
     four plate-fit assertions failed on HEIGHT, scale and font size, and
     reasoning from those three led away from the cause every time. An A/B on
     the pre-refactor arm answered it in one run: 492px of 810 at scale 1.00.
     Measure the two arms before theorising about the symptom.
     The inner already has its measure from the base cascade (44rem, 48ch for
     centred, wider for .canvas-panel-wide). It does not need restating, and
     restating it is what broke it. */
  html.reveal-ready .canvas-stage .canvas-panel-in {
    position: static;
    height: auto;
    min-height: 0;
    padding-block: clamp(1rem, 3vh, 2.5rem);
    padding-bottom: clamp(2rem, 5vh, 4rem);   /* clear of the fixed chrome */
  }

  /* 🪤 THE PLATE BOUND, WHICH HAS SILENTLY FAILED TWICE IN THIS PROJECT.
     .plate-fit is bounded by a minmax(0,1fr) track inside the inner, and an fr
     track needs a DEFINITE height — not auto, not min-height. Under this model
     the panel IS definite (inset:0 in a 100svh stage), so the inner can take a
     real height instead of the --deck-h special case. min() of two definite
     lengths is itself definite, which is what keeps the fitter working while
     still capping a table at the height past which it stops being readable. */
  html.reveal-ready .canvas-stage .canvas-panel:has(.plate-fit) .canvas-panel-in {
    /* ⛔ NOT `min(100%, …)`. I wrote that first and the plate bound broke for
       the THIRD time in this project's history, from a direction the file's
       own warning had not covered: the panel is `display:grid;
       align-content:center`, so the grid AREA is content-sized, and a
       percentage height against a content-sized area is a cyclic dependency
       the browser resolves as `auto`. Auto is not definite, the fitter's
       minmax(0,1fr) track fell back to content, and /mira's plate rendered
       862px inside an 810px frame — caught by _test-plate-fit, which is the
       only reason it is not shipping.
       --deck-h is `min(100svh, 900px)`: two absolute lengths, definite by
       construction, and independent of what the grid does above it. */
    height: var(--deck-h);
    padding-block: 0;
  }

  /* ⛔ NO TRAVEL ON THE BEAT ITSELF. The directional drift (translate 2.6rem
     from the side the panel occupies) was art direction for beats that ARRIVED
     by scrolling; against a stage where nothing moves it reads as the beat
     sliding when the founder asked for it not to. What remains is the fade and
     a scale so slight it registers as focus rather than motion.
     The sub-element reveal is untouched — split.js still brings the lines in
     one after another, in place, which is the "subtext progressively revealing
     in place" half of the brief and the half that was already right. */
  html.reveal-ready .canvas-stage .canvas-panel-in,
  html.reveal-ready .canvas-stage :where(.canvas-panel-l) .canvas-panel-in,
  html.reveal-ready .canvas-stage :where(.canvas-panel-r) .canvas-panel-in,
  html.reveal-ready .canvas-stage :where(.canvas-panel-c) .canvas-panel-in {
    translate: 0 0;
    scale: 0.994;
    opacity: 0;
    transition: opacity 260ms ease, scale 260ms ease;
  }
  html.reveal-ready .canvas-stage .canvas-panel.is-here .canvas-panel-in,
  html.reveal-ready .canvas-stage .canvas-panel-in.is-here {
    opacity: 1;
    scale: 1;
  }
}

/* ══ STANDING — prose where a row of stat blocks used to be ═══════════════
   Founder 2026-08-16: the stat rows "are not reading well against the photo
   backgrounds, wrap information weird across multiple rows".

   Both halves of that are geometry. A three-column row over a photograph gives
   each column roughly a third of the frame, so a six-word label wraps four
   times; and three floating columns give the eye no line to follow, which is
   what "not reading well against the photo" is. One paragraph at the reading
   measure fixes both at once — see standing() in build/build-site.js.

   ⛔ AND IT FIXES THE ALIGNMENT FINDING IN THE SAME MOVE. Measured before
   this: 26 beats where the heading sat in a 44rem measure above a figures row
   spanning the full wide panel — leftSpread 6px, rightSpread 632-777px. A
   heading and its content disagreeing on the right edge by 632px is the
   "aligned containers between headers and content" complaint, and it existed
   because the two elements had different containers. Prose shares the
   heading's container, so there is nothing left to misalign. */
.standing {
  max-width: 54ch;              /* the reading measure, not the panel's width */
  margin-block: .9rem 0;
}
/* A derived number, emphasised inside the sentence rather than set as a
   column. Same weight as the surrounding text so the line still reads as a
   line — the old treatment was 5rem type, which is what made three of them
   into competing columns in the first place. */
.standing .n-in {
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--star);
  white-space: nowrap;          /* "600,000" never breaks across a line */
}
/* Same measure as the prose above it. A block <p> holding one small button
   fills its container, so without this the box edges disagree with the
   paragraph by ~630px even though the ink does not. Boxes that agree are
   cheaper than explaining to the next reader why they do not. */
.standing-more { margin-block: 1.1rem 0; max-width: 54ch; }

/* ⛔ THE PROSE AND ITS HEADING MUST SHARE A MEASURE, and a wide panel would
   otherwise let the heading run to the full frame while the paragraph stops at
   54ch — re-creating the ragged right one level up. Applied to the heading in
   any beat that carries a standing block, derived by :has() rather than by a
   class somebody has to remember to add. */
.canvas-panel-in:has(.standing) .d2,
.canvas-panel-in:has(.standing) .d3 {
  max-width: 54ch;
}

/* ══ ONE MEASURE PER BEAT ═════════════════════════════════════════════════
   Founder 2026-08-16: "verify all text is wrapping correctly and has aligned
   containers between headers and content. Determine if there are any other
   outliers with offset padding keeping them from either aligning left, center
   or right evenly."

   Measured by comparing each child's INK extent against the beat's INTENDED
   alignment — left-set beats share a left edge, right-set a right edge,
   centre-set a centre. Raw box comparison is the wrong instrument here: a
   block <p> always fills its container, so it reports a 630px "misalignment"
   the reader cannot see, and it flags legitimate centring as a defect.

   42 outliers over 4px, and the largest group was MINE: `.panel-say`, the
   prose block added above each product screenshot in this same session, had no
   measure of its own, so it centred on the panel while the plate beneath it
   centred on its own narrower width. Six pages, three viewports, eighteen
   instances of a caption not lining up with the thing it captions.

   The fix is one rule rather than one per element: every text child of a beat
   takes the same measure and the same inline centring, so the beat's alignment
   is a property of the BEAT and not something each new block has to remember.
   A block added next year inherits it. */
.canvas-panel-in > .panel-say,
.canvas-panel-in > .standing,
.canvas-panel-in > .standing-more,
.canvas-panel-in > .small,
.canvas-panel-in > .figs-src {
  max-width: 54ch;
}

/* ⛔ THE SAME DEFECT ONE SIBLING OVER. The rule at the foot of this file already fixes
   this for .lede — 'Two measures in one block read as a formatting fault rather than as
   emphasis' — and .small was left at 54ch, so a citation under a paragraph wrapped to a
   different column. Founder, on /leaders: the wrapping "doesn't match the paragraph above
   it."
   ⚠ I first tried to fix this by giving both a shared measure, and it took two attempts to
   notice the file had already decided the answer: 46ch on both still disagreed (553px vs
   413px, because ch is FONT-RELATIVE and .small is set smaller), and a shared rem measure
   matched at 1440 and missed by 12px at 1235. Both were inventions competing with an
   existing rule. The established remedy is to take the panel's measure, not to pick a new
   one — READ WHAT THE FILE ALREADY DOES BEFORE ADDING A VARIABLE TO IT. */
.canvas-panel-in > .lede + .small,
.canvas-panel-in > .lead + .small {
  max-width: none;
}
/* Centre-set beats centre every child on the same axis. margin-inline: auto is
   what makes the ink centres agree; text-align alone centres the LINES inside
   each box while leaving the boxes themselves at different widths. */
.canvas-panel-c .canvas-panel-in > .panel-say,
.canvas-panel-c .canvas-panel-in > .standing,
.canvas-panel-c .canvas-panel-in > .standing-more,
.canvas-panel-c .canvas-panel-in > .small,
.canvas-panel-c .canvas-panel-in > .d2,
.canvas-panel-c .canvas-panel-in > .d3,
/* .figs and .hero-actions joined the list on the second measurement pass, not
   the first — they are the only two children that are grids rather than text,
   so they centre on their own track sizing and were the last six outliers on
   the site, all on /journey. Named here rather than left to a wildcard: a
   wildcard would also catch .plate-fit, which must keep its own geometry. */
.canvas-panel-c .canvas-panel-in > .figs,
.canvas-panel-c .canvas-panel-in > .hero-actions,
/* ⛔ .lead AND .lede WERE MISSING FROM THIS LIST and they are the most common
   child on the site. Measured on /journey: one lede centred at x=332 in a
   1440px frame while its own heading centred at 720 — a 388px offset, on the
   page the founder reads first. It looked centred wherever the panel itself
   happened to be narrow, which is why it survived: the defect only appears
   when the container is wider than the text's own measure. */
.canvas-panel-c .canvas-panel-in > .lead,
.canvas-panel-c .canvas-panel-in > .lede {
  margin-inline: auto;
  text-align: center;
}
/* 🪤 text-align DOES NOTHING TO A FLEX ROW. .hero-actions is display:flex, so
   centring its ink needs justify-content — it was sitting at x=635 against its
   heading's 720 with `text-align: center` applied and inert. A rule that is
   present, matching, and has no effect is worse than a missing one: it reads
   as covered. */
.canvas-panel-c .canvas-panel-in > .hero-actions {
  justify-content: center;
}
/* 🪤 A RIGHT-SET BEAT NEEDS NOTHING HERE, AND THE RULE I WROTE WAS DEAD.
   I added `margin-left: auto` for .canvas-panel-r children after the probe
   reported /privacy and /leaders as right-edge outliers. Measured: the rule
   MATCHED the element and computed to 0px, because .canvas-panel-in is already
   the width its children cap at — there is no free space for `auto` to take.
   The probe was wrong, not the layout. `.canvas-panel-r .canvas-panel-in` sets
   `margin-left: auto; text-align: left`: the COLUMN goes right, the text inside
   stays left-aligned, which is correct for readability. Comparing ink right
   edges on deliberately ragged-right text reports every short last line as a
   misalignment.
   Recording it rather than deleting it silently, because the next person to run
   an alignment probe will reach the same wrong conclusion from the same class
   name. The instrument now reads each child's own computed text-align. */

/* ══ A BEAT THAT SAYS SOMETHING AND SHOWS SOMETHING ═══════════════════════
   Founder 2026-08-16, with a 2560x1440 capture: "some of them pin a screencast
   to the bottom and content to the top with a large gap."

   ⛔ THE CAUSE IS MINE AND IT IS A GRID ROW. .canvas-panel-in for a plate beat
   declares `grid-template-rows: minmax(0, 1fr)` — ONE explicit row, sized to
   take everything, because for years the plate was the only child. `.panel-say`
   (the caption added this session so no screenshot ships unexplained) became
   the FIRST child, so it took the 1fr row and the plate was pushed into an
   implicit `auto` row beneath it. Caption stretched to the top, plate shrunk to
   the bottom, ~270px of nothing between them.

   ⚠ AND THE OBVIOUS REPAIR IS THE TRAP THIS FILE HAS ALREADY SPRUNG TWICE.
   `grid-template-rows: auto auto` with `align-content: center` would centre the
   pair — and would make the plate's row `auto`, which is not a DEFINITE height,
   which silently disables the fitter's minmax(0,1fr) bound. The plate row stays
   `minmax(0, 1fr)`; the caption takes `auto`; the gap is a real gap rather than
   leftover space. Nothing is left over, so nothing pools. */
.canvas-panel-in:has(> .panel-say) {
  /* ⚠ auto auto + centre, NOT auto 1fr + stretch. Measured at 2560x1440 with
     the 1fr version: the plate's ROW was 570px for 261px of content, so the
     pair sat 270px from the top and 424px from the bottom. Everything was
     "centred" — the inner in the frame, the plate in its row — and the
     composition still read high, because a row sized to the LEFTOVER is not a
     row sized to the thing in it.
     ⚠ AND IT IS max-height, NOT height — MEASURED, NOT ASSUMED. A definite
     `height` was the cautious choice, on this file's own warning that the
     fitter's minmax(0,1fr) track needs a definite container. It left the plate
     in a 520px row holding 261px of content: skew 154px -> 129px, better and
     still visibly high. `max-height` lets the box shrink to its content and
     caps it at the same ceiling: skew 129px -> 0px, and _test-plate-fit still
     passes all five checks including the /paper-deadlines 5,744px ledger.
     The bound survives because fit.js MEASURES the box and sets --fit; the CSS
     track is the no-JS backstop, and a max-height is a backstop too. Recorded
     because the conservative reading of the warning was wrong here, and the
     only way to know that was to try it and run the test. */
  grid-template-rows: auto auto;
  gap: clamp(0.75rem, 2vh, 1.75rem);
  align-content: center;
}
.canvas-panel-in:has(> .panel-say) > .plate-fit {
  max-height: min(58svh, 520px);
}
.panel-say { margin: 0; }

/* ⛔ AND A WIDE PLATE MUST STILL CLEAR THE RAIL. At 2560 the plate ran
   edge-to-edge and passed UNDER the fixed navigation on the right — measured on
   the same capture. `canvas-panel-wide` drops the reading measure, which is
   correct for a table and wrong for the viewport: "no measure" is not the same
   as "the full window". The rail is the constraint, so the bound is stated
   against it rather than against a number that looks about right. */
.canvas-panel-wide .canvas-panel-in,
.canvas-stage .canvas-panel-wide .canvas-panel-in {
  max-width: min(100%, 96rem);
  margin-inline: auto;
  padding-inline: clamp(1.25rem, 4vw, 5rem);
}

/* ══ THE STAGE STOPS AT THE MOBILE BREAKPOINT ════════════════════════════
   Founder 2026-08-16: "the formatting collapse that pushed content to bottom
   of screen is happening when you make the page very narrow ... primarily a
   problem in mobile portrait but it is showing up in a monitor split screen
   when the screen is less than 50% of the space."

   ⛔ THE CAUSE IS MINE, AND IT IS A CONFLICT I DID NOT CHECK FOR. Below 700px
   the canvas DELIBERATELY stops being a pinned photograph and becomes a normal
   document — commit V30, "mobile was a deck mechanic running on a document
   layout". Measured on /measure: at 430px .canvas-bg computes to
   position:relative, height 578px; at 900px and 1440px it is sticky at 932px.

   I applied the pinned stage unconditionally. So under 700px the CONTENT was
   pinned for six screens of scroll while the PHOTOGRAPH scrolled away after
   578px — type floating in an empty field, which is exactly what "formatting
   collapse" describes. The two halves of one mechanic, one pinned and one not.

   🪤 And it explains why four probe runs found nothing: 1235, 1440, 1920 and
   2560 are all above the breakpoint. The bug lived entirely in the width band
   I never tested, and the founder found it by resizing a window. A probe that
   samples only the sizes you designed at will confirm your design.

   ⚠ ALL PANELS MUST BE VISIBLE HERE, not just the arrived one. reveal.js keeps
   .is-here on a single beat; that is correct for a stage where they are stacked
   and wrong the moment they are back in flow, where it would render one beat
   and hide the rest. Opacity is forced rather than left to the arrival rules.
   ⚠ 700px matches the existing breakpoint exactly. A second, different
   breakpoint would give a band where the bg is one thing and the flow another
   — the defect this block exists to remove. */
@media (max-width: 700px) {
  html.reveal-ready .canvas-stage {
    height: auto;
  }
  html.reveal-ready .canvas-stage .canvas-flow {
    position: static;
    height: auto;
    overflow: visible;
    /* ⛔ AND THE NEGATIVE PULL GOES WITH IT. `.canvas-flow` carries
       `margin-top: -100svh` so the type sits ON the held photograph. That is
       only correct while the photograph IS a held, viewport-tall frame. Below
       700px it is a normal document image sized to its own aspect — measured
       578px against a 932px viewport — so a -932px pull put the first beat at
       top:-354, a third of a screen ABOVE the picture it was supposed to be
       over. The type was off the top of the image and the reader saw a photo
       followed by empty ground.
       ⚠ I fixed the stacking first and shipped exactly that, because I checked
       that the panels were in flow with real heights and opacity 1 — all true,
       and all irrelevant to where the flow STARTS. Two halves of one mechanic
       again: un-pinning the layer without un-pulling it. */
    margin-top: 0;
  }
  html.reveal-ready .canvas-stage .canvas-panel {
    position: static;
    inset: auto;
    display: block;
    pointer-events: auto;
  }
  /* the arrival is a fade in normal flow again, never a stacked cross-fade */
  html.reveal-ready .canvas-stage .canvas-panel-in,
  html.reveal-ready .canvas-stage .canvas-panel.is-here .canvas-panel-in,
  html.reveal-ready .canvas-stage .canvas-panel-in.is-here {
    opacity: 1;
    scale: 1;
    translate: 0 0;
    height: auto;
    position: static;
  }
  /* ⛔ THE LINE CASCADE NEEDS RELEASING TOO, and forcing the PANEL visible was
     not enough. split.js shreds a lead into per-line spans whose reveal hangs
     off `.canvas-panel-in.is-here .is-split .sp-line-in` (line 423). On the
     stage exactly one panel carries .is-here at a time — correct there. In
     document flow every panel is on screen at once and only one ever gets the
     class, so every other beat rendered its first lines and left the rest
     faded out. Visible in the 430px capture: the last three lines of the
     catalog paragraph dimmed to nothing mid-sentence.
     Third instance of the same shape in this one media block — un-pin the
     layer, un-pull the flow, un-gate the lines. Each was a separate thing the
     stage owned that document flow does not. */
  html.reveal-ready .canvas-stage .is-split .sp-line-in {
    opacity: 1;
    translate: 0 0;
    transform: none;
  }

  /* a caption + plate beat is a stack of two blocks on a phone, not a grid */
  html.reveal-ready .canvas-panel-in:has(> .panel-say) {
    grid-template-rows: auto auto;
  }
  html.reveal-ready .canvas-panel-in:has(> .panel-say) > .plate-fit {
    max-height: none;
  }
}

/* ══ THE SEQUENCE CONTROL ═══════════════════════════════════════════════════
   Founder, 2026-08-17: "cycles too fast and needs way to pause it." A sequence
   a reader cannot stop is one they cannot read, so the pause is a real focusable
   control rather than a hover trick — hover pausing is added on top, not
   instead. The frame dots double as direct navigation, and using one pauses,
   because jumping to a frame is an explicit request to look at it. */
.seq-ctl{
  display:flex;align-items:center;gap:9px;justify-content:center;
  margin-top:7px;opacity:.66;transition:opacity 160ms ease;
}
.canvas-panel:hover .seq-ctl,.seq-ctl:focus-within{opacity:1}
.seq-btn{
  width:20px;height:20px;border-radius:50%;flex:none;
  border:1px solid color-mix(in srgb,var(--star) 26%,transparent);
  background:transparent;color:var(--star-dim);
  font-size:9px;line-height:1;cursor:pointer;padding:0;
}
.seq-btn:hover{color:var(--star);border-color:var(--star-dim)}
.seq-dots{display:inline-flex;gap:5px}
.seq-dot{
  width:6px;height:6px;padding:0;border-radius:50%;cursor:pointer;
  border:1px solid color-mix(in srgb,var(--star) 30%,transparent);background:transparent;
}
.seq-dot.on{background:var(--waterline);border-color:var(--waterline)}
.seq-btn:focus-visible,.seq-dot:focus-visible{outline:2px solid var(--waterline);outline-offset:2px}
@media (prefers-reduced-motion: reduce){ .seq-ctl{display:none} }

/* ══ THE PLATE IS A MONITOR, NOT A SHRINK-WRAP ══════════════════════════════
   Founder, 2026-08-17: "the outer browser container should always stay the same
   size like it represent a real monitor that cant get smaller and larger to
   accommodate the content. You have the content causing the whole frame to jump
   right now."

   Exactly right, and it was a consequence of how the frames overlay: each frame
   sizes the shared grid cell, so a taller frame grew the plate and every switch
   nudged the layout. A screen does not resize when its contents change.

   So the sequenced plate is FIXED at a 16:10 monitor and every frame fills it.
   Content that exceeds it scrolls inside the frame, which is what a real window
   does — the window is the constant.

   ⚠ CORRECTED 2026-08-17, after photographing it. The first attempt paired
   `aspect-ratio:16/10` with `max-block-size:min(62svh,620px)` and the two fight:
   the box takes its inline size from the panel, the ratio asks for a matching
   height, and the cap overrides that height while the width stays put. Measured
   at a 1440 viewport it rendered 1326 x 620 — a 2.14:1 letterbox, not a monitor,
   with the passage stranded in the top third. An aspect-ratio a max-block-size
   can veto is not a constraint.

   ⭐ SO THE HEIGHT CAP DRIVES THE WIDTH. Derive the inline size FROM the same
   cap times the ratio, and the ratio can no longer be overruled from the other
   axis. It also centres, because a monitor narrower than its panel that sits
   hard left reads as a broken float rather than a screen. */
/* ⚠ CORRECTED AGAIN 2026-08-17: the ratio belongs to the SCREEN, not the plate.
   With it on .plate-fit the caption was inside the fixed box and its last line
   was clipped by the monitor edge. The caption now sits below the screen, so
   the plate is auto-height and .pcard-in carries the monitor geometry. */
.plate-fit[data-frames].is-seq-on{
  /* ⚠ 16:10 became 16:11 and the cap rose, on the founder's read: "probably
     need main container taller to accomodate all changes". The workspace tab
     is a dense screen and squeezing it to fit was costing components. */
  /* ⛔ AND THE PLATE OPTS OUT OF fit.js. Measured 2026-08-17: `.plate-fit` is
     `block-size:100%; max-block-size:100%` so it fills its grid cell (520 px at
     a 900 px viewport), and fit.js then SCALES `.plate-fit-in` down to fit —
     0.688 here. The screen rendered 630 px wide inside a 916 px box, throwing
     away a third of the width and shrinking the very content the founder asked
     to make room for. Sequenced plates size themselves, so `--fit:1` and the
     cell cap is released; a screen that is scaled to fit is not a monitor. */
  --mon-h: min(64svh, 640px);
  inline-size: min(100%, calc(var(--mon-h) * 1.4545));
  block-size: auto;
  max-block-size: none;
  align-self: center;
  margin-inline: auto;
}
/* ⛔ THE SCREEN IS A FIXED DESIGN SIZE THAT SCALES WHOLE. Founder, 2026-08-17:
   "at certain overall window sizing ratios the outer wrapper shrinks to size
   [but] the inner content gets jumbled and doesnt adjust size down accordingly
   … it ends up overlapping content on monitor charts, adding scroll bars of
   administration setup but it should have standard common reduction in size."

   Exactly right, and it was an architecture error rather than a tuning one. The
   screen box was `inline-size:100%` of whatever space existed, while everything
   inside it was authored in fixed pixels — so shrinking the box REFLOWED the
   content instead of shrinking it. A monitor does not reflow when you look at
   it from further away.

   So the screen is now a fixed 880x605 (16:11) and fit.js scales the frame to
   whatever room there is — the same machinery every other plate on the site
   already uses, which is why `--fit:1` is no longer forced here. The layout is
   then IDENTICAL at every viewport and only its size changes, which is the
   "standard common reduction" asked for. Legibility stays gated: fit.js floors
   the scale at 0.55 and _test-plate-fit measures rendered text >= 9px after the
   transform. */
.plate-fit[data-frames].is-seq-on .pcard-in{
  inline-size: 880px;
  block-size: 605px;
  flex: none;
}
/* ⛔ THE SCREEN CLEARS THE BEAT PROSE. Measured 2026-08-17: the card spanned
   x301-1139 and the prose column x74-630, so 238 word-spans were painted over
   the screen — over the white reading ground on the administration frame, where
   the monospace prose and the passage made each other unreadable. Both are
   legitimate: this canvas puts its prose over the ground by design. What is not
   legitimate is putting it over a SCREEN, which is opaque and is the thing the
   beat is asking you to look at.
   So above 1100px the screen takes the right half and the prose keeps the left.
   Below that the prose reflows and there is no room to split, so it centres and
   the reader scrolls them apart instead. Width is capped as a FRACTION rather
   than a pixel count — a hardcoded 740 would be wrong at the next breakpoint. */
@media (min-width: 1100px){
  .plate-fit[data-frames].is-seq-on{
    /* ⚠ THE GUTTER IS EXPLICIT. Measured 2026-08-17: the screen's right edge sat
       ON the viewport edge — 1440 exactly at a 1440 viewport — because 56% is
       taken from a full-bleed grid cell, not from the padded content box, so
       right-aligning it walked straight past the canvas gutter. Overflow ran
       46px at 1920 and 82px at 1280, worsening as the viewport narrowed, which
       is the founder's "content cutoff". Reserve the gutter rather than trusting
       the percentage. */
    inline-size: min(52%, calc(var(--mon-h) * 1.4545));
    margin-inline: auto max(4vw, 26px);
  }
  /* …and the prose of THAT BEAT ONLY moves left to meet it. Measured: the say
     block is centred at x455-985 while the screen sits 641-1382, so shifting
     the screen right cut the collision from 238 spans to 14 and no further —
     the remaining ones were the centred lede running underneath it. Scoped with
     :has() so no other beat's composition changes; where :has() is unsupported
     the rule is skipped and the page falls back to the centred layout it has
     always had. */
  .canvas-panel:has(.plate-fit[data-frames]) .panel-say{
    margin-inline: 0 auto;
    max-inline-size: min(44%, 46ch);
  }
}

.plate-fit[data-frames].is-seq-on .pcard-cap{
  flex: none; margin-block-start: 10px; max-inline-size: none;
}
.plate-fit[data-frames].is-seq-on > .plate-fit-in[data-frame]{
  block-size: 100%;
  min-block-size: 0;
}
/* ⚠ block-size:auto, not 100%. The card is now the SCREEN plus a caption of
   varying length, so it sizes to its contents; the fixed geometry belongs to
   .pcard-in alone. */
.plate-fit[data-frames].is-seq-on .pcard{margin-top:0;block-size:auto;display:flex;flex-direction:column}
/* ⛔ flex:none IS THE FIX. With flex:1 the flex algorithm sizes this box and
   aspect-ratio never applies — measured 2.11:1 on a declared 16:11 screen.
   A ratio a sibling property can veto is not a constraint; this is the third
   time that exact sentence has been true on this element. */
.plate-fit[data-frames].is-seq-on .pcard-in{flex:none;display:flex;flex-direction:column}
/* ⚠ position:relative is LOAD-BEARING, not tidiness. Measured 2026-08-17:
   .pcard-body was static, so the kiosk's absolutely-positioned dimmed app
   resolved against an ancestor further up and painted from y=38 — straight
   over .pcard-hd at y=68. The card's own title was underneath the backdrop. */
/* ⚠ The measurement that settled the last 6 px, after two wrong guesses:
   body client 347 / scroll 353, children ws-nav 26 + ws-bar 33 + ws-cols 244,
   and a 13 px bottom pad the card uses when its caption still lived inside it.
   The caption moved out; the pad did not. Widening the goal rail and trimming
   the right column both changed nothing because neither was the term. */
.plate-fit[data-frames].is-seq-on .pcard-body{flex:1;min-block-size:0;overflow:auto;
  position:relative;padding-block-end:2px;display:flex;flex-direction:column}

/* THE LEDE SHARES ITS SIBLINGS MEASURE. Measured on /pilot at 1440: the lede
   right edge sat at 624 and its sibling paragraphs at 632 - an 8px step, plus a
   different characters-per-line because the lede is 1.14rem against the same
   cap. Two measures in one block read as a formatting fault rather than as
   emphasis. Scoped to the canvas panel rather than changing .lede globally,
   which is defined in conductor.css and used across the site. */
.canvas-panel-in > .lede { max-width: none; }
