/* ELAH — scenes 02 to 08. Imported by page.css.
   Split from page.css only for length; the two are one stylesheet and the
   token rule is the same in both: nothing here invents a value. */

.scene { position: relative; }
.scene:not([data-film]) { padding: var(--space-4xl) var(--space-l); }

.scene__stage {
  display: grid;
  place-items: center;
  padding: var(--space-2xl) var(--space-l);
}
/* 40px of gutter each side is 20% of a 390px screen, and the tablet is the
   thing paying for it. 16px gives the device 48px more to be legible in. */
@media (max-width: 46rem) {
  .scene__stage { padding-inline: var(--space-s); }
  .scene:not([data-film]) { padding-inline: var(--space-s); }
}

.scene__headline {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--display-1);
  font-weight: var(--display-weight);
  line-height: var(--display-leading);
  letter-spacing: var(--display-tracking);
  text-transform: var(--display-case);
  text-wrap: balance;
  color: var(--ink);
  max-inline-size: 22ch;
}
.scene__headline--quiet { max-inline-size: 24ch; }
.scene__headline--card { color: var(--invert-ink); }

/* A scene's one line IS a headline; it just is not the heading element. It
   takes the display register, because the alternative is a filler step. */
.scene__line {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--display-1);
  font-weight: var(--display-weight);
  line-height: var(--display-leading);
  letter-spacing: var(--display-tracking);
  text-transform: var(--display-case);
  color: var(--ink);
  max-inline-size: 20ch;
  text-wrap: balance;
}
.scene__sub {
  margin: 0;
  font-size: var(--step-0);
  line-height: var(--leading-body);
  color: var(--ink-muted);
  max-inline-size: var(--measure);
}
.scene__note {
  margin: 0;
  font: var(--weight-regular) var(--step--2)/1.6 var(--font-mono);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--tracking-meta);
  color: var(--ink-subtle);
  max-inline-size: var(--measure-wide);
}

/* Scenes inside a live film stack and cross fade. When scroll-film drops to its
   static edit under reduced motion they must become ordinary blocks again, or
   every beat lands piled on beat one and the later ones are simply gone. */
/* An inactive beat is HIDDEN, not faded to a ghost.
   A 6% opacity ghost looked right and is a contrast violation: axe measures
   the rendered result, and muted ink already tuned to just above AA falls far
   through the floor once it is multiplied by 0.06. This is the same failure
   START-HERE section 8 records for an extra opacity:.8 on muted ink, an order
   of magnitude worse. visibility also takes the beat out of the accessibility
   tree, which is correct: it is not on screen. Delaying the visibility change
   by the fade duration keeps the cross dissolve. */
[data-film-scene] {
  transition: opacity var(--dur-base) var(--ease-emphasis),
              visibility 0s linear var(--dur-base);
}
/* GATED ON THE JS BOOT FLAG, and that gate is the whole point.
   scroll-film sets data-film-state on <html> once it is running, and .is-active
   on a beat only ever comes from JS. Hiding beats unconditionally meant that
   with scripting off, five of this page's eight text blocks were stacked at
   grid-area 1/1 with visibility:hidden and NOTHING could ever reveal them. Same
   shape as the [data-reveal] container bug in START-HERE section 8: content
   suppressed by CSS that only JS can undo, no error, nothing visibly broken.
   Un-gated, the beats simply flow down the stage and every word is readable. */
html[data-film-state] [data-film] .scene__stage > [data-film-scene] {
  grid-area: 1 / 1;
  opacity: 0;
  visibility: hidden;
}
html[data-film-state] [data-film] .scene__stage > [data-film-scene].is-active {
  opacity: 1;
  visibility: visible;
  transition-delay: 0s;
}
/* No JS: the stage is an ordinary stack of blocks. */
html:not([data-film-state]) .scene__stage {
  grid-template-columns: 1fr;
  gap: var(--space-2xl);
  place-items: start;
}
[data-film-state="static"] .scene__stage {
  grid-template-columns: 1fr;
  gap: var(--space-2xl);
  place-items: start;
}
[data-film-state="static"] [data-film-scene] { grid-area: auto; opacity: 1; }

/* ------------------------------------------------------------------- 02 */

.scene--threat .scene__stage { inline-size: 100%; }

/* THE TABLET.
   The casing is a render with a genuinely transparent aperture; the live
   display sits behind it and shows through. Registration comes from
   build_tablet.py, which prints the aperture as a fraction of the render:
   nothing here is measured off a screenshot. */
/* The tablet got bigger by widening the SCENE, not by taking the split further
   off the type column - that was the lever the handoff warned about, and at
   86rem/1.62fr the type column was already down to ~508px. 92rem at 1.9fr
   leaves it at ~492px (a 16px loss it can afford now the readout and the note
   are gone) and takes the device from ~823px to ~935px, 13.6% bigger. Pushing
   the split alone to the same device width would have cost the type 70px. */
.threat {
  position: relative;
  inline-size: 100%;
  max-inline-size: 92rem;
  min-block-size: min(76svh, 44rem);
  display: grid;
  grid-template-columns: minmax(0, 1.9fr) minmax(0, 1fr);
  align-items: center;
  gap: clamp(1.25rem, 3vw, 2.75rem);
}

.tab { position: relative; perspective: 2200px; }
.tab__body {
  position: relative;
  /* The tilt lives HERE, on the parent of both the casing and the glass, so
     one matrix moves both and they cannot come apart. Rendering the tilt in
     Blender instead would mean re-projecting the live display through a
     homography to keep it registered - the same picture, far more to go
     wrong. */
  transform: rotateY(-9deg) rotateX(2.2deg) rotateZ(-0.6deg);
  transform-style: preserve-3d;
  filter: drop-shadow(0 2.2rem 2.4rem rgb(0 0 0 / 0.5));
}
.tab__case { display: block; inline-size: 100%; block-size: auto; position: relative; z-index: 2; }

/* The aperture, as printed by build_tablet.py. */
.tab__screen {
  position: absolute;
  left: 14.034%;
  top: 17.805%;
  width: 71.932%;
  height: 64.390%;
  z-index: 1;
  overflow: hidden;
  background: color-mix(in srgb, var(--surface) 42%, #05070a);
}
.tab__glass { position: absolute; inset: 0; }
.tac__svg { display: block; inline-size: 100%; block-size: 100%; }

/* The type is off the glass now. A tablet does not render a 4rem display
   headline; the page does. */
.threat__type { display: grid; gap: var(--space-s); justify-items: start; }
.threat__type .scene__headline { margin: 0; }

@media (max-width: 60rem) {
  .threat { grid-template-columns: minmax(0, 1fr); gap: var(--space-l); }
  .tab__body { transform: none; }
}

/* THE DISPLAY HAS TO BE READABLE, and at phone width it was not.
   MEASURED AT 390px: the glass is about 250px wide showing a 1000-unit
   viewBox, so the scale is 0.25 and 13px of label renders at 3.3px. Every
   track callsign, every range and the unit designations were a texture. The
   scene's whole job in 02 is that you read a hostile track closing on a mortar
   platoon; none of that survived the trip to a phone.

   Scaling the TYPE rather than cropping the viewBox: a tighter viewBox would
   make everything bigger but would cut the range rings, and the rings are what
   make the closing distance mean anything. The strokes come up with the text
   so the symbols do not turn into hairlines beside their own labels - a 1px
   grid line at 0.25 scale is a quarter of a pixel and simply disappears.

   Both displays get this. 06 reuses 02's symbology deliberately, so a rule
   that only fixed 02 would leave the rhyme half legible. */
@media (max-width: 60rem) {
  .tac__t { font-size: 30px; stroke-width: 7px; letter-spacing: 0.03em; }
  .tac__t--dim { font-size: 26px; }
  .tac__grid { stroke-width: 2.4; opacity: 0.11; }
  .tac__rings { stroke-width: 2; }
  .tac__unit rect, .tac__unit path { stroke-width: 3.4; }
  .tac__unit circle { stroke-width: 3.6; }
  .tac__hostile { stroke-width: 4; }
  .tac__vel { stroke-width: 3.4; }
  .tac__trail { stroke-width: 2.6; stroke-dasharray: 6 9; }
  .tac__sectorEdge, .tac__link { stroke-width: 1.8; }
  .tac__corner { stroke-width: 3.4; }
}

.tac__ground { fill: color-mix(in srgb, var(--surface) 42%, #05070a); }
.tac__grid { stroke: var(--instrument); stroke-width: 1; opacity: 0.085; fill: none; }
.tac__rings { fill: none; stroke: var(--instrument); opacity: 0.3; }
.tac__ticks { stroke: var(--instrument); opacity: 0.34; fill: none; stroke-width: 1; }
.tac__sector { fill: var(--instrument); opacity: 0.045; }
.tac__sectorEdge { stroke: var(--instrument); stroke-width: 1; opacity: 0.26; stroke-dasharray: 5 6; fill: none; }
.tac__dead { fill: var(--instrument); opacity: 0.05; }
.tac__corner { stroke: var(--instrument); stroke-width: 2; opacity: 0.42; fill: none; }
.tac__link { stroke: var(--instrument); stroke-width: 1; opacity: 0.34; stroke-dasharray: 2 5; fill: none; }

/* Units. Frame, icon and size indicator all inherit, so a symbol is a shape
   and a position and nothing else has to be restated per unit. */
.tac__unit rect { fill: none; stroke: var(--instrument); stroke-width: 2; }
.tac__unit path { stroke: var(--instrument); stroke-width: 2; fill: none; }
.tac__unit circle { stroke: var(--instrument); stroke-width: 2.2; }
.tac__size { fill: var(--instrument); stroke: none; }
.tac__fill { fill: var(--instrument); stroke: none; }
.tac__staff { stroke-width: 2; }
.tac__icon .tac__arc { stroke-width: 1.5; opacity: 0.8; }
.tac__unit--own rect { stroke-width: 2.4; }

/* HOSTILE. Track Red is a token now (--hostile / --hostile-ink in tokens.css)
   rather than the hex that used to be repeated down this file, because it is a
   MEANING and not a style choice: on a tactical display red means hostile and
   nothing else may. 6.70:1 on the screen ground - and the frame shape says it
   too, so the picture still reads without the hue. */
.tac__hostileFill { fill: color-mix(in srgb, var(--surface) 42%, #05070a); stroke: none; }
.tac__hostile { fill: none; stroke: var(--hostile); stroke-width: 2.5; stroke-linejoin: miter; }
.tac__uav { fill: var(--hostile); }
.tac__vel, .tac__trail { stroke: var(--hostile); fill: none; }
.tac__vel { stroke-width: 2; }
.tac__trail { stroke-width: 1.5; opacity: 0.45; stroke-dasharray: 4 7; }
.tac__bearing {
  stroke: var(--hostile); stroke-width: 1; opacity: 0.4; stroke-dasharray: 2 6; fill: none;
  transform-box: view-box; transform-origin: 500px 330px;
  transform: scale(calc(1 - var(--film-progress, 0) * 0.76));
}

/* Type. It was one 15px size for everything, so a unit label and a scale note
   carried equal weight. Two sizes, tighter tracking, and a halo drawn from the
   screen ground so a label crossing a range ring stays legible. */
.tac__t {
  font-family: var(--font-mono);
  font-size: 13px;
  letter-spacing: 0.07em;
  font-variant-numeric: tabular-nums;
  fill: var(--instrument);
  paint-order: stroke fill;
  stroke: color-mix(in srgb, var(--surface) 42%, #05070a);
  stroke-width: 3.5px;
  stroke-linejoin: round;
}
.tac__t--dim { font-size: 12px; opacity: 0.55; }
.tac__t--hostile { fill: var(--hostile); }

/* The track's movement used to live HERE, as translate(-143px, 100px) scaled by
   --film-progress, and it has moved into scenes.js. Not a preference: the icon
   was driven from this rule, the range was driven from a counter in the type
   column, and the two closing distances they implied were 1.2km and 1.4km. Two
   sources for one fact, already a fifth of a kilometre apart. bootTrack() owns
   position, reported range, the bearing line and the sensor link off a single
   number now. Do not reintroduce a transform on .tac__track: a CSS transform
   property beats the transform ATTRIBUTE the JS writes, so this rule coming
   back would silently take the movement over again and the label would go back
   to disagreeing with the icon. */

/* POWER ON.
   A CRT does not fade in. It strikes a line, blooms, overshoots and settles,
   and the whole thing is over inside a second and a half. Held at the line for
   a beat because the strike is the part that reads as a screen rather than as
   an animation. Runs once, when the device is fully on screen. */
/* THE FLICKER, and the three dips in it are a measured number rather than a
   taste one. WCAG 2.3.1 allows no MORE than three flashes in any one second,
   and this screen is far too big to be exempt on area. So: three dips, at
   0.55s, 0.84s and 1.22s of a 1.45s run, which is three inside the worst
   one-second window and never four. They also bottom out at 0.42 opacity
   rather than near black - a shallow dip reads as a tube struggling to strike
   and does not meet the general flash threshold's luminance change at all, so
   the count is the belt and the depth is the braces. */
.tab[data-on] .tab__glass { animation: tac-on 1.45s cubic-bezier(0.16, 1, 0.3, 1) both; }
@keyframes tac-on {
  0%   { transform: scaleY(0.005) scaleX(0.7); opacity: 0; filter: brightness(4) saturate(0.2); }
  8%   { transform: scaleY(0.005) scaleX(1);   opacity: 1; filter: brightness(4) saturate(0.2); }
  18%  { transform: scaleY(0.014) scaleX(1);   opacity: 1; filter: brightness(3.4) saturate(0.4); }
  30%  { transform: scaleY(1) scaleX(1);       opacity: 1; filter: brightness(2.2) saturate(0.7); }
  38%  { opacity: 0.42; filter: brightness(1.7) saturate(0.8); }   /* dip 1 */
  46%  { opacity: 1;    filter: brightness(1.95); }
  58%  { opacity: 0.55; filter: brightness(1.3); }                 /* dip 2 */
  66%  { opacity: 1;    filter: brightness(1.5); }
  84%  { opacity: 0.7;  filter: brightness(1.15); }                /* dip 3 */
  92%  { opacity: 1;    filter: brightness(1.24); }
  100% { transform: scaleY(1) scaleX(1); opacity: 1; filter: brightness(1); }
}
@media (prefers-reduced-motion: reduce) {
  .tab[data-on] .tab__glass { animation: none; }
}

/* THE ALERT. A pulse at 1Hz, a third of the rate at which flashing content
   becomes a seizure risk, and it stops entirely under reduced motion - the
   alert still reads, it simply stops moving. */
.alert {
  position: relative;
  z-index: 2;
  margin: 0;
  display: flex;
  align-items: center;
  gap: var(--space-2xs);
  padding: var(--space-2xs) var(--space-xs);
  border: 1px solid color-mix(in srgb, var(--hostile) 55%, transparent);
  background: color-mix(in srgb, var(--hostile) 12%, transparent);
  font: var(--weight-semibold) var(--step--1)/1 var(--font-mono);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--tracking-meta);
  color: var(--hostile-ink);
  animation: alert-pulse 1s steps(1, end) infinite;
}
.alert__dot {
  inline-size: 0.5rem; block-size: 0.5rem; border-radius: 50%;
  background: var(--hostile); flex: none;
}
@keyframes alert-pulse {
  0%, 55% { border-color: color-mix(in srgb, var(--hostile) 78%, transparent);
            background: color-mix(in srgb, var(--hostile) 18%, transparent); }
  56%, 100% { border-color: color-mix(in srgb, var(--hostile) 30%, transparent);
              background: color-mix(in srgb, var(--hostile) 7%, transparent); }
}
@media (prefers-reduced-motion: reduce) { .alert { animation: none; } }

.scene__headline--threat,
.threat .kicker,
.threat .scene__sub { position: relative; z-index: 2; }

/* The headline carries the threat colour, so it is the one red thing in the
   type column and the alert below it is a restatement rather than the first
   news. Display type, so the 3:1 large-text floor applies and Track Red clears
   it at 4.73:1 - do NOT reuse this on the mono lines, which need Track Ink. */
.scene__headline--threat { color: var(--hostile); }

.threat__answer { display: grid; gap: var(--space-m); justify-items: start; }

/* The two beats of 02 are titled now, so their kicker is doing a heading's job
   rather than a label's and is sized for it: step-1 against the step--1 the
   kicker carries everywhere else. It stays mono, uppercase and tracked, which
   is what keeps it a title and stops it competing with the display line under
   it. Scoped to this section - the fold's kicker and GUIDANCE in 04 are still
   labels and are deliberately left alone. */
.kicker--lead {
  font-size: var(--step-1);
  color: var(--ink-muted);
}

/* The subtext in 02 carries the scene's actual argument rather than a caption,
   so it takes step-1 - the ramp's own "lead paragraph" step - against the
   step-0 .scene__sub uses elsewhere. SCOPED TO THIS SECTION ON PURPOSE. The
   base class is shared with scenes 03 to 08, which have not had their pass yet,
   and quietly resizing the lead of six scenes nobody has looked at is not a
   change this edit is entitled to make. Lift it to the base class when those
   scenes get reviewed. */
.scene--threat .scene__sub { font-size: var(--step-1); }

/* THE HIGHLIGHT. <mark> because that is precisely what the element means, and
   the semantics are free.
   NOT the accent. This section opens with "NO ACCENT ANYWHERE IN THIS SECTION"
   and the reason is narrative: HE Yellow means high explosive, and at this
   point in the story nothing of ours is in the air. Track Red is spoken for
   too - red is hostile, and the alert two lines up owns it. That leaves the
   raised surface, which is the honest answer anyway: the band lifts the line
   off the ground and the ink goes from muted to full, 6.83:1 to 10.02:1, so
   the highlight makes it more readable rather than merely louder. */
.mark {
  background: var(--surface-raised);
  color: var(--ink);
  font-weight: var(--weight-medium);
  padding: 0.14em 0.42em;
  margin-inline-start: -0.42em;   /* so the text still starts on the grid */
  /* Without this a wrapped mark gets its padding only at the two outer ends
     and the middle lines run edge to edge. */
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
}

/* ---------------------------------------------------------- the consequence
   Three lines that rise as the drill beat runs. Track Red measures 4.73:1 on
   --surface, which clears AA for NORMAL text, so neither line leans on the
   large-text exemption to pass. The ladder is step-3 / step-2 over a step-1
   subtext: the subtext went up a step this pass, so these went up with it to
   keep the consequence above the line that sets it up. */
.happens { display: grid; gap: var(--space-2xs); justify-items: start; }
.happens__h {
  margin: 0;
  font-size: var(--step-3);
  font-weight: var(--weight-semibold);
  line-height: 1.25;
  color: var(--hostile);
  max-inline-size: var(--measure);
}
.happens__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--space-3xs);
}
.happens__item {
  font-size: var(--step-2);
  line-height: 1.4;
  color: var(--hostile);
  max-inline-size: var(--measure);
  padding-inline-start: 1.1em;
  position: relative;
}
/* The marker is drawn rather than inherited: a default disc sits on the text
   baseline's centre and reads as punctuation at this size. A square set to the
   cap height reads as a bullet on a briefing slide, which is the register. */
.happens__item::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  inset-block-start: 0.52em;
  inline-size: 0.42em;
  block-size: 0.42em;
  background: var(--hostile);
}

/* THE RISE, and it only ever ADDS to a resting state that is already visible.
   That direction is the whole point. The trap list is explicit about it:
   anything hidden by CSS must be gated on the JS boot flag or it is
   unreachable with scripting off - and this is worse than the usual case,
   because it would ALSO be unreachable under the reduced-motion still edit,
   where the section is 'static' and --scene-progress never advances past 0.
   So the gate is the SECTION's own running flag, not the html one, and with no
   JS, a static edit, or reduced motion, these three lines simply are on the
   page with no transform at all.

   --scene-progress is 0→1 across this beat's own range (.58 to 1 of the
   section), so the stagger reads in beat-local terms. NOTHING RED HAPPENS IN
   THE FIRST 40% OF THE BEAT. The first pass started the header at 14%, which
   put it on screen while the beat was still cross-fading in - the consequence
   arrived WITH the drill instead of after it, and read as part of the same
   block. The beat now gets its first 40% for the kicker, the line and the
   subtext alone; the header is up by 60%, the first bullet by 72%, the second
   by 84%. The section also carries more scroll than it did (data-film 1.2 to
   1.6), so that opening 40% is roughly 270px of scrolling rather than 200. */
.scene--threat[data-film-state="running"] .happens__h,
.scene--threat[data-film-state="running"] .happens__item {
  --rise: clamp(0, calc((var(--scene-progress, 1) - var(--rise-from)) / 0.22), 1);
  opacity: var(--rise);
  transform: translateY(calc((1 - var(--rise)) * 1.5rem));
  will-change: opacity, transform;
}
.happens__h { --rise-from: 0.40; }
.happens__list .happens__item:nth-child(1) { --rise-from: 0.52; }
.happens__list .happens__item:nth-child(2) { --rise-from: 0.64; }

/* Belt and braces. The section should already be 'static' under reduced motion
   (two scenes on the stage means the still edit defaults to static), so this
   should never be the thing that saves it - but the failure mode if that ever
   stops being true is three invisible lines and no error anywhere. */
@media (prefers-reduced-motion: reduce) {
  .scene--threat[data-film-state="running"] .happens__h,
  .scene--threat[data-film-state="running"] .happens__item {
    opacity: 1;
    transform: none;
  }
}

/* Underlined AND bold because the word is doing the argument's whole job: the
   drill is not a plan, it is a wish. Offset so the rule clears the descender
   rather than cutting through it. */
.hope {
  font-weight: var(--weight-semibold);
  text-decoration: underline;
  text-decoration-thickness: 2px;
  text-underline-offset: 0.18em;
}

/* ------------------------------------------------------------------- 03 */

.scene__stage--unfold {
  inline-size: 100%;
  grid-template-columns: minmax(min(18rem, 100%), 0.9fr) minmax(min(18rem, 100%), 1fr);
  align-items: center;
  gap: var(--space-xl);
}
.unfold__canvas {
  grid-area: 1 / 1;
  inline-size: 100%;
  block-size: min(70svh, 38rem);
}
/* The still is the reduced-motion EDIT of this stage, not a slow-network
   fallback. The canvas is primary and it stays. */
.unfold__still { display: none; }
.unfold__type { grid-area: 1 / 2; display: grid; gap: var(--space-l); justify-items: start; }

.steps { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--space-xs); }
.steps__item {
  display: flex;
  gap: var(--space-s);
  align-items: baseline;
  font: var(--weight-regular) var(--step--1)/1.5 var(--font-mono);
  letter-spacing: var(--tracking-meta);
  color: var(--ink-subtle);
  text-transform: uppercase;
  border-block-start: 1px solid var(--rule);
  padding-block-start: var(--space-2xs);
  max-inline-size: var(--measure);
  transition: color var(--dur-base) var(--ease-emphasis);
}
.steps__n { font-variant-numeric: tabular-nums; color: var(--instrument); }
.steps__item[data-lit] { color: var(--ink); border-block-start-color: var(--ink-subtle); }

/* ------------------------------------------------------------------- 04 */

/* THE BREAKDOWN STACKS, it does not sit in a column.
   The render is 2:1 now instead of 780x1061, and a 2:1 image dropped into a
   0.85fr column is a 300px-wide strip in which none of the drawn-apart parts
   can be told from each other - the whole point of exploding it. So the type
   and the specs share the top row and the breakdown runs the full width
   beneath them, which is also the shape of an actual spec sheet. */
.anatomy {
  display: grid;
  grid-template-columns: minmax(min(20rem, 100%), 1fr) minmax(min(18rem, 100%), 0.95fr);
  gap: var(--space-l) var(--space-2xl);
  align-items: start;
  max-inline-size: 84rem;
  margin-inline: auto;
}
.anatomy__type { display: grid; gap: var(--space-m); justify-items: start; }
.anatomy__figure {
  margin: 0;
  grid-column: 1 / -1;               /* full width, under both columns */
  display: grid;
  gap: var(--space-l);
  justify-items: center;
}
.anatomy__figure img { inline-size: 100%; max-inline-size: 100%; block-size: auto; }
/* The specs move up beside the headline, so the breakdown below is uninterrupted. */
.anatomy__calls { grid-column: 2; grid-row: 1; inline-size: 100%; }

@media (max-width: 60rem) {
  .anatomy { grid-template-columns: minmax(0, 1fr); }
  .anatomy__calls { grid-column: 1; grid-row: auto; }
}

/* ------------------------------------------------------------------- 06
   THE SHOWCASE. One stage, two layers: the answer sits BEHIND the tablet the
   whole time, and the tablet travels off the top to uncover it. Not a cross
   fade - a cross fade says "and now, something else", and the point of this
   scene is that the hardware was back there all along. */
.scene--showcase .scene__stage { inline-size: 100%; overflow: hidden; }

.showcase {
  position: relative;
  inline-size: 100%;
  max-inline-size: 92rem;
  min-block-size: min(78svh, 46rem);
  display: grid;
  grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
  align-items: center;
  gap: clamp(1.25rem, 3vw, 2.75rem);
}

/* Both layers occupy the same cell. The shot is first in the DOM and lower in
   the stack, so the tablet is what you see until it leaves. */
.showcase__shot { grid-area: 1 / 1; margin: 0; z-index: 0; }
.showcase__shot img { inline-size: 100%; block-size: auto; display: block; }
.showcase__tab   { grid-area: 1 / 1; z-index: 1; }
.showcase__type  { grid-column: 2; display: grid; gap: var(--space-m); justify-items: start; }
/* UNDERLINED, and it is the one headline on the page that is. 03 underlines
   "Hope" because the word is doing that scene's whole argument; this sentence
   is what replaces it, so it carries the same mark. Thickness in em rather
   than px, because at display-1 a 2px rule reads as a hairline. */
.scene__headline--rule {
  text-decoration: underline;
  text-decoration-thickness: 0.055em;
  text-underline-offset: 0.13em;
}
.showcase__note { display: grid; gap: var(--space-s); justify-items: start; }

/* Same step as 02 and 03 so the three "Today's ..." beats are one family. */
.scene--showcase .scene__sub { font-size: var(--step-1); }

/* EACH BLOCK RISES, in the order the argument is made: the kicker names the
   beat, the headline answers 03, the line under it says why, then the four
   rounds, then the question about the fifth. A 0.16 span each and a 0.06 to
   0.10 stagger, so one is always arriving while the last is settling rather
   than the column appearing all at once.

   Read off --film-progress, which scroll-film writes on the stage, so no
   JavaScript. Gated on the SECTION's running flag for the reason the
   consequence block in 02 is: this rule drives opacity to zero, and a rule
   that can hide content must never be reachable when the thing that un-hides
   it is not running. With no JS, under the still edit, or under reduced
   motion, every block is simply on the page. */
.scene--showcase[data-film-state="running"] .showcase__type > * {
  --rise: clamp(0, calc((var(--film-progress, 1) - var(--rise-from, 0)) / 0.19), 1);
  opacity: var(--rise);
  transform: translateY(calc((1 - var(--rise)) * 2rem));
  will-change: opacity, transform;
}
/* STRICTLY SEQUENTIAL. Each block is fully up and has HELD for a moment before
   the next one starts to move - the starts are 0.22 apart and the travel is
   0.19, so there is a 0.03 pause between one settling and the next leaving.
   Earlier passes overlapped them (0.16 travel on 0.10 starts, then 0.24 on
   0.13) and the column read as one continuous slide rather than four separate
   arrivals, which is what "too fast" was describing: not the speed of any one
   block, but never being given a finished line to read.
   The type deliberately runs AHEAD of the tablet, which does not finish
   clearing until 0.64: they are in different columns, so the words can be read
   while the device is still on its way out. The last block settles at 0.88,
   leaving the closing eighth of the section at rest. */
.showcase__type > :nth-child(1) { --rise-from: 0.03; }   /* kicker            */
.showcase__type > :nth-child(2) { --rise-from: 0.25; }   /* headline          */
.showcase__type > :nth-child(3) { --rise-from: 0.47; }   /* the same tube ... */
.showcase__type > :nth-child(4) { --rise-from: 0.69; }   /* same round ...    */

@media (prefers-reduced-motion: reduce) {
  .scene--showcase[data-film-state="running"] .showcase__type > * {
    opacity: 1;
    transform: none;
  }
}

/* THE TRAVEL, gated on the section's own running flag for the same reason the
   consequence block in 02 is: this rule can drive opacity to near zero, and a
   rule that can hide content must never be reachable when the thing that
   un-hides it is not running. With no JS, under the still edit, or under
   reduced motion, the two layers stop overlapping entirely and stack instead -
   see below - so both are simply on the page. */
.scene--showcase[data-film-state="running"] .showcase__tab {
  --lift: clamp(0, calc((var(--film-progress, 0) - 0.30) / 0.34), 1);
  transform: translateY(calc(var(--lift) * -132%)) scale(calc(1 - var(--lift) * 0.06));
  opacity: calc(1 - var(--lift) * 0.88);
  will-change: transform, opacity;
}

/* No JS, or the static still edit: the layers become an ordinary stack. */
html:not([data-film-state]) .showcase,
[data-film-state="static"] .showcase { grid-template-columns: minmax(0, 1fr); }
html:not([data-film-state]) .showcase__shot,
html:not([data-film-state]) .showcase__tab,
html:not([data-film-state]) .showcase__type,
html:not([data-film-state]) .showcase__type > *,
[data-film-state="static"] .showcase__shot,
[data-film-state="static"] .showcase__tab,
[data-film-state="static"] .showcase__type,
[data-film-state="static"] .showcase__type > * {
  grid-area: auto;
  grid-column: auto;
  transform: none;
  opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
  .scene--showcase[data-film-state="running"] .showcase__tab { transform: none; opacity: 1; }
}

@media (max-width: 60rem) {
  .showcase { grid-template-columns: minmax(0, 1fr); }
  .showcase__type { grid-column: 1; }
}

/* 05's old count block - .count, .rounds, .rounds__item, .count__fifth,
   .count__rate, .count__num, .count__unit - was removed with the four round
   IDs and the fifth-drone question it styled. Nothing else used any of it. */

/* ------------------------------------------------------------------- 06 */

.math { display: grid; gap: var(--space-l); max-inline-size: 78rem; margin-inline: auto; justify-items: start; }
/* components.css already gives .table-wrap overflow-x:auto, but a grid child
   under justify-items:start shrink-to-fits, so the wrapper grew to the table's
   own width and pushed the DOCUMENT sideways instead of scrolling inside
   itself. The scroll container has to be told to stop at the column. */
.math > .table-wrap { inline-size: 100%; min-inline-size: 0; }
/* NO VERTICAL SCROLLBAR. components.css gives this wrapper overflow-x:auto so a
   wide table scrolls sideways on a phone instead of pushing the document, and
   its overflow-y stays `visible` - except that CSS does not allow that pairing:
   a `visible` computes to `auto` the moment the other axis scrolls. So the box
   was a vertical scroll container too, and the rise transform below moves the
   caption 22.4px down past the bottom of it, which painted a scrollbar down the
   side of the table at every desktop width. Measured: 23px of overflow while
   any row is still rising, 0px once they settle.
   `clip` is the one value that is NOT blockified this way, so the horizontal
   scrolling survives and the vertical scrollbar goes. Clipping is also the
   right look: the caption slides up out of the boundary rather than out of
   thin air. An engine too old for `clip` ignores the line and gets today's
   behaviour, which is a scrollbar, not a broken table. */
.scene--math .table-wrap { overflow-y: clip; }
.ledger { min-inline-size: 34rem; }
.ledger { inline-size: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.ledger__caption {
  caption-side: bottom;
  padding-block-start: var(--space-s);
  font: var(--weight-regular) var(--step--2)/1.6 var(--font-mono);
  letter-spacing: var(--tracking-meta);
  color: var(--ink-subtle);
  text-align: start;
  max-inline-size: var(--measure);
}
.ledger th, .ledger td {
  text-align: start;
  padding: var(--space-s) var(--space-m) var(--space-s) 0;
  border-block-end: 1px solid var(--rule);
  font-size: var(--step-0);
  font-weight: var(--weight-regular);
  color: var(--ink-muted);
}
.ledger thead th {
  font: var(--weight-medium) var(--step--2)/1 var(--font-mono);
  letter-spacing: var(--tracking-meta);
  text-transform: uppercase;
  color: var(--ink-subtle);
}
.ledger tbody th { color: var(--ink); }
.ledger td:first-of-type {
  font-family: var(--font-mono);
  color: var(--ink);
  text-align: end;
  white-space: nowrap;
}
.ledger__row--ours th,
.ledger__row--ours td { color: var(--accent); border-block-end-color: var(--accent); }
/* The row is already accent coloured with an accent rule under it; the
   UNDERLINE is on the name alone rather than the whole row, because a row of
   four underlined cells reads as four links. Same mark as "Stay on the gun."
   in 06 and "Hope" in 03 - it is the page's one way of saying "this phrase". */
.ledger__row--ours th {
  text-decoration: underline;
  text-decoration-thickness: 2px;
  text-underline-offset: 0.22em;
}

/* ---------------------------------------------- 07 on a phone: STACKED ROWS

   MEASURED AT 390px: the table is 544px wide inside a 310px column, so two of
   its four columns are off the right edge. `.table-wrap` scrolls, so nothing
   is technically lost and nothing overflows the document - but there is no
   affordance saying so, and the two columns hidden by default are "Launch
   system" and "What it can reach", which ARE the argument. The comment on this
   section says so in as many words: the two columns cross exactly on the DAVID
   row, everything above it is ground, everything below is air. On a phone the
   scene was making its case entirely off-screen.

   So below 46rem each row becomes a block and every cell carries its own
   label. Nothing is hidden, nothing scrolls sideways, and the DAVID row still
   reads as the turn because it keeps the accent and the rule.

   ROLES ARE IN THE MARKUP for this. `display: block` on table elements drops
   the implicit table/row/cell semantics in several engines, so the <table>
   carries role="table" and its rows role="row" explicitly. They are redundant
   at desktop width and load-bearing here. */
@media (max-width: 46rem) {
  /* The 34rem floor is what forced the sideways scroll; there is no table to
     keep wide any more. */
  .ledger { min-inline-size: 0; }
  .scene--math .table-wrap { overflow-x: visible; }
  .ledger, .ledger tbody, .ledger tr, .ledger th, .ledger td { display: block; }

  /* The column heads become each cell's own label, so the header row has no
     job left on screen. Clipped rather than display:none, because it is still
     the accessible name for the columns. */
  .ledger thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

  .ledger tbody tr {
    padding-block: var(--space-s);
    border-block-end: 1px solid var(--rule);
  }
  .ledger tbody tr:last-child { border-block-end: none; }

  /* The effector name is the row's heading now, not its first column. */
  /* caption-side has no meaning once the table is a stack of blocks, so the
     caption lands at the TOP - fine, it reads as an intro there. What is not
     fine is that it also loses the table's width and inherited `--measure`,
     which wrapped it to one word per line. */
  .ledger caption {
    display: block;
    inline-size: 100%;
    max-inline-size: none;
    padding: 0 0 var(--space-m);
  }
  /* AND IT HAS TO RISE EARLY NOW. --rise-from: 0.66 was written for a caption
     UNDER the table, arriving after the last row. Flattened, the caption is at
     the TOP of the stack, so at 0.66 it was a hole above rows that had already
     arrived, filled in at the end. It reads with the headline here. */
  .ledger__caption { --rise-from: 0.14; }
  .ledger tbody th {
    padding: 0 0 var(--space-2xs);
    border: none;
    font-size: var(--step-0);
    /* "XM395 APMI, GPS guided 120mm" runs to the millimetre at 390px. */
    overflow-wrap: anywhere;
  }
  .ledger td {
    display: grid;
    grid-template-columns: minmax(6.5rem, 0.46fr) 1fr;
    gap: var(--space-s);
    padding: var(--space-3xs) 0;
    border: none;
  }
  /* Cost stops being right-aligned: in a stacked row there is no column of
     figures for it to line up with, and an end-aligned value beside a
     start-aligned label just reads as a mistake. */
  .ledger td:first-of-type { text-align: start; }
  .ledger td::before {
    content: attr(data-label);
    font: var(--weight-regular) var(--step--2)/1.5 var(--font-mono);
    font-variant-numeric: tabular-nums;
    letter-spacing: var(--tracking-meta);
    text-transform: uppercase;
    color: var(--ink-subtle);
  }
  .ledger__row--ours td { border: none; }
  .ledger__row--ours { border-block-end-color: var(--accent) !important; }
}

/* THE ROWS RISE ONE AT A TIME, same mechanism as 06 and read off the same
   --film-progress. Applied to the CELLS, not to the <tr>: whether a table row
   is transformable has been a browser-by-browser question for years, and the
   cells are where this table's border-block-end actually lives, so moving them
   carries the rule with them and sidesteps the question entirely.
   --rise-from is set on the ROW and inherits down to its cells.
   Gated on the section's running flag. Nothing sets that attribute except
   scroll-film, so with no JavaScript the rule is unreachable and every row is
   simply on the page - the same reason 02 and 06 are gated. */
.scene--math[data-film-state="running"] .math > .kicker,
.scene--math[data-film-state="running"] .math > .scene__headline,
.scene--math[data-film-state="running"] .math > .math__punch,
.scene--math[data-film-state="running"] .ledger__caption,
.scene--math[data-film-state="running"] .ledger thead tr > *,
.scene--math[data-film-state="running"] .ledger tbody tr > * {
  --rise: clamp(0, calc((var(--film-progress, 1) - var(--rise-from, 0)) / 0.07), 1);
  opacity: var(--rise);
  transform: translateY(calc((1 - var(--rise)) * 1.4rem));
  will-change: opacity, transform;
}
/* Starts 0.08 apart against 0.07 of travel, so each is up and holding before
   the next moves. Nine steps, settled by 0.73, the closing quarter at rest. */
.math > .kicker                   { --rise-from: 0.02; }
.math > .scene__headline          { --rise-from: 0.10; }
.ledger thead tr                  { --rise-from: 0.18; }
.ledger tbody tr:nth-child(1)     { --rise-from: 0.26; }
.ledger tbody tr:nth-child(2)     { --rise-from: 0.34; }
.ledger tbody tr:nth-child(3)     { --rise-from: 0.42; }   /* DAVID */
.ledger tbody tr:nth-child(4)     { --rise-from: 0.50; }
.ledger tbody tr:nth-child(5)     { --rise-from: 0.58; }
.ledger__caption                  { --rise-from: 0.66; }
.math__punch                      { --rise-from: 0.66; }

@media (prefers-reduced-motion: reduce) {
  .scene--math[data-film-state="running"] .math > .kicker,
  .scene--math[data-film-state="running"] .math > .scene__headline,
  .scene--math[data-film-state="running"] .math > .math__punch,
  .scene--math[data-film-state="running"] .ledger__caption,
  .scene--math[data-film-state="running"] .ledger thead tr > *,
  .scene--math[data-film-state="running"] .ledger tbody tr > * {
    opacity: 1;
    transform: none;
  }
}
.ledger thead th { color: var(--instrument); }
.math__punch { font-size: var(--step-0); color: var(--ink); }

/* ------------------------------------------------------------------- 07 */

.scene--card { background: var(--surface-inverse); color: var(--invert-ink); }
.card-sheet {
  display: grid;
  gap: var(--space-l);
  justify-items: start;
  max-inline-size: 46rem;
  margin-inline: auto;
}
.card-sheet .kicker,
.card-sheet .scene__sub { color: var(--invert-muted); }

/* The card runs in six groups now rather than one flat list of twelve, because
   a spec sheet long enough to be worth reading is not scannable without them.
   A <dl> may only contain dt, dd, div, script and template, so a group heading
   cannot live inside one: each group is its own <dl> behind its own <h3>, and
   .datacard__set is what keeps the heading tied to its rows against
   .card-sheet's much larger gap. */
.datacard__set { inline-size: 100%; display: grid; gap: var(--space-2xs); }
.datacard__group {
  margin: 0;
  font: var(--weight-medium) var(--step--2)/1.5 var(--font-mono);
  letter-spacing: var(--tracking-meta);
  text-transform: uppercase;
  color: var(--invert-ink);
  padding-block-end: var(--space-3xs);
  /* Heavier than the row hairline on purpose: the group rule has to read as a
     different order of division from the 39 row rules under it. */
  border-block-end: 2px solid color-mix(in srgb, var(--invert-ink) 50%, transparent);
}

.datacard { margin: 0; inline-size: 100%; display: grid; font-variant-numeric: tabular-nums; }
.datacard__row {
  display: grid;
  grid-template-columns: minmax(min(11rem, 45%), 0.6fr) 1fr;
  gap: var(--space-m);
  padding-block: var(--space-xs);
  border-block-end: 1px solid color-mix(in srgb, var(--invert-ink) 18%, transparent);
}
.datacard dt {
  font: var(--weight-regular) var(--step--2)/1.5 var(--font-mono);
  letter-spacing: var(--tracking-meta);
  text-transform: uppercase;
  color: var(--invert-muted);
}
.datacard dd { margin: 0; font-size: var(--step-0); color: var(--invert-ink); }
.datacard__row--pending dd {
  font-family: var(--font-mono);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-meta);
  color: var(--invert-accent);
}
/* PENDING is the only accented word on the card, so the sentence that explains
   it has to drop back out of the accent or the row reads as two live-round
   marks. Body face, muted ink, normal tracking: a gloss, not a value. */
.datacard__gloss {
  font-family: var(--font-body);
  font-weight: var(--weight-regular);
  letter-spacing: var(--tracking-body);
  color: var(--invert-muted);
}
/* Two block spans instead of a <br>; see the comment at the heading. */
.card-h__line { display: block; }
/* The lede is the claim discipline, not decoration: it is the line that says
   every figure below is a target. It sits at reading width rather than the
   card's full 46rem so it does not read as another row. */
.card-sheet__lede { max-inline-size: 40rem; }
.card-sheet__note { max-inline-size: 44rem; }
.cta--card { background: var(--invert-accent); color: var(--surface-inverse); }

/* Below this the label column stops being a column. Measured at 390px: 45% of
   the viewport is 176px, which left "Gas generator spins the spool; no starter
   motor" running to five lines in a 190px gutter and made a 39 row card twice
   as tall as it needs to be. Stacked, the label sits over its value the way a
   pocket-sized reissue of a printed form does.

   34rem and not the page's usual 60rem: at 900px the two columns are still
   comfortable (see the tablet frame) and stacking there would throw away the
   one thing this section is trading on, which is looking like a document. */
@media (max-width: 34rem) {
  .datacard__row { grid-template-columns: 1fr; gap: var(--space-3xs); }
  /* 39 stacked rows is 4,838px - 5.7 screens, a quarter of the whole page in
     one card. Nothing here is cut: the row rhythm is tightened and the group
     gap comes off the card's 40px default, which is sized for the space
     between a headline and a table, not between two blocks of a form. */
  .datacard__row { padding-block: var(--space-3xs); }
  .card-sheet { gap: var(--space-m); }
  .datacard__set { gap: var(--space-3xs); }
  .card-sheet__lede, .card-sheet__note { max-inline-size: none; }
}

/* ------------------------------------------------------------------- 08 */

.scene--displace { padding-block-end: var(--space-2xl); }
.displace {
  margin: 0;
  display: grid;
  gap: var(--space-xl);
  justify-items: center;
  max-inline-size: 78rem;
  margin-inline: auto;
}
.displace img {
  inline-size: 100%;
  /* 30rem, not 56. The frame is a PORTRAIT object now - an emplaced mortar,
     863x1105 after trim - where it used to be a landscape plate of kit on the
     ground. At 56rem the gun stood 1150px tall and the closing beat became the
     loudest thing on the page, which is the opposite of what
     .scene__headline--quiet is for. */
  max-inline-size: 34rem;
  block-size: auto;
  /* NO MASK. The old render carried its own ground plane, painted to match the
     page, and this feathered the rectangle's straight edge away:
       mask-image: radial-gradient(ellipse 74% 68% at 50% 48%, #000 55%, ...)
     The render is transparent now with a shadow-only ground, so there is no
     rectangle to hide - and that ellipse would have faded out the muzzle and
     the far bipod foot, which are exactly the parts furthest from centre. */
}
.displace__caption { display: grid; gap: var(--space-m); justify-items: center; text-align: center; }
.displace__caption .scene__sub { max-inline-size: 48ch; }

/* ---------------------------------------------------------------- footer */

.site-footer { padding: var(--space-2xl) var(--space-l) var(--space-xl); border-block-start: 1px solid var(--rule); }
.site-footer__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
  gap: var(--space-xl);
  max-inline-size: 78rem;
  margin-inline: auto;
}
.footer__note { margin: 0; font-size: var(--step--1); line-height: var(--leading-body); color: var(--ink-muted); max-inline-size: var(--measure); }
.footer__sources { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--space-2xs); }
.footer__sources a { font: var(--weight-regular) var(--step--2)/1.6 var(--font-mono); color: var(--ink-subtle); }

/* 15px tall as plain inline links, in a list of seven — fine for a cursor,
   a coin toss for a thumb. Each becomes its own 44px row with a rule between.
   SCOPED, and the scoping was learned the hard way: applied unconditionally
   this made the DESKTOP footer 200px taller and airier, which pushed the
   visual gate's last frame to flat 0.958 against a 0.95 threshold. A touch
   affordance has no business changing a pointer layout. */
@media (pointer: coarse), (max-width: 46rem) {
  .footer__sources { gap: 0; }
  .footer__sources a {
    display: flex;
    align-items: center;
    min-block-size: 2.75rem;
    padding-block: var(--space-3xs);
    line-height: 1.5;
  }
  .footer__sources li + li { border-block-start: 1px solid var(--rule); }
}
.site-footer__base {
  margin-block-start: var(--space-xl);
  font: var(--weight-regular) var(--step--2)/1 var(--font-mono);
  letter-spacing: var(--tracking-meta);
  color: var(--ink-subtle);
  text-align: center;
}

/* -------------------------------------------------------- reduced motion */

@media (prefers-reduced-motion: reduce) {
  /* The unfold stage swaps its canvas for the deployed still. A canvas frozen
     mid sequence would leave a half open round beside copy saying it becomes
     an aircraft; the still has to carry the argument the motion made. */
  .unfold__canvas { display: none; }
  .unfold__still {
    display: block;
    grid-area: 1 / 1;
    inline-size: 100%;
    max-inline-size: 22rem;
    block-size: auto;
    justify-self: center;
  }
  .rounds__item { color: var(--ink); }
  .rounds__item .rounds__state { color: var(--accent); }
  .steps__item { color: var(--ink); }
}

@media (max-width: 60rem) {
  .scene__stage--unfold { grid-template-columns: 1fr; gap: var(--space-l); }
  .unfold__canvas { grid-area: auto; block-size: min(42svh, 22rem); }
  .unfold__type { grid-area: auto; }
  .anatomy { grid-template-columns: 1fr; gap: var(--space-xl); }
}


/* ------------------------------------------- the font shorthand trap

   `font-variant-numeric: tabular-nums` on :root does NOT reach these, and the
   reason is easy to miss: the `font` SHORTHAND resets font-variant-numeric to
   normal, so every rule written as `font: weight size/leading family` silently
   throws the inherited value away. The harness caught three elements still
   proportional after the root rule was added, which was the only reason it
   surfaced at all: on screen the difference is one or two wobbling digits.

   Anything styled with the shorthand has to restate it afterwards. Listed
   explicitly rather than blanket-applied, so the list stays greppable when a
   new component is written with a shorthand and quietly loses its figures. */
.skip-link,
.topbar__word,
.topbar__meta,
.kicker,
.cta,
.steps__item,
.ledger__caption,
.ledger thead th,
.datacard dt,
.datacard__group,
.footer__sources a,
.site-footer__base { font-variant-numeric: tabular-nums; }
