/* ==========================================================================
   The differentiators — five chapters on the home page (`index.php`)
   ==========================================================================

   One argument per screen, each with a built visual rather than a screenshot:
   there is no per-feature footage on this site, and a drawn mechanism explains
   itself faster than a photograph of a HUD anyway.

   Home page only, so it is loaded from index.php's $headExtra rather than from
   partials/header.php's fixed list — same route profile.css takes.

   Three rules the whole file follows, all inherited from the rest of the site:

   - **Square corners, hairlines, amber.** No radius anywhere, decoration is
     1px rules and corner ticks, and the one accent is --amber. If something
     here needs a colour that is not in base.css, it is wrong.
   - **No copy lives in SVG.** Every word is HTML positioned over the drawing.
     The visual editor measures its highlights with a Range (docs/editable-copy.md)
     and a translated string may not have to fit inside a viewBox. The SVG
     carries geometry and digits — a scale's numbers are not copy, and nothing
     translates them.
   - **The section is the fit, not the padding.** Chapters use `.section-fit`
     from components.css, which is already what frames the trailer and the
     vehicle viewer directly above them.
   ========================================================================== */

/* --- The reveal primitive ------------------------------------------------

   One transition for the whole series. `diff.js` adds `.is-in` when a chapter
   crosses into view; `--i` staggers children of the same block so a heading
   lands before the paragraph under it.

   Kept deliberately short and small: this is punctuation between chapters, not
   an entrance. Anything longer reads as the page being slow. */
[data-reveal] {
  opacity: 0;
  transform: translateY(16px);
  transition:
    opacity .66s cubic-bezier(.22, .61, .36, 1),
    transform .66s cubic-bezier(.22, .61, .36, 1);
  transition-delay: calc(var(--i, 0) * 85ms);
}
[data-reveal].is-in {
  opacity: 1;
  transform: none;
}

/* --- Chapter shell ------------------------------------------------------- */

.dx {
  position: relative;
  overflow: hidden;
}

/* The copy column. Held to a reading measure so a chapter never runs a
   sentence the full 1320px of the site — the visual is what is allowed to be
   wide here. */
.dx-copy {
  max-width: 30rem;
}
.dx-copy h2 {
  font-size: clamp(30px, 4.4vw, 54px);
  font-weight: 600;
  line-height: 1;
  letter-spacing: .01em;
  text-wrap: balance;
}
.dx-copy p {
  color: var(--muted);
  font-size: clamp(15px, 1.25vw, 17px);
  line-height: 1.62;
  margin-top: 18px;
  text-wrap: pretty;
}

/* A note that belongs to the drawing rather than to the argument — the CER's
   "example values", the economy's trade-token line. Mono, quiet, and never
   the thing being read first. */
.dx-note {
  font-family: var(--f-mono);
  font-size: 11px;
  line-height: 1.5;
  color: var(--muted-2);
  letter-spacing: .03em;
}

/* Labels sitting on the drawings. They are HTML over SVG (see the header), so
   they need their own type scale — small, stencil, and never wider than the
   part of the diagram they name. */
.dx-tag {
  font-family: var(--f-stencil);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
}
.dx-tag-amber { color: var(--amber); }

/* Split chapters (02, 04): copy on one side, stage on the other. It is the
   `.fit-grow`, so it absorbs whatever height the fit left over and centres
   both columns in it. */
.dx-split {
  display: grid;
  grid-template-columns: minmax(0, 24rem) minmax(0, 1fr);
  gap: clamp(32px, 5vw, 76px);
  align-items: center;
}
/* 04 mirrors 02 so two split chapters never read as the same slide twice. The
   copy keeps DOM order (it is what a screen reader and a narrow screen should
   meet first); only the painted order flips. */
.dx-split-rev { grid-template-columns: minmax(0, 1fr) minmax(0, 24rem); }
.dx-split-rev > .dx-copy { order: 2; }
.dx-split-rev > .dx-stage { order: 1; }

/* The drawing's frame. Corner ticks and a hairline, the same vocabulary the
   hero uses at the top of the page. */
.dx-stage {
  position: relative;
  border: 1px solid var(--line-soft);
  background:
    linear-gradient(180deg, rgba(232, 227, 214, .022), transparent 40%),
    var(--panel);
  padding: clamp(18px, 2vw, 30px);
}
.dx-stage::before,
.dx-stage::after {
  content: "";
  position: absolute;
  width: 14px;
  height: 14px;
  border-color: var(--amber-line-strong);
  border-style: solid;
}
.dx-stage::before {
  top: -1px;
  left: -1px;
  border-width: 2px 0 0 2px;
}
.dx-stage::after {
  right: -1px;
  bottom: -1px;
  border-width: 0 2px 2px 0;
}

.dx-stage svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}

/* A word pinned onto a drawing.

   Placed in percentages against the drawing's own box, which is what keeps it
   on its part of the diagram at every width — the SVGs here scale with the
   column, so any offset in px would drift. Its `left`/`top` are inline on the
   element, like the rest of this page's one-off geometry; only the behaviour
   common to all of them lives here.

   This is the mechanism that lets the rule at the top of the file hold: no
   copy inside SVG, so every label stays translatable and stays editable from
   the back office. */
.dx-pin {
  position: absolute;
  transform: translate(-50%, -50%);
  text-align: center;
  white-space: nowrap;
  pointer-events: none;
}
.dx-pin-l { transform: translate(0, -50%); text-align: left; }
.dx-pin-r { transform: translate(-100%, -50%); text-align: right; }

/* Shared drawing strokes. `vector-effect` keeps a hairline a hairline at every
   width — these drawings scale with the column, and a 1px rule that becomes
   2.4px on a wide screen stops matching the CSS borders beside it. */
.dx-line,
.dx-line-soft,
.dx-amber,
.dx-dash {
  fill: none;
  vector-effect: non-scaling-stroke;
  stroke-linecap: square;
  stroke-linejoin: miter;
}
.dx-line { stroke: var(--line); stroke-width: 1; }
.dx-line-soft { stroke: var(--line-faint); stroke-width: 1; }
.dx-amber { stroke: var(--amber); stroke-width: 1.5; }
.dx-dash {
  stroke: var(--muted-3);
  stroke-width: 1.5;
  stroke-dasharray: 6 7;
}
.dx-fill-amber { fill: var(--amber); }
.dx-fill-panel { fill: var(--panel-3); }

/* The one exception to "no text in SVG": the CER scale's numerals. They are
   digits on a ruler — nothing translates them, and pinning eleven of them as
   HTML would mean eleven percentages to keep in step with the tick loop that
   draws them. Sized in viewBox units, so they scale with the drawing. */
.dx-num {
  fill: var(--muted-3);
  font-family: var(--f-mono);
  font-size: 22px;
}

/* ==========================================================================
   01 — The sky is empty
   ==========================================================================

   The one chapter where the drawing is the section rather than a panel in it,
   because the argument is about a space being empty and a bordered box would
   make it a diagram of emptiness instead.

   A dusk battlefield: the ground in silhouette, the sky graded and holding
   nothing at all, and the copy standing in the empty half. Everything here
   serves one requirement — that a reader can tell the sky from the ground
   without being told — so the two are separated three times over: by value
   (a lit sky against black land), by the hot band at the horizon, and by the
   roofline of a village standing on it.

   The composition and the reasoning behind the planes are documented at the
   markup in index.php; this file is the sky, the light and the motion. */

/* The sky. It is CSS and not part of the SVG on purpose: the drawing is
   `slice`-cropped and this box is not, so a sky painted inside the viewBox
   loses its dark end on a wide screen. Here it always spans the section.

   The ramp peaks at 62% and stays warm from 54% to 70%, which is the band the
   drawn horizon lands in across every viewport this chapter is allowed on.
   Below it the colour falls away to near-black — the ground covers all of
   that, and the fall-off is only insurance against a sliver showing.

   It opens on --bg-home rather than on a sky colour, so the section grows out
   of the page above it instead of starting with a seam. */
.dx-sky {
  background: linear-gradient(180deg,
    #12140d 0%,
    #0e1016 9%,
    #131729 22%,
    #1e1d33 36%,
    #3b2833 48%,
    #7a441f 57%,
    #b0661a 62%,
    #7d4a18 66%,
    #33200f 72%,
    #120f0a 82%,
    #0a0906 100%);
}
.dx-sky-art {
  position: absolute;
  inset: 0;
  overflow: hidden;
}
.dx-sky-art svg {
  width: 100%;
  height: 100%;
  display: block;
}
/* Just enough shade in the one corner the copy occupies. It is a single large
   radial rather than the two full-width bands this used to be: those dimmed
   the whole sky, and a sky that has been dimmed to make room for text is no
   longer a sky the text can claim is empty. */
.dx-sky-scrim {
  position: absolute;
  inset: 0;
  background: radial-gradient(86% 74% at 4% 2%,
    rgba(7, 8, 5, .88) 0%,
    rgba(7, 8, 5, .5) 44%,
    transparent 74%);
}
/* The art and the scrim are both positioned and both precede these in the DOM,
   so without a stacking context of their own they paint *under* them —
   positioned elements win over in-flow ones whatever the source order says.
   The hero solves it the same way one section up; this is that rule, not a
   new one. */
.dx-sky .section,
.dx-zone {
  position: relative;
  z-index: 2;
}

/* Copy at the top of the frame rather than the bottom, because the empty sky
   is what it is standing in.

   **The floor is not decoration.** Once this section is scrolled to the top of
   the viewport the fixed header sits over its first ~69px, and the section's
   own `--section-y` is no help there: it shrinks to about 31px on a short
   screen while the header does not shrink at all. A purely proportional
   padding therefore puts the kicker *behind* the bar exactly where there is
   least room to lose — measured at 1280×760 and 1024×620 before this floor
   went in. 56px is what clears it at the smallest `--section-y` this page
   produces, with room to spare at every larger one. */
.dx-sky-fit {
  display: flex;
  align-items: flex-start;
  padding-top: clamp(56px, 7vh, 96px);
}
.dx-sky .dx-copy { max-width: 34rem; }
/* It is the chapter that opens the series, so it is allowed to be the loudest
   of the five. */
.dx-sky .dx-copy h2 { font-size: clamp(32px, 4.8vw, 62px); }
/* One step up from --muted: the sky behind this paragraph is the lightest
   ground any copy on this page sits on, and --muted does not hold against it
   on a short screen where the warm band climbs into the text. */
.dx-sky .dx-copy p { color: var(--ink-soft); }

/* A short window is the one that squeezes this chapter, because the sky is a
   share of the height and the copy is not. The section keeps its width and
   loses its depth, so the block has to give some back or it ends up standing
   on the skyline instead of in the air above it. */
@media (max-height: 780px) {
  /* Down to the floor, and no further — see the note on .dx-sky-fit. What
     gives here is the type, not the header clearance. */
  .dx-sky-fit { padding-top: 56px; }
  /* The aircraft gives too — not because it collides, but because it is out
     of proportion. A short section has the same sky *fraction* and far fewer
     pixels of it (180px at 2400×620 against 424px at 1600×900), and a drawing
     171px tall hanging in 180px of sky is the whole band. At 124 it is 141
     tall and still clears the horizon by ~39px there. */
  .dx-nofly { width: clamp(95px, 8vw, 124px); }
  .dx-sky .dx-copy h2 { font-size: clamp(30px, 3.4vw, 44px); }
  .dx-sky .dx-copy p { margin-top: 12px; font-size: 15px; }

  /* Past about 3.5:1 there is not enough sky left for the block to finish in,
     and the last line lands on the far hills (measured: 33px into them at
     2400×620). The type has already given what it can, so the shade takes the
     rest — reaching further and darker means the copy keeps a bed under it
     whether it is standing in the air or on the ridge, and the overlap reads
     as depth rather than as a collision. */
  .dx-sky-scrim {
    background: radial-gradient(96% 92% at 2% 0%,
      rgba(7, 8, 5, .9) 0%,
      rgba(7, 8, 5, .58) 46%,
      transparent 78%);
  }
}

/* The rim on the near vehicles. A second copy of the silhouette offset toward
   the sun and one value up — which is what a low sun behind an object actually
   does, and the cheapest way to keep a black shape on a dark field from going
   flat. Never brighter than this: it is a reflection, not a light source. */
.dx-rim { fill: rgba(232, 163, 23, .42); }

/* --- The aircraft that is not coming -------------------------------------

   Sits in the right half of the sky, opposite the copy. Its box is a share of
   the section like the brackets are, never of the drawing — see the note at
   the markup for why nothing in HTML may be pinned to a cropped SVG.

   The outline is hollow: this is the one thing in the frame that is not
   there, so it is the one thing with no fill. Everything standing on the
   ground below it is solid, which is the whole contrast the chapter is built
   on. The strike is the only bright amber above the horizon. */
/* Squeezed from both ends, which is why these three numbers are so specific.
   Above it is the 69px fixed header, and 10% of a short section is not clear
   of that — hence the same px floor .dx-sky-fit needs, for the same reason.
   Below it is the horizon, which on a wide-short window is only ~49% of a
   536px section: the box is taller than it is wide (the span is the long
   axis), so at 200px across this would be 226px tall and parked in the hills.
   84px down and 150px across clears the header and the ridge in that worst
   case at once. */
.dx-nofly {
  position: absolute;
  top: max(84px, 10%);
  right: 22%;
  width: clamp(100px, 9.5vw, 150px);
  z-index: 2;
  pointer-events: none;
}
.dx-nofly svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}
.dx-nofly-craft {
  fill: none;
  stroke: rgba(232, 227, 214, .3);
  stroke-width: 1.5;
  vector-effect: non-scaling-stroke;
  stroke-linejoin: round;
}
.dx-nofly-bar {
  fill: none;
  stroke: var(--amber);
  stroke-width: 2.5;
  vector-effect: non-scaling-stroke;
  stroke-linecap: square;
  opacity: .8;
}

/* Below the fit's breakpoint the sky is a strip and the copy has most of it,
   so the plane moves out to the edge and shrinks rather than going away —
   it is the chapter's one piece of iconography and the argument is thinner
   without it. */
@media (max-width: 1023px) {
  .dx-nofly {
    /* Keep the floor. A bare 8% is 45px on a 560px section and puts the
       wingtip behind the header — the percentage is the ceiling here, not
       the rule. */
    top: max(84px, 8%);
    right: 6%;
    width: clamp(88px, 16vw, 150px);
  }
}

/* Under ~780px the copy alone is the width. `.dx-copy` holds a 34rem measure
   (544px) and the gutters take the rest, so there is no column left for the
   aircraft to stand in beside it — at 700px it lands 16px inside the
   paragraph, and moving it below the copy only trades that for a collision
   with the skyline. It goes, and nothing is lost that the first sentence of
   the paragraph does not already say outright. */
@media (max-width: 779px) {
  .dx-nofly { display: none; }
}

/* --- The zone brackets ---------------------------------------------------

   One bracket, `NO AIR SUPPORT`, and it measures the **whole** sky: header to
   horizon, top tick to bottom tick. A hairline with a tick at each end — the
   site's own corner vocabulary turned on its side — and the label to its left.

   It used to be a short span at a safe 7–22%, next to a second `GROUND SECTOR`
   bracket below the horizon. Both are gone: a bracket that measures part of the
   sky is not measuring the sky, and pairing it with one over the ground made
   the empty half look like half the subject rather than the whole point.

   **Which means it now has to find the horizon**, the thing every other piece
   of HTML on this chapter is kept away from. It can, because the horizon's
   position is not a mystery — it is the same arithmetic the SVG does. `slice`
   scales by whichever ratio is larger, so viewBox y 560 lands (900−560)×scale
   up from the section's bottom edge:

       scale  = max(W / 3000, H / 900)
       bottom = (900 − y) × scale

   for whichever viewBox y it should stop at, which is `max(W × k/3000, H × k/900)`
   — vw because this section is full-bleed so its width is the viewport's (the
   scrollbar costs under 2px here), and % because `bottom` already resolves
   against the section's height. Change the viewBox and these constants have to
   move with it, or the bracket quietly stops pointing at anything.

   **It stops at the skyline, not at the horizon.** The horizon is y 560, but
   the hills stand on it and crest between y 474 and 528, so a bracket taken all
   the way down to 560 spends its last stretch buried in terrain — which is what
   it did on the first attempt, and it looked like an error rather than a
   measurement. y 520 is the middle of that crest band: the tick lands on the
   hills rather than behind them, and the rule stays in the sky it is measuring.
   380 = 900 − 520, hence 12.6667vw / 42.2222%. */
.dx-zone {
  position: absolute;
  right: var(--gutter);
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 13px;
  pointer-events: none;
  color: var(--muted-2);
}
/* A hairline that fades out at both ends, with a short tick where each end
   would have been.

   It was a 9px `]` — three solid borders at .55 — and at the short height that
   read as a small bracket. Stretched over the whole sky the same treatment
   became a 400px staple down the edge of the page: the heaviest single mark in
   the chapter, competing with the headline and reading more like a scrollbar
   than an annotation. Length and weight cannot both be at full strength. The
   length is the part that carries the argument, so the weight is what gives:
   a 1px rule at .34 that never quite starts or stops, and 7px ticks instead of
   a full-height box. */
.dx-zone::after {
  content: "";
  align-self: stretch;
  width: 7px;
  background:
    linear-gradient(180deg, transparent, currentColor 12%, currentColor 88%, transparent)
      right / 1px 100% no-repeat,
    linear-gradient(currentColor, currentColor) right top / 7px 1px no-repeat,
    linear-gradient(currentColor, currentColor) right bottom / 7px 1px no-repeat;
  opacity: .34;
}
/* Top: the same 84px floor .dx-sky-fit and .dx-nofly use, because the fixed
   header covers the first 69px of this section whenever it is the screen —
   which is the position the chapter is composed for. Starting at 0 would put
   the top tick behind the bar. */
.dx-zone-air {
  top: 84px;
  bottom: max(12.6667vw, 42.2222%);
}

/* Below the fit's own breakpoint the scene is cropped hard and the section is
   no longer a screen, so the bracket has neither the width to sit in nor much
   sky to measure. It is annotation; it goes.

   There used to be a `min-aspect-ratio: 4/1` clause here as well, for when the
   width-driven scale pushed the horizon up into a fixed-percentage bracket.
   That failure cannot happen now — the bracket is pinned to the horizon rather
   than guessing where it is, so at an extreme aspect it simply gets shorter
   along with the sky it is measuring. */
@media (max-width: 1023px), (max-height: 599px) {
  .dx-zone { display: none; }
}

/* --- Motion --------------------------------------------------------------

   The sky holds still. That is not an omission: nothing crosses it, which is
   the chapter's whole claim, and a chapter that argues for an empty sky by
   flying something through it is arguing against itself. The bombing run and
   the ghost aircraft that used to live here were exactly that — and they were
   cropped off-frame at most viewport ratios besides, so what a reader actually
   got was a label pointing at nothing.

   What is left is one thing, on the light and not in the sky: the sun's bloom
   breathing over twenty-odd seconds. It is slow enough that nobody is ever
   caught watching it move; the scene is only never quite the same twice.

   Two haze bands used to drift here as well. They went with the ellipses that
   drew them — a drifting edge is still an edge. */
.dx-sky.is-in .dx-bloom { animation: dx-bloom 22s ease-in-out infinite alternate; }
@keyframes dx-bloom {
  from { opacity: .8; }
  to   { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .dx-sky.is-in .dx-bloom { animation: none; }
}

/* ==========================================================================
   02 — The repair queue
   ==========================================================================

   The signature mechanic, and the chapter that gets the most room. The stage
   is the queue beside a side elevation of the tank; damaging a module marks it
   in both places at once, which is the whole point — the list is not a menu,
   it is the tank.

   Laid out as a bench rather than as a split, and the reason is the drawing's
   proportions. A tank in elevation is about two and a half times wider than it
   is tall, so in a column beside the copy it can only ever be small — this
   chapter used to spend a whole screen on a 770px panel with a 320px drawing
   in it. Across the full measure with the copy above, the same screen carries
   an elevation two-thirds again as wide, and the section fills. */

.dx-bench {
  display: flex;
  flex-direction: column;
  gap: clamp(26px, 4vh, 54px);
  justify-content: center;
}

/* Headline and paragraph side by side, bottom-aligned, because the bench under
   them is the full measure and a 30rem column of copy above it would leave the
   chapter top-heavy on one side. Kicker spans both. */
.dx-copy.dx-bench-copy { max-width: none; }
.dx-bench-copy p { max-width: 34rem; }

@media (min-width: 900px) {
  .dx-bench-copy {
    display: grid;
    grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr);
    column-gap: clamp(28px, 5vw, 80px);
    align-items: end;
  }
  /* `justify-self` is not optional here. `.kicker` is an inline-flex chip that
     shrink-wraps its label everywhere else on the page, but a grid item is
     blockified and then stretched to its cell by default — so on the two bench
     chapters the chip ran the full measure and read as a banner rather than as
     the same badge chapters 01, 03 and 04 carry. */
  .dx-bench-copy .kicker {
    grid-column: 1 / -1;
    justify-self: start;
  }
  /* Beside the heading rather than under it, so the 18px that separates the
     two when they are stacked would be a shove off the baseline here. */
  .dx-bench-copy p { margin-top: 0; padding-bottom: .35em; }
}

/* The panel takes whatever height the fit has left, up to a ceiling: past that
   the elevation is already as wide as the measure allows, and the extra height
   is only ever added around it. The ceiling is inside the fit's own query on
   purpose — where there is no fit there is no leftover height to cap, and a
   `max-height` on a stack that has grown past it would be clipped by `.dx`. */
.dx-bench-stage {
  flex: 1 1 auto;
  min-height: 0;
}
@media (min-width: 1024px) and (min-height: 600px) {
  .dx-bench-stage { max-height: 46rem; }
}

/* Extra measure for this chapter on a large screen. The bench is the one block
   on the page whose content scales with the room it is given — the drawing
   simply gets bigger — so it is the one that should be allowed past the site's
   1320px. Chapters 03–05 are still on the default and can join it as each is
   rebuilt. */
@media (min-width: 1660px) {
  .dx-rep { --maxw: 1560px; }
}

.dx-rep-stage {
  display: grid;
  grid-template-columns: minmax(0, clamp(13rem, 17%, 18rem)) minmax(0, 1fr);
  gap: clamp(16px, 2vw, 30px);
  align-items: stretch;
  height: 100%;
}

/* Top-aligned. The column is as tall as the drawing beside it and the queue is
   a fraction of that, so the leftover space goes underneath rather than being
   split around a block floating in the middle of the panel. */
.dx-q-col {
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  min-width: 0;
}

.dx-q-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 10px;
  font-family: var(--f-stencil);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .14em;
  color: var(--amber);
}
.dx-q-head::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--amber-line);
}

/* The list holds its own order once JS or a visitor has touched it, which is
   exactly what lang-transition.js cannot walk (it patches by position, so a
   reordered row would receive its neighbour's name). It is `data-lang-skip` in
   the markup and diff.js repaints it from __servalDiff instead. */
/* Wrapper holds the list and the demo cursor and nothing else: the cursor is
   placed from coordinates diff.js measures against the list's own box, so the
   two have to share an origin. */
.dx-q {
  display: flex;
  flex-direction: column;
  gap: 6px;
  list-style: none;
  width: 100%;
  min-height: 168px;
}

/* Rows are buttons, not draggable divs: promoting one has to work from a
   keyboard and from a screen reader, and the drag is the enhancement on top.
   The FLIP transform diff.js applies on reorder is on `transform`, so nothing
   here may transition it — the two would fight. */
.dx-q-row {
  position: relative;
  display: grid;
  grid-template-columns: 18px minmax(0, 1fr) auto;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 13px 13px;
  border: 1px solid var(--line);
  background: var(--panel-2);
  color: var(--ink-soft);
  font-family: var(--f-body);
  font-size: 13px;
  text-align: left;
  cursor: grab;
  touch-action: none;
  overflow: hidden;
}
.dx-q-row:focus-visible {
  outline: 2px solid var(--amber);
  outline-offset: 2px;
}
.dx-q-row.is-lift {
  cursor: grabbing;
  border-color: var(--amber-line-strong);
  z-index: 2;
}
/* Head of the queue: the one actually being worked on. */
.dx-q-row.is-active {
  border-color: var(--amber-line-strong);
  background: #1f1c11;
  color: var(--ink);
}
/* Repaired: fades and slides out, and then stops taking up room so the rows
   behind it close up. Two classes because it is two moves — diff.js adds
   `is-gone` once the fade has finished and FLIPs the survivors up into the
   space. Out of the flow means out of the tab order too, which is right: there
   is nothing left to promote. */
.dx-q-row.is-done {
  opacity: 0;
  transform: translateX(-10px);
  transition: opacity .3s ease, transform .3s ease;
  pointer-events: none;
}
.dx-q-row.is-gone { display: none; }

.dx-q-ico {
  font-size: 15px;
  color: var(--muted-2);
  line-height: 1;
}
.dx-q-row.is-active .dx-q-ico { color: var(--amber); }
.dx-q-name { min-width: 0; }
.dx-q-time {
  font-family: var(--f-mono);
  font-size: 11px;
  color: var(--muted-2);
  font-variant-numeric: tabular-nums;
}

/* What the list is for, said once and quietly. The chapter used to answer this
   by moving a drawn cursor over the rows and promoting one itself; a page that
   appears to click its own controls reads as a recording of an interface
   rather than as the interface.

   It carries `.dx-note` and adds almost nothing to it, which is the point:
   that is already this page's register for a caption that is not part of the
   argument — mono, muted, small — and it is the same one the CER's "example
   values" line uses. An instruction is the last thing on a page that should
   be shouting, so no rule above it, no amber, no caps. It retires on the first
   real input and does not come back.

   It sits under the queue's heading rather than under the rows, where it read
   as a footnote to the panel instead of a caption on the list. The heading's
   own 12px sits above it, so it only owns the gap down to the first row. */
.dx-q-hint {
  display: flex;
  align-items: center;
  gap: 7px;
  margin-bottom: 14px;
  transition: opacity .4s ease;
}
.dx-q-hint i { font-size: 13px; line-height: 1; opacity: .8; }
/* Two steps, the same pair the rows use: fade, then stop taking up room so the
   queue closes up over it. Anything less and a retired instruction goes on
   holding a line of the panel for the rest of the visit. */
.dx-q-hint.is-off {
  opacity: 0;
  pointer-events: none;
}
.dx-q-hint.is-gone { display: none; }

/* Progress runs under the row's own content as a filled ground rather than as
   a bar beside it: at 13px type a separate track would be taller than the text
   it belongs to. Width is set by diff.js. */
.dx-q-fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: 0;
  background: rgba(232, 163, 23, .16);
  border-right: 1px solid var(--amber-line);
  pointer-events: none;
}

/* --- The elevation -------------------------------------------------------

   The drawing board. Its graph paper is a background rather than SVG lines so
   it fills the panel at whatever size the fit hands it, which leaves the SVG
   holding only the vehicle. */
.dx-plate {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  padding: clamp(12px, 1.6vw, 26px);
  border: 1px solid var(--line-faint);
  background:
    repeating-linear-gradient(90deg, var(--line-faint) 0 1px, transparent 1px 46px),
    repeating-linear-gradient(180deg, var(--line-faint) 0 1px, transparent 1px 46px);
}

/* `height: auto` with a ceiling, never `height: 100%`: below 1024px the fit is
   off and this box has no definite height to be a percentage of. When the
   ceiling binds, `meet` letterboxes the vehicle inside the full width instead
   of cropping it. */
.dx-stage .dx-elev {
  display: block;
  width: 100%;
  height: auto;
  max-height: 100%;
}
.dx-elev-ground {
  fill: none;
  stroke: var(--amber-line);
  stroke-width: 1;
  opacity: .5;
  vector-effect: non-scaling-stroke;
}

/* One part of the vehicle. Filled, because the parts are traced from the model
   as an X-ray union and overlap heavily — a fill is what makes the near ones
   read in front of the far ones. */
.dx-elev-part {
  fill: var(--panel-3);
  stroke: var(--muted-4);
  stroke-width: 1.4;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
.dx-mod-fine .dx-elev-part { stroke-width: 2; }

/* A module, and its four readings. They have to survive without colour —
   red-on-amber is the one pair on this site a colour-blind reader would lose —
   so each state also changes the line itself: damaged is broken, under repair
   is broken and pulsing, repaired is solid and brighter than the parts around
   it that were never hit.
      undamaged  quiet olive, the same as the hull
      damaged    red, dashed
      repairing  amber, dashed, pulsing — work in progress, not work done
      repaired   solid amber, and it stays */
.dx-mod path,
.dx-mod circle,
.dx-mod rect {
  transition: stroke .35s ease, opacity .35s ease;
}
.dx-mod.is-hit .dx-elev-part {
  stroke: var(--danger);
  stroke-dasharray: 5 4;
}
.dx-mod.is-fixing .dx-elev-part {
  stroke: var(--amber-bright);
  stroke-dasharray: 5 4;
  animation: dx-fixing 1.15s ease-in-out infinite, dx-crawl 1.1s linear infinite;
}
.dx-mod.is-fixed .dx-elev-part {
  stroke: var(--amber);
  stroke-dasharray: none;
}
/* Under repair. The pulse is the state — the previous version went solid and
   bright the moment a module reached the head of the queue, which is what a
   *finished* repair should look like, and there was then nothing left for the
   finish itself to say. */
@keyframes dx-fixing {
  0%, 100% { opacity: .38; }
  50%      { opacity: 1; }
}
@keyframes dx-crawl {
  to { stroke-dashoffset: -18; }
}


/* The impact marker: a ring that expands once where a shell landed. */
.dx-hit {
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}
.dx-hit.is-on { animation: dx-hit .9s ease-out; }
@keyframes dx-hit {
  0%   { opacity: 1; transform: scale(.3); }
  70%  { opacity: .8; }
  100% { opacity: 0; transform: scale(1.9); }
}

/* A fixed-size SVG inside a stage needs `.dx-stage` on its selector to beat
   `.dx-stage svg { width: 100% }` above — the demo cursor that used to live
   here was stretched to the full width of the panel until it got that. Worth
   knowing before dropping a glyph into one of these. */

/* ==========================================================================
   03 — The economy
   ==========================================================================

   Centred, and the only symmetrical chapter of the five: three bands stacked
   down the middle — the argument, one date line, the ledger under it.

   It was the argument and three struck words floating mid-screen, and it
   filled about a third of the height it was given, which is the complaint
   chapter 02 drew as well. The answer there was a bigger drawing. The answer
   here is that the chapter finally has something to put on the screen: what
   money buys and what it does not, in a column each. */

.dx-econ-col {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 0;
  gap: clamp(22px, 3.2vh, 48px);
}

.dx-econ-copy {
  max-width: 40rem;
  margin: 0 auto;
  text-align: center;
}
.dx-econ-copy p { margin-inline: auto; }

/* --- The carousel -------------------------------------------------------

   The chapter's middle band: three slides, one per thing money buys, stacked in
   a single grid cell and cross-faded. diff.js puts `.is-on` on one slide and on
   the ledger row that belongs to it; everything else here is declared under the
   slide it belongs to, so each shot starts from its own first frame when its
   turn comes round instead of being caught wherever a shared timeline had got
   to.

   **Stacked rather than translated.** A track that slides sideways would need
   every slide to be the same width *and* would animate layout; one grid cell
   with three children in it is the max of their heights, holds still, and
   composites. It also means the band's height is set by the tallest slide — so
   all three share one viewBox, and adding a fourth that does not is how this
   chapter starts overflowing a 1080p screen again.

   The sizing that used to be on `.dx-rel` lives here now: the date line is one
   of the three, and the box they all size to is the carousel. */
.dx-show {
  display: grid;
  width: 100%;
  max-width: 76rem;
  margin-inline: auto;
}
.dx-slide {
  grid-area: 1 / 1;
  position: relative;
  opacity: 0;
  transition: opacity .55s ease;
  pointer-events: none;
}
/* Without diff.js nothing is ever `.is-on`, and stacked slides at zero opacity
   is an empty band where the argument used to be. The rig below is always
   drawn, so the no-JS picture is the vehicle plus the first slide's overlay —
   `.is-cued` goes on in the same synchronous run that paints slide 0, so there
   is no flash between the two rules. */
.dx-show:not(.is-cued) .dx-slide:nth-of-type(2) { opacity: 1; }
.dx-show.is-cued .dx-slide.is-on { opacity: 1; }

/* The date line waits for its vehicle. Everything in that slide — the trail,
   the dust, the dates, the copy that drives on — is placed against the launch
   mark, so fading it in on the state change puts a whole drawing on a mark the
   tank has not reached yet: the reader sees a second tank appear on the left
   while the first is still on its way there. Held back until the drive is done,
   the order is the one the argument needs — the vehicle parks, then the copy
   leaves it. The delay belongs to the incoming state only; leaving `is-early`
   drops this rule, so it fades out at once. Keep it in step with
   `.dx-show-rig`'s transition and with RIG_MS in diff.js. */
.dx-show.is-early .dx-slide[data-dx-slide="early"] { transition-delay: .8s; }

/* --- The rig ------------------------------------------------------------

   The vehicle, drawn once and never faded. It shares the grid cell with the
   slides and sits under them, so every overlay lands on top of it.

   The states move it rather than replace it: skins and premium leave it on the
   middle of the frame, and early drives it left to the date line's launch mark.
   -383.3 is exactly the gap between the two placements — `$dxSuAt(600 +
   $dxGunBias)` puts its origin at 453.8 and `$dxSuAt(300)`, which is where the
   date line's travelling copy starts, puts it at 70.5. Keep the two together:
   if the vehicle is re-centred, this moves with it or the tank arrives beside
   the mark instead of on it. */
.dx-show-base { grid-area: 1 / 1; }
.dx-show-base svg { display: block; width: 100%; height: auto; overflow: visible; }

.dx-show-rig { transition: transform .85s cubic-bezier(.5, 0, .2, 1); }
.dx-show.is-early .dx-show-rig { transform: translateX(-383.3px); }

/* Suspension, for the length of the drive and no longer. Two beats of the date
   line's own bob is .84s, which is the transition above; it is keyed to the two
   states that are *arrived at* by moving — early from premium, skins back from
   early — so the one transition that does not move the tank (skins to premium)
   does not jiggle it either. */
.dx-show.is-early .dx-show-rig .dx-rel-hull,
.dx-show.is-skins .dx-show-rig .dx-rel-hull {
  animation: dx-rel-bob .42s ease-in-out 2;
}

/* The two amber layers. The coat carries early, the paint carries skins, and
   they cross-fade — see the note in index.php for why this is not one layer.
   The coat's fade is slow and delayed so that on the way into skins it is still
   there while the wipe is laying its replacement, rather than leaving the tank
   bare for half a second in between. */
.dx-show-coat,
.dx-show-paint { pointer-events: none; }
.dx-show-coat path,
.dx-show-paint path { fill: var(--amber); }

.dx-show-coat {
  opacity: 0;
  transition: opacity .9s ease .3s;
}
.dx-show.is-early .dx-show-coat { opacity: 1; }

.dx-show-paint { opacity: 0; transition: opacity .5s ease; }
.dx-show.is-skins .dx-show-paint { opacity: 1; }

/* No JS, so no state class and no cue: the rig would sit there bare, which is
   the one frame of this chapter that says nothing. The wipe rect already rests
   at its far end, so showing the paint is all it takes to hand a reader without
   this file a finished, painted vehicle — the same argument `--p: 1` makes for
   the date line. */
.dx-show:not(.is-cued) .dx-show-paint { opacity: 1; }
.dx-slide svg { display: block; width: 100%; height: auto; overflow: visible; }

/* The bare silhouette under everything. The date line's travelling copy keeps
   its own fills — its whole argument is which vehicle is amber and which is
   not. */
.dx-show-veh path { fill: var(--muted-4); }

/* **There was a `.dx-show-halo` here and there must not be another.** It was a
   soft amber radial ellipse under the vehicle, breathing on a 3.6s loop, and
   the same idea had spread to the two chip families as a pulsing amber
   `box-shadow`. Three problems with it, in order of how much they cost:

   It was not light. A radial gradient behind an object is a backlight, and this
   scene has one light source already — the sun in chapter 01 — which this band
   does not share a sky with. What it actually read as was a yellow bruise
   around the tank.

   It competed with the thing it was lighting. The vehicle is amber in two of
   the three states, so an amber wash behind it lowers the contrast of exactly
   the silhouette the chapter is asking a reader to look at.

   And it made every state look the same. A halo that is on for skins and on for
   premium is not carrying either argument; it is a texture the eye stops seeing
   by the second turn of the carousel.

   What replaced it is light with an edge — a wipe, a spray front, a sheen, a
   star. All four are *shapes*, all four are clipped to the vehicle, and each
   one belongs to one state. If a future state needs to feel lit, give it
   another shape. Do not give it a fade. */

/* SKINS. The wipe rect starts at x=-620 width=620 — clear of the vehicle, which
   is body-centred and spans 453.8…912.8 — and travelling 990 leaves it covering
   370…990: past the barrel tip and behind the back plate. That travel is tied
   to `$dxGunBias` in index.php; when the vehicle was centred on its silhouette
   instead this was 900, and moving it right without moving this left the last
   thirteen units of gun unpainted. */
@keyframes dx-show-wipe {
  from { transform: translateX(0); }
  to { transform: translateX(990px); }
}
.dx-show.is-skins .dx-show-wipe {
  animation: dx-show-wipe 1.7s cubic-bezier(.4, 0, .25, 1) .35s both;
}

/* THE LIVERY. Sits in the wipe's clip directly over the paint, so the vehicle
   arrives marked rather than merely tinted — see the pattern's own note in
   index.php for why that is the difference between "a skin" and "selected".
   Nothing here but the fill: the pattern carries the geometry, and it is
   declared in the SVG because a `<pattern>` has no CSS equivalent. */
.dx-show-livery { pointer-events: none; }
.dx-show-livery path { fill: url(#dxSkinLivery); }

/* THE SPRAY FRONT. A hard bright bar riding the wipe's leading edge, clipped to
   the body. Same keyframes, same duration, same delay and same easing as
   `.dx-show-wipe` — that is not convenience, it is the requirement: any
   difference at all and the bar and the edge of the paint drift apart, which
   reads as a stray line crossing a tank that is already painted. Change one,
   change both. */
.dx-show-front { opacity: 0; }
.dx-show-front rect { fill: #fff4d2; }
.dx-show.is-skins .dx-show-front {
  opacity: .85;
  animation: dx-show-wipe 1.7s cubic-bezier(.4, 0, .25, 1) .35s both;
}

/* THE SHEEN. The loop that says "cosmetic": a leaning specular band crossing
   the vehicle every few seconds, the way an item in a shop catches the light.

   It starts *after* the wipe has finished (.35 + 1.7 = 2.05s, rounded up to
   2.2s) because a highlight sweeping over paint that is still being laid is two
   edges travelling the same direction at different speeds, and the eye reads
   the pair as a rendering fault rather than as a shine. Then every 4.6s, which
   is long enough that it is an event rather than a shimmer.

   The travel is on the inner group and the lean is a `skewX` attribute on the
   rect inside it, for the same reason the crown is split in two: a CSS
   transform on the rect would replace the skew rather than compose with it. */
.dx-show-sheen { opacity: 0; }
@keyframes dx-show-sheen {
  /* Held off-vehicle at both ends of the cycle: the band only exists for the
     ~1.1s it is actually crossing, and the rest of the loop is the wait. */
  0%, 4% { transform: translateX(390px); }
  28%, 100% { transform: translateX(1000px); }
}
.dx-show.is-skins .dx-show-sheen { opacity: 1; }
.dx-show.is-skins .dx-show-sheen-run {
  animation: dx-show-sheen 4.6s cubic-bezier(.35, 0, .3, 1) 2.2s infinite;
}

/* The sparkles. Two stars each: `--amber-bright` at full radius with a
   near-white core at .42 of it. One flat amber star on amber paint is one value
   on another and reads as a smudge — the hot centre is what makes each one an
   unmistakable point of light, which is the whole of what "not clear enough"
   was about. The radii went up by about a third at the same time; both halves
   were needed, and either alone was not enough.

   Each scales about its own centre — `fill-box` is what makes that the group's
   own bounding box rather than the whole SVG viewport, and without it eight
   sparkles all pivot around the same far-away origin and swing rather than
   twinkle. They start and end at zero, so the loop has a dark beat and they
   blink instead of pulsing; the stagger is what keeps the eight from blinking
   as one. */
.dx-show-spark {
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}
.dx-show-spark-ray { fill: var(--amber-bright); }
.dx-show-spark-core { fill: #fff6dd; }
/* The old curve spent two thirds of its loop on the way down and the sparkles
   were dim more often than they were lit. This one snaps up, holds at full
   through the middle of the cycle, and only then lets go — a glint has a hard
   attack and a long look at it. */
@keyframes dx-show-spark {
  0% { opacity: 0; transform: scale(.1) rotate(-35deg); }
  16% { opacity: 1; transform: scale(1.14) rotate(-8deg); }
  32% { opacity: 1; transform: scale(.95) rotate(0deg); }
  64% { opacity: .9; transform: scale(1.02) rotate(9deg); }
  100% { opacity: 0; transform: scale(.1) rotate(30deg); }
}
.dx-show.is-skins .dx-show-spark {
  animation: dx-show-spark 2.6s ease-in-out infinite;
  /* Behind the wipe's own .35s head start, then one beat per sparkle across. */
  animation-delay: calc(.55s + var(--i, 0) * .17s);
}

/* THE SWATCH ROW. Four squares in the empty ~340 units left of the vehicle —
   the same clear column the premium chips use, and vacant in every state, so
   nothing here has to fight the drawing for room.

   Square and hairlined like everything else on this page. The dark border is
   not decoration: two of the four are light enough to vanish against nothing at
   all, and a swatch with no edge is a smear rather than a sample. */
.dx-show-swatch { display: flex; gap: 7px; }
.dx-show-chip {
  width: clamp(14px, 1.55vw, 21px);
  aspect-ratio: 1;
  background: var(--c);
  border: 1px solid rgba(16, 15, 10, .8);
  opacity: 0;
}
/* The picked one, in the game's own idiom: a ring outside the sample rather
   than a border on it, so the colour it is naming is not partly covered by the
   thing pointing at it. `outline` and not a second border for exactly that —
   an outline is drawn outside the box and takes no room from it. */
.dx-show-chip.is-on {
  outline: 2px solid var(--amber-bright);
  outline-offset: 2px;
}
@keyframes dx-show-chip-in {
  0% { opacity: 0; transform: translateY(9px) scale(.55); }
  60% { opacity: 1; transform: translateY(0) scale(1.18); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
/* Ahead of the paint, not behind it: the row lands, the amber one is picked,
   and *then* the wipe lays that colour on the tank. The other order is a tank
   that repaints itself and a palette that turns up afterwards to explain it.
   The wipe starts at .35s, so the four are all in by then. */
.dx-slide.is-on .dx-show-chip {
  animation: dx-show-chip-in .44s cubic-bezier(.2, .8, .3, 1) both;
  animation-delay: calc(var(--i, 0) * .09s);
}

/* THE CROWN — the whole of the premium state now that the gamertag is gone.
   Amber-bright on the bare `--muted-4` vehicle (the paint belongs to skins), so
   it carries itself with nothing behind it.

   It grows out of the roof it is planted on rather than fading in: `fill-box`
   plus a bottom-centre origin puts the pivot at the crown's base, which is the
   one point of it that is touching the tank. */
.dx-show-crown {
  fill: var(--amber-bright);
  opacity: 0;
  transform-box: fill-box;
  transform-origin: 50% 100%;
}
@keyframes dx-show-crown-in {
  0% { opacity: 0; transform: translateY(-13px) scale(.55); }
  62% { opacity: 1; transform: translateY(0) scale(1.1); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
.dx-show.is-prem .dx-show-crown {
  animation: dx-show-crown-in .55s cubic-bezier(.2, .8, .3, 1) both;
}

/* Its shine: three stars around it on their own beats, not a glow behind it.
   They borrow the sparkles' keyframes deliberately — the two states are the
   same kind of light and should read as one vocabulary — but run slower and
   further apart, because three of them are an ornament where eight are a
   surface. */
.dx-show-crown-shine { opacity: 0; }
.dx-show.is-prem .dx-show-crown-shine { opacity: 1; }
.dx-show-gleam {
  fill: #fff6dd;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}
.dx-show.is-prem .dx-show-gleam {
  animation: dx-show-spark 2.4s ease-in-out infinite;
  animation-delay: calc(.65s + var(--i, 0) * .42s);
}

/* PREMIUM. The nametag chip that used to live here is gone — its markup, its
   entrance and its glow. The crown above is the whole of the state now; see the
   note in index.php for why a made-up gamertag was arguing the wrong thing.

   The reward chips. A bezelled icon and a figure, which is how a game says
   "you earned that" — the two progress bars these replaced said "loading",
   which is a different sentence.

   `animation-delay` stays on its own line rather than going into the shorthand:
   the delay is a `calc()` off `--i`, and a `<time>` in the shorthand is
   positional, so a calc there is ambiguous with the duration. */
.dx-show-gain {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 9px;
}
.dx-show-gain-row {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 5px 11px 5px 7px;
  border: 1px solid var(--amber-line);
  background: var(--panel-3);
  color: var(--amber);
  font-family: var(--f-mono);
  font-size: clamp(11px, 1.05vw, 14px);
  line-height: 1;
}
.dx-show-gain-row .ti { font-size: 1.3em; }
.dx-show-gain-row b { font-weight: 600; letter-spacing: .02em; }

/* The boost tag. Inverted — amber ground, dark type — because it is the one
   part of the chip that is a *state* rather than a quantity, and a filled block
   is how a game says "this is switched on". Square, like everything else here. */
.dx-show-boost {
  padding: 2px 5px;
  background: var(--amber);
  color: var(--panel-3);
  font-style: normal;
  font-size: .82em;
  font-weight: 600;
  letter-spacing: .02em;
}

/* In from the left with one overshoot before it settles. The little punch is
   the whole difference between a number appearing and a number being awarded. */
@keyframes dx-show-gain-in {
  0% { opacity: 0; transform: translateX(-16px) scale(.8); }
  55% { opacity: 1; transform: translateX(0) scale(1.14); }
  100% { opacity: 1; transform: translateX(0) scale(1); }
}
/* The second animation these carried was `dx-show-gain-glow`, a pulsing amber
   `box-shadow` — the same backlight fade as the halo, on a chip instead of
   behind a tank, and it is gone with it. The entrance is the whole animation
   now, which is also why this is back to the shorthand: one name, one time. */
.dx-slide.is-on .dx-show-gain-row {
  animation: dx-show-gain-in .62s cubic-bezier(.2, .8, .3, 1) both;
  animation-delay: calc(.45s + var(--i, 0) * .22s);
}

/* --- The date line ------------------------------------------------------

   One playhead, and every rule below is a function of it. diff.js writes `--p`
   on `.dx-rel` — 0 to 1 over a couple of seconds, started when the drawing
   arrives on screen and rewound to 0 once the chapter has left it. So the shot
   runs at its own pace rather than the reader's, it cannot play to an empty
   room, and coming back to the chapter plays it again.

   **`--p` defaults to 1 here, not 0.** That is the finished frame, and it is
   what no-JS, no-IntersectionObserver and reduced motion all get: the vehicle
   parked at the far mark, the trail laid, both labels up. An animation whose
   fallback is frame zero is a blank chapter. */

.dx-rel {
  --p: 1;
  position: relative;
  width: 100%;
}

/* Height-capped while the fit is on, because the copy and the ledger have to
   fit the same screen. It is capped by *width*: the labels are HTML pinned in
   per-cent of this box (see the header), so the box has to shrink with the
   drawing rather than letting `preserveAspectRatio` shrink the drawing inside
   a box that stays wide. Every slide's viewBox is 1200×188, so a width cap is a
   height cap — keep the 6.4 in step with it if those boxes are ever re-cropped,
   and keep them identical to each other. */
@media (min-width: 1024px) and (min-height: 600px) {
  .dx-show { max-width: min(76rem, calc(30vh * 6.4)); }
}

/* This is the most vertical of the five chapters — three stacked bands rather
   than two columns — and the fit's box is about 870px on a 1440p screen but
   only about 620px on a 1080p one. Everything that costs height is therefore
   stepped down once on the shorter of the two: the title (its sentence wraps
   to two lines at any size, so a smaller one costs nothing), the drawing, and
   the ledger's line rhythm. The alternative is a chapter that quietly grows
   past the single screen the whole series is built on. */
@media (min-width: 1024px) and (min-height: 600px) and (max-height: 1000px) {
  .dx-econ-copy { max-width: 46rem; }
  .dx-econ-copy h2 { font-size: clamp(28px, 3.4vw, 42px); }
  .dx-show { max-width: min(76rem, calc(17vh * 6.4)); }
  .dx-led dt { font-size: clamp(17px, 1.6vw, 22px); }
  .dx-led dd { font-size: 13px; max-width: 30rem; }
  .dx-led dd + dt { margin-top: clamp(12px, 1.4vh, 18px); }
  .dx-led-row + .dx-led-row { margin-top: clamp(12px, 1.4vh, 18px); }
}

.dx-rel-veh path { fill: var(--muted-4); }
.dx-rel-paid path { fill: var(--amber); }

/* The travel. 600 user units is exactly the gap between the two marks, so the
   copy leaves the parked vehicle's footprint and lands on the far one. The
   dust rides the same transform rather than being nested inside the vehicle,
   because it has to be painted *behind* it. */
.dx-rel-move,
.dx-rel-dust { transform: translateX(calc(var(--p) * 600px)); }

/* Amber under, plain over. A cross-fade between two stacked silhouettes,
   because `fill` will not interpolate on a custom property — and the point of
   the shot is that the shape does not change while the colour does. */
.dx-rel-free { opacity: clamp(0, calc((var(--p) - .25) * 2.2), 1); }

/* The ground it went over, which replaced a dimension line spanning the two
   vehicles: a bracket with tick legs reads as a ruler, and there is nothing
   here to measure — no number for the wait exists.

   A mark appears as the vehicle's centre passes over it. Centre = 300 + p·600
   and mark i sits at x = 305 + 20i, so it is passed once p·600 − 5 − 20i > 0;
   dividing by the 20-unit pitch gives each mark its own width to fade in. */
.dx-rel-mark {
  stroke: var(--muted-3);
  stroke-width: 2;
  vector-effect: non-scaling-stroke;
  opacity: clamp(0, calc(var(--p) * 30 - .25 - var(--i, 0)), 1);
}

/* Suspension. The hull rides in its own group over running gear that stays on
   the ground — 1.6 units against the 7.7 the two overlap by, so the hull never
   lifts clear of its own tracks. Paused rather than stopped, so a scroll that
   resumes picks the cycle up mid-stride instead of snapping to frame zero.
   Only the moving copy has it; the parked one is parked. (Paused matters less
   now that `.is-rolling` spans one uninterrupted run, but a language swap
   mid-run still passes through here.) */
@keyframes dx-rel-bob {
  0%, 100% { transform: none; }
  50% { transform: translateY(-1.6px); }
}
.dx-rel-move .dx-rel-hull {
  animation: dx-rel-bob .42s ease-in-out infinite;
  animation-play-state: paused;
}
.dx-rel.is-rolling .dx-rel-move .dx-rel-hull { animation-play-state: running; }

.dx-rel-dust ellipse {
  fill: var(--muted-3);
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}
@keyframes dx-rel-puff {
  from { opacity: .5; transform: scale(.35); }
  to { opacity: 0; transform: translate(-30px, -14px) scale(1.6); }
}
.dx-rel.is-rolling .dx-rel-dust ellipse {
  animation: dx-rel-puff .62s ease-out infinite;
  animation-delay: calc(var(--i, 0) * .21s);
}

/* Both belong to the far mark, and neither exists until the vehicle is nearly
   on it — a label naming a place nothing has reached yet is a spoiler. */
.dx-rel-late { opacity: clamp(0, calc((var(--p) - .82) * 6), 1); }

/* --- The ledger --------------------------------------------------------- */

.dx-ledger {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 1px minmax(0, 1fr);
  gap: clamp(20px, 3.2vw, 52px);
  align-items: start;
  width: 100%;
  max-width: 64rem;
  margin-inline: auto;
}
.dx-led-rule {
  align-self: stretch;
  background: var(--line-faint);
}

.dx-led-head {
  display: block;
  margin-bottom: clamp(12px, 1.4vw, 18px);
}
.dx-led dt {
  position: relative;
  display: inline-block;
  font-family: var(--f-display);
  font-size: clamp(18px, 1.9vw, 25px);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: .02em;
  text-transform: uppercase;
  color: var(--ink-soft);
  padding: 1px 3px;
}
.dx-led dd {
  margin: 5px 0 0;
  color: var(--muted-2);
  font-size: clamp(12.5px, 1vw, 14px);
  line-height: 1.55;
  max-width: 26rem;
}
.dx-led dd + dt { margin-top: clamp(14px, 1.8vh, 24px); }

/* --- The rows, which are also the carousel's controls -------------------

   `.dx-led-row` wraps each dt/dd pair so the whole entry is what lights, not
   the term on its own, and it takes over the vertical rhythm `dd + dt` used to
   carry in this column.

   **The current row is marked by a background, and the other two are left
   exactly as they read on their own.** Dimming them was tried and is worse: two
   greyed-out rows make the ledger look like two thirds of it is disabled, when
   all three are equally true all the time — the carousel is only saying which
   one the drawing is about this second. A wash behind one row says that without
   taking anything away from the others. */
.dx-led-row {
  /* The row, not the `dt`, is what the stretched hit area and the wash measure
     against — `.dx-led dt` is `position: relative` for the strike-through in the
     other column, and left as-is here it would catch the button's
     pseudo-element and shrink the target to the width of one word. */
  position: relative;
  padding: 8px 12px;
  /* Pulled back out again so the *text* still lines up with the heading above
     it and with the column opposite, while the wash bleeds into the gutter. The
     ledger's column gap is at least 20px, so a 12px bleed always has room. */
  margin-inline: -12px;
  background: transparent;
  transition: background-color .45s ease;
}
.dx-led-row dt { position: static; }

/* The padding is on every row, always, so nothing moves when the wash arrives —
   which is also why the gap between rows is smaller than it used to be: 8px of
   padding at each end of the join now does most of the work the margin did. */
.dx-led-row + .dx-led-row { margin-top: clamp(2px, 0.6vh, 8px); }

/* The wash and the type together: the term goes amber and the sentence under it
   comes up to `--ink-soft`. Only the current row is touched — the other two
   keep the colours they have on their own, which is the whole point of not
   dimming them. */
.dx-led-row dt,
.dx-led-row dd { transition: color .45s ease; }

.dx-led-row.is-on { background: rgba(232, 163, 23, .1); }
.dx-led-row.is-on dt { color: var(--amber); }
.dx-led-row.is-on dd { color: var(--ink-soft); }

/* The control. It inherits every bit of its type from the `dt` it replaced, so
   the column reads exactly as it did when these were words rather than buttons
   — the affordance is the cursor and the hover, not a button that looks like
   one. */
.dx-led-hit {
  display: inline;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
/* Stretched over the whole row, so the sentence under the term is clickable
   too. The row carries the padding now, so this is simply all of it. */
.dx-led-hit::after {
  content: "";
  position: absolute;
  inset: 0;
}
.dx-led-row:hover { background: rgba(232, 163, 23, .05); }
.dx-led-row.is-on:hover { background: rgba(232, 163, 23, .1); }
.dx-led-hit:focus-visible::after {
  outline: 2px solid var(--amber-line-strong);
  outline-offset: -2px;
}

/* The strike is the one thing kept from the version this replaced. The word
   itself is struck rather than given a red circle-slash icon — the icon is the
   cliché, the strike is the claim — but it belongs to a column with a heading
   over it, not to the middle of the page. */
.dx-led-no dt { color: var(--muted-2); }
.dx-led-no dt::after {
  content: "";
  position: absolute;
  left: 0;
  top: 52%;
  height: 2px;
  width: 100%;
  background: var(--amber);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform .5s cubic-bezier(.65, 0, .35, 1);
  transition-delay: calc(var(--i, 0) * 130ms + 250ms);
}
.dx-ledger.is-in .dx-led-no dt::after { transform: scaleX(1); }

/* ==========================================================================
   04 — CER
   ==========================================================================

   The scale is drawn; the handle is a real <input type="range">. Same call the
   video player makes for its seek bar (docs/frontend.md): arrow keys, Home/End
   and the screen-reader announcement stay the platform's, and no amount of
   hand-built handle is worth losing them. The input's own thumb IS the visible
   handle, so there is nothing to keep in sync. */

.dx-cer-scale { position: relative; margin-top: 18px; }

/* Laid over the drawn scale, and aligned to it by the same two numbers diff.js
   uses to place the window: the ruler starts at x=40 and spans 1120 units of a
   1200-unit viewBox, so the input starts at 3.333% and is 93.333% wide. Change
   one and the other two have to move — they are the same measurement written
   in three places because CSS cannot read a viewBox. `.dx-cer-ends` below is
   inset by that same 3.333%, which is what sits its two labels on the ruler's
   own ends rather than on the panel's. */
.dx-cer-range {
  position: absolute;
  left: 3.3333%;
  width: 93.3334%;
  /* The scale's baseline is y=80 of a 120-unit viewBox — the ruler's own box
     since the coarse rank moved out of it into a drawing of its own, so this
     moved with it. Percentage tracks the drawing as it scales; the half-height
     offset then centres the thumb on it. */
  top: 66.6667%;
  margin: -19px 0 0;
  padding: 0;
  height: 38px;
  background: none;
  appearance: none;
  -webkit-appearance: none;
  cursor: ew-resize;
}
.dx-cer-range:focus { outline: none; }
.dx-cer-range:focus-visible { outline: 2px solid var(--amber); outline-offset: 4px; }

.dx-cer-range::-webkit-slider-runnable-track { height: 38px; background: none; }
.dx-cer-range::-moz-range-track { height: 38px; background: none; }

.dx-cer-range::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 3px;
  height: 38px;
  background: var(--amber-bright);
  border: none;
  box-shadow: 0 0 0 1px rgba(20, 20, 13, .9), 0 0 14px 2px rgba(232, 163, 23, .5);
  cursor: ew-resize;
}
.dx-cer-range::-moz-range-thumb {
  width: 3px;
  height: 38px;
  border: none;
  border-radius: 0;
  background: var(--amber-bright);
  box-shadow: 0 0 0 1px rgba(20, 20, 13, .9), 0 0 14px 2px rgba(232, 163, 23, .5);
  cursor: ew-resize;
}

/* The match window. diff.js sets x/width, and deliberately without a
   transition: the sweep repaints every frame, so a 0.18s ease on `x` spends
   the whole sweep chasing a target that has already moved — the band visibly
   trailed the handle by tens of points. Frame-by-frame is already smooth, and
   a keyboard step is one notch. */
.dx-cer-win {
  fill: rgba(232, 163, 23, .13);
  stroke: var(--amber-line-strong);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

.dx-cer-read {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 6px 16px;
  font-family: var(--f-mono);
  font-size: 12px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.dx-cer-read b {
  font-weight: 500;
  font-size: 15px;
  color: var(--amber);
}
.dx-cer-read span { color: var(--muted-2); }

/* The coarse rank's label: what the drawing under it is, and one mono line
   saying what it does. Baseline-aligned so the stencil tag and the note sit on
   one line at full width and stack cleanly when the note runs out of room.

   Only the coarse rank carries one. It used to carry nothing but a pinned
   "Ailleurs" floating between the two drawings, which named neither of them —
   a reader met six grey blocks and had to infer both what they were and that
   they were the thing being argued against. The ruler above needs no matching
   tag: it is this site's scale on this site's page, and labelling it only
   raised the question of whose the other one was. */
.dx-cer-rank {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 10px;
  /* Deliberately wider than the gaps inside the ruler block above it. This is
     where the panel changes subject — from our scale to somebody else's — and
     at 18px the label read as a third line of the ruler's own caption. */
  margin-top: 34px;
}
.dx-cer-rank .dx-note { color: var(--muted-2); }

/* The ruler's two ends, in the flow under it rather than pinned over it: the
   drawing is about 40px tall on a phone and a label centred inside that box
   lands outside it. The inset is the ruler's own — see .dx-cer-range. */
.dx-cer-ends {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  margin: 6px 3.3333% 0;
  color: var(--muted-2);
}
.dx-cer-ends .dx-tag { color: inherit; }

/* The coarse alternative, in the second rank and never named. Rendered muted
   and smaller because it is the thing being argued against, not a comparison
   table the reader has to study. It is its own <svg> now — same 1200-unit
   width and the same x=40..1160 span as the ruler, so the two ranks measure
   the same stretch of screen. */
.dx-cer-coarse { margin-top: 8px; }
.dx-cer-coarse rect {
  fill: rgba(232, 227, 214, .07);
  stroke: var(--line);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

/* ==========================================================================
   05 — Roadmap
   ==========================================================================

   One encoding, two shapes. Solid amber runs today, dashed is intended — and
   that pair is stated once, by the board's own column heads, rather than by a
   legend sitting above two drawings.

   **The period and the modes are deliberately not drawn alike.** They were,
   until 2026-08-19: two horizontal rails with blocks on them, stacked. Their
   axes are different measurements (historical time against development order),
   and drawing them as twins made "plus tard" appear twice on the page meaning
   two different things. The period keeps an axis because it has one; the modes
   became a list because a mode has a state, not a position. */

.dx-road {
  background:
    radial-gradient(46% 62% at 16% 24%, rgba(60, 66, 32, .5), transparent 62%),
    radial-gradient(40% 58% at 84% 78%, rgba(60, 66, 32, .34), transparent 60%),
    var(--bg-home);
}

.dx-road-stage {
  display: flex;
  flex-direction: column;
  gap: clamp(30px, 5vh, 64px);
  width: 100%;
  max-width: 64rem;
  margin-inline: auto;
}

/* Inside the fit, the stage takes the height the copy left over and spreads its
   two blocks through it, up to a ceiling. Centred in a 100dvh band the whole
   chapter reads as a small card floating in the dark, which is the standing
   complaint about these screens; spread, it reads as a composition. The ceiling
   is inside the fit's own query for the reason .dx-bench-stage's is — where
   there is no fit there is no leftover height to cap. */
@media (min-width: 1024px) and (min-height: 600px) {
  .dx-road-stage {
    flex: 1 1 auto;
    justify-content: space-evenly;
    max-height: 34rem;
  }
}

.dx-road-head {
  font-family: var(--f-stencil);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--muted-2);
  padding-bottom: 10px;
  margin-bottom: clamp(16px, 2.2vh, 26px);
  border-bottom: 1px solid var(--line-faint);
}

/* --- The period ------------------------------------------------------- */

/* Boxes, not an SVG. A viewBox holds its aspect ratio, so a strip that reads
   at 1200px is 23px tall on a phone and the year stops being legible at the
   one size where legibility matters most. Here the block is sized by its own
   text and the two extensions take whatever is left. */
.dx-road-era {
  display: flex;
  align-items: center;
  gap: clamp(10px, 1.4vw, 18px);
}

.dx-road-now {
  flex: 0 0 auto;
  padding: clamp(9px, 1.3vh, 14px) clamp(16px, 2.4vw, 30px);
  border: 1.5px solid var(--amber);
  background: rgba(232, 163, 23, .14);
  font-family: var(--f-mono);
  font-size: clamp(13px, 1.15vw, 15px);
  letter-spacing: .04em;
  color: var(--amber);
  font-variant-numeric: tabular-nums;
}

/* The dashed run out to either side, with an arrowhead on the outer end. The
   rule is a border on a pseudo-element rather than on the element itself so
   the arrow can sit on the same centre line without a second box. */
.dx-road-ext {
  flex: 1 1 0;
  position: relative;
  height: 2px;
  min-width: 40px;
}
.dx-road-ext::before {
  content: "";
  position: absolute;
  inset: 0 12px;
  border-top: 1.5px dashed var(--muted-3);
}
.dx-road-ext::after {
  content: "";
  position: absolute;
  top: 50%;
  width: 0;
  height: 0;
  border-block: 7px solid transparent;
}
/* Explicit sides rather than :first-child / :last-child: the ends of the strip
   are the two dashed period chips now, and the runs sit between them and the
   middle block. The arrowhead points outward, at the period being reached. */
.dx-road-ext-l::after {
  left: 0;
  transform: translateY(-50%);
  border-right: 12px solid var(--muted-3);
}
.dx-road-ext-r::after {
  right: 0;
  transform: translateY(-50%);
  border-left: 12px solid var(--muted-3);
}

/* The two periods the game is headed for. Same chip as the middle one, dashed
   and muted, because they are under the board's PRÉVU encoding rather than its
   EN JEU one — and that difference is the only thing keeping this strip honest. */
.dx-road-then {
  flex: 0 0 auto;
  padding: clamp(7px, 1vh, 11px) clamp(11px, 1.6vw, 18px);
  border: 1.5px dashed var(--muted-3);
  font-family: var(--f-mono);
  font-size: clamp(11px, .95vw, 12.5px);
  letter-spacing: .04em;
  color: var(--muted-2);
  font-variant-numeric: tabular-nums;
}

/* --- The modes -------------------------------------------------------- */

/* Two columns and a spine, the same geometry chapter 03's ledger uses, so the
   page has one way of showing two things side by side rather than two. */
.dx-road-board {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 1px minmax(0, 1fr);
  gap: clamp(20px, 3.2vw, 52px);
  align-items: start;
}

.dx-road-state {
  display: inline-flex;
  align-items: center;
  padding: 5px 11px;
  border: 1.5px solid var(--line);
  font-family: var(--f-stencil);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: .13em;
  text-transform: uppercase;
  color: var(--muted-2);
  margin-bottom: clamp(12px, 1.6vh, 18px);
}
.dx-road-col-next .dx-road-state {
  border-style: dashed;
  border-color: var(--muted-3);
}
.dx-road-state-live {
  border-color: var(--amber);
  background: rgba(232, 163, 23, .14);
  color: var(--amber);
}

.dx-road-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.dx-road-list li {
  position: relative;
  padding-left: 26px;
  font-family: var(--f-display);
  font-size: clamp(16px, 1.6vw, 21px);
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: .02em;
  text-transform: uppercase;
  color: var(--ink-soft);
}
.dx-road-list li + li { margin-top: clamp(12px, 1.8vh, 22px); }

/* The marker repeats the period strip's encoding: filled amber is running,
   dashed outline is intended. It is the only thing carrying that distinction
   inside a row, so it must not become decoration. */
.dx-road-list li::before {
  content: "";
  position: absolute;
  left: 0;
  top: .34em;
  width: 12px;
  height: 12px;
  background: var(--amber);
}
.dx-road-col-next .dx-road-list li::before {
  background: none;
  border: 1.5px dashed var(--muted-3);
}

/* One step down from the row it qualifies, not two: --muted-3 is the dashed
   line's colour and a line can afford to be nearly invisible where a sentence
   cannot. */
.dx-road-note {
  display: block;
  margin-top: 6px;
  font-family: var(--f-mono);
  font-size: 11px;
  font-weight: 400;
  line-height: 1.5;
  letter-spacing: .03em;
  text-transform: none;
  color: var(--muted-2);
}

/* Planned rows sit one step back from the live one. A step, not a fade: they
   are real entries a reader is meant to read, and greying them out would say
   "disabled" where the board only means "not yet". */
.dx-road-col-next .dx-road-list li { color: var(--muted); }

/* --- The reveal ------------------------------------------------------- */

/* Solid first, dashed after, so the reader takes in what exists before what is
   promised — the order the section is arguing for. */
.dx-solid {
  opacity: 0;
  transition: opacity .5s ease .1s;
}
.dx-road-stage.is-in .dx-solid { opacity: 1; }

.dx-planned {
  opacity: 0;
  transition: opacity .6s ease .75s;
}
.dx-road-stage.is-in .dx-planned { opacity: 1; }

/* ==========================================================================
   Narrow screens
   ==========================================================================

   `.section-fit` has already switched itself off below 1024px (components.css)
   — a stack of full-width copy and drawings does not fit one screen however
   tall the box is told to be. What is left here is turning the two-column
   chapters into stacks and giving the drawings room to stay readable. */

@media (max-width: 1023px) {
  .dx-split,
  .dx-split-rev {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(26px, 5vw, 40px);
  }
  .dx-split-rev > .dx-copy { order: 0; }
  .dx-split-rev > .dx-stage { order: 0; }
  .dx-copy { max-width: 34rem; }

  /* Chapter 01's drawing is the section's background, so with `.section-fit`
     switched off it has only the copy's own height to occupy — and a horizon
     with two lines of text over it is not a horizon. This is the one chapter
     that has to be told how tall to be down here.
     The copy stays at the top, where the empty sky is: `.dx-sky-fit` is doing
     that on its own, and this used to push it back down to `flex-end` from
     when the copy sat under the picture instead of in it. */
  .dx-sky { min-height: 80svh; }
  .dx-sky .section { display: flex; flex-direction: column; }
  /* The scene is cropped to a keyhole at this width, so the sky above the
     horizon is a narrow strip — but the header is still 69px of it, so this
     keeps the same floor the wide layout uses. */
  .dx-sky-fit { padding-top: 56px; }

  /* The ledger's two columns become one list. The rule between them is the
     only thing holding them apart, and a horizontal one here would read as a
     separator between two sections rather than as a spine. */
  .dx-ledger {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(22px, 4vw, 34px);
    max-width: 34rem;
  }
  .dx-led-rule { display: none; }
}

@media (max-width: 720px) {
  /* The premium chips are placed in per-cent of the slide but sized in px, so
     they stop shrinking with it long before the vehicle does. They are pinned
     at 14% and the body's back plate is at 30.9%, which leaves them 33% of the
     slide to fit in — about 110px at a 330px phone slide, which is what a chip
     measures at the clamp's floor. Stepping the type and the padding down here
     keeps the gap rather than betting on that last pixel. */
  .dx-show-gain-row {
    gap: 6px;
    padding: 4px 7px 4px 5px;
    font-size: 10px;
  }
  .dx-show-gain { gap: 6px; }

  /* The swatch row is pinned at the same 14% and is the wider of the two — four
     squares side by side against a stack of chips. Same treatment: step the
     gap down so the row keeps clear of the back plate at 30.9% rather than
     betting on the clamp's floor. */
  .dx-show-swatch { gap: 5px; }

  /* The queue goes under the elevation rather than beside it: at this width a
     14rem column beside a drawing leaves neither of them legible, and stacking
     it *above* handed the drawing's position to the list's height. Rows leave
     the flow as they are repaired (`.is-gone`), so the tank climbed ~145px up
     the section over one cycle and dropped back on reset — the one thing in
     this chapter that is supposed to hold still while the queue drains. Under
     it, the drawing's top is fixed and the list empties downwards.

     `order` rather than swapping the rows in the markup: the queue is the
     interactive half and keeps coming first in the DOM, for a keyboard as much
     as for lang-transition.js, which walks by position.

     The min-height floor on `.dx-q` stays with it. This rule used to take it
     away — a 168px box holding three rows that leave is 168px of reserved
     space, and above the drawing that space pushed the drawing down the screen
     for as long as it was empty. Below it, the same reservation costs nothing
     visible and is what keeps the section from closing up under the tank. */
  .dx-rep-stage { grid-template-columns: minmax(0, 1fr); }
  .dx-q-col { order: 2; }

  /* The CER handle is 38 CSS pixels tall and the ruler it sits on is drawn in
     viewBox units, so the drawing shrinks with the column and the handle does
     not. On a phone the ruler's own box is about 34px and a 38px thumb hangs
     out of the bottom of it, over the two end labels underneath. Stepping it
     down keeps the handle inside its drawing; it is still the full height of
     the ruler, which is what makes it grabbable. */
  .dx-cer-range {
    height: 26px;
    margin-top: -13px;
  }
  .dx-cer-range::-webkit-slider-runnable-track { height: 26px; }
  .dx-cer-range::-moz-range-track { height: 26px; }
  .dx-cer-range::-webkit-slider-thumb { height: 26px; }
  .dx-cer-range::-moz-range-thumb { height: 26px; }

  /* Pins are `nowrap` so a label never breaks across a diagram it is naming.
     On a phone that turns into a label wider than the drawing, and `.dx`
     clips overflow — so the roadmap's longest ("Coop & missions solo") lost
     its end rather than wrapping. Down here wrapping is the lesser harm. */
  .dx-pin {
    white-space: normal;
    max-width: 30vw;
    line-height: 1.25;
  }
  .dx-pin .dx-tag,
  .dx-tag { font-size: 10px; letter-spacing: .08em; }

  /* The roadmap board stacks, and the spine goes with it rather than becoming
     a horizontal rule: down here the two states are read one after the other,
     and a line between them would say "separator", not "spine". Each column
     still carries its own state chip, which is what actually distinguishes
     them, so nothing is lost. */
  .dx-road-board { grid-template-columns: minmax(0, 1fr); }
  .dx-road-board .dx-led-rule { display: none; }
  .dx-road-col-next { margin-top: clamp(22px, 4vh, 34px); }

  /* Five items on one line do not fit a phone, and the first thing to go is the
     connective tissue rather than a label. The three chips keep their reading
     order and wrap if they must; solid against dashed still says which period
     is the one you can play. */
  .dx-road-ext { display: none; }
  .dx-road-era {
    flex-wrap: wrap;
    gap: 10px;
  }
}

/* ==========================================================================
   Reduced motion
   ==========================================================================

   The house convention (site.css has three of these). Every chapter must end
   up at a finished, readable state — not an empty one. That means the reveal
   primitive stops hiding things, the two self-playing demos are frozen by
   diff.js at a composed frame rather than at frame zero, and the drawings that
   animate a dash to full length simply start there. */

@media (prefers-reduced-motion: reduce) {
  [data-reveal],
  [data-reveal].is-in {
    opacity: 1;
    transform: none;
    transition: none;
  }

  /* Chapter 03 needs nothing for its travel — diff.js leaves `--p` at the CSS
     default of 1, which is the parked, arrived, fully-labelled frame. What it
     does need is the two loops off: the suspension and the dust are keyed to
     `.is-rolling`, which is never added under this query, and this is the belt
     to that braces. */
  .dx-rel-move .dx-rel-hull,
  .dx-rel-dust ellipse {
    animation: none;
  }
  .dx-solid,
  .dx-planned {
    opacity: 1;
    transform: none;
    transition: none;
  }
  .dx-led-no dt::after { transform: scaleX(1); transition: none; }

  /* The carousel does not run: diff.js leaves it on the first slide and never
     starts a timer, though the rows still switch it on click. What is left here
     is each slide's composed frame — the paint fully on, the halo still, the
     bars full — and no cross-fade between them. */
  .dx-slide { transition: none; }
  /* The rig still changes pose on a click — that is a state, not an animation —
     but it gets there without the drive or the suspension. */
  .dx-show-rig { transition: none; }

  /* **Every selector below repeats the state class it is cancelling, and that
     is not verbosity.** `@media` adds no specificity of its own: a rule in here
     only beats the rule that started the animation if it is at least as
     specific, and chapter 03 starts all of its animations from `.dx-show.is-x
     .thing` or `.dx-slide.is-on .thing` — two classes before the target. A bare
     `.dx-show-wipe { animation: none }` is one, so it lost, and the paint wiped
     itself across the tank for a reader who had asked the whole machine to hold
     still. Four rules in this block were quietly doing nothing for that reason
     before 2026-08-19. Match the specificity, or the rule is decoration. */
  .dx-show.is-early .dx-show-rig .dx-rel-hull,
  .dx-show.is-skins .dx-show-rig .dx-rel-hull { animation: none; }
  .dx-show-coat,
  .dx-show-paint { transition: none; }
  .dx-show.is-skins .dx-show-wipe { animation: none; transform: translateX(990px); }

  /* The spray front and the sheen are both *travelling* light: there is no
     composed frame of either, only a bar parked somewhere across the tank. They
     are the two things in this chapter that are switched off outright rather
     than frozen at their end. */
  .dx-show.is-skins .dx-show-front,
  .dx-show.is-skins .dx-show-sheen { animation: none; opacity: 0; }
  .dx-show.is-skins .dx-show-sheen-run { animation: none; }

  /* Left visible, not hidden: the composed frame for this slide is the paint
     fully on, and paint with no shine on it is the frame *before* the one this
     slide settles at. Same argument for the swatch row and for the crown — each
     is what its state is *about*, so each ends up placed and full rather than
     absent.
     Held inside their own state as well as at their own specificity: the
     sparkles and the crown live in the rig, which every slide shares, so an
     unscoped `opacity: 1` here would leave a crown on the tank through skins
     and through the date line. */
  .dx-show.is-skins .dx-show-spark { animation: none; opacity: .9; }
  .dx-slide.is-on .dx-show-chip { animation: none; opacity: 1; }
  .dx-show.is-prem .dx-show-crown { animation: none; opacity: 1; }
  .dx-show.is-prem .dx-show-gleam { animation: none; opacity: .9; }
  .dx-slide.is-on .dx-show-gain-row { animation: none; }
  .dx-led-row,
  .dx-led-row dt,
  .dx-led-row dd { transition: none; }

  /* Chapter 01 needs nothing here. Its only animation is the sun's bloom, and
     that is switched off beside the rule that sets it — the scene is a still
     picture either way, which is the whole reason it survives this media query
     without a composed-frame fallback of its own. */

  .dx-hit.is-on { animation: none; opacity: 0; }
  .dx-q-row.is-done { transition: none; }
  .dx-mod path,
  .dx-mod circle,
  .dx-mod rect { transition: none; }
  /* The pulse goes, the state stays: amber, and still dashed, so the composed
     frame diff.js leaves behind here still reads as a repair in progress
     rather than as one that finished. */
  .dx-mod.is-fixing .dx-elev-part { animation: none; opacity: 1; }
}
