/*
  Homepage sections, in page order:

    1   hero            dark, full viewport, ambient globe
    2   process         grey, six stages in one strip — see process.css
    3   configurator    white, the interactive supply-chain map
    3b  ai story        white, the scroll-driven AI × human story
    3c  mission         grey, statement and paragraph over a dotted world map
    3d  network works   white, six network advantages, one drawing each
    7   platform        dark, full bleed, the software / expert-judgment diagram
    10  final CTA       dark, closing route motif

  4 (the library effect, three leverage diagrams), 5 (six benefit rows), 6 (a
  services carousel), 8 (the control band) and 9 (three commitments) were removed
  from the page. The numbers are left out rather than closed up, so the ones a
  reader already knows keep pointing at the same sections in build.mjs,
  content/copy.js and here.

  Sections carry their own `data-theme`, so the rules below reference only the
  semantic tokens from tokens.css and work under either.
*/

/* ═══ 0 · ONE LEFT EDGE ══════════════════════════════════════════════════ */

/*
  Every section below the hero starts where the hero headline starts — the footer
  included, since it is the last thing on the page and the step inward was most
  visible there, under a closing CTA that is flush left.

  `.shell` in base.css centres its 1240px measure inside the section's gutters,
  and the hero is only gutter-padded — so the two drift apart as the viewport
  grows: 36px at 1440 and 596px at 2560, which reads as the page stepping inward
  the moment the hero ends. Dropping the centring keeps the measure and puts one
  vertical line through the whole page.

  Homepage only. /manifesto loads manifesto.css instead and stays a centred
  editorial column — it aligns its own footer to that column.
*/
.section .shell,
.footer .shell {
  margin-inline: 0;
}

/* ═══ 1 · HERO ═══════════════════════════════════════════════════════════ */

/*
  Two layers, not two columns.

  The top layer is the proposition beside the globe — the copy column is a grid item
  and the globe is absolute, so the "column" on the right is the space the copy does
  not take. The bottom layer is the three steps, spanning the whole content box.

  `1fr auto` and not `auto auto`: the copy is centred in whatever is left once the
  strip has taken its height, so the strip sits on the bottom padding at every
  viewport and the block above it stays optically centred rather than drifting up as
  the strip grows. The bottom padding is the strip's own — the hero used to end on
  up to 116px of nothing, which is space the strip now occupies.
*/
.hero {
  position: relative;
  min-height: 100svh;
  display: grid;
  grid-template-rows: 1fr auto;
  align-items: center;
  padding: calc(var(--topbar-h) + 32px) var(--gutter) clamp(30px, 3.6vh, 36px);
  background: var(--bg);
  color: var(--fg);
  overflow: hidden;
  isolation: isolate;
}

/*
  The sky. Above the scrim rather than below it: the scrim is near-solid ink over
  the left half, so a field behind it would stop at an invisible vertical line —
  which is the whole complaint the sky was moved out of the globe scene to fix.

  Being above the scrim also puts it behind the headline, and that is fine at
  this size. The largest star is 1.6px at 0.65 alpha and most are half that,
  which reads as texture under 76px type, not as noise in it.
*/
.hero-sky {
  position: absolute;
  inset: 0;
  z-index: 3;
  width: 100%;
  height: 100%;
  fill: #dfe4f2;
  pointer-events: none;
}

/*
  The globe is framed off-centre by positioning its stage, not by offsetting the
  camera — globe.gl owns the camera for its orbit controls. Sized in vh on both
  axes so the sphere keeps a constant size relative to viewport height at any
  window width, with its centre off to the right so roughly a third of the sphere
  hangs off the edge of the window.

  ── Why 106vh and 43%, and not 128vh and 50% ────────────────────────────────

  A 128vh stage puts the sphere's lower limb at about 96% of the viewport height,
  which was fine while the hero ended in empty space and is not fine now that the
  three steps run along the bottom of it: the strip's right-hand columns sat on the
  brightest part of the limb. So the sphere is a sixth smaller and its centre is
  seven points of viewport height higher, which lifts the limb clear of the strip
  and leaves the breathing room under it that the old framing never had.

  The last five points off it are for the left limb rather than the bottom one. The
  paragraph reaches its widest at the narrow end of this layout, around 900px, and at
  112vh the lit edge of the sphere came to meet it; a smaller sphere on the same
  centre pulls that edge right, away from the copy.

  It stays the largest thing on the page by a wide margin — 17% off a sphere that is
  taller than the window is not a smaller globe, it is a globe that no longer touches
  either the bottom edge or the type.
*/
.globe-stage {
  position: absolute;
  top: 43%;
  left: 85vw;
  width: 106vh;
  height: 106vh;
  transform: translate(-50%, -50%);
  z-index: 1;
  cursor: grab;
  /*
    Feathers the edge of the canvas into the hero, and it is now insurance rather
    than a fix.

    It was a fix. js/globe.js asked globe.gl for a transparent background and did not
    get one: the UnrealBloom pass is not alpha-aware, so the composer returned an
    opaque frame, measured at rgb(1,1,1) against the hero's rgb(8,8,8). Seven units
    out of 255 is nothing as a colour and everything as a hard vertical line down a
    flat black field, which is what the stage's left edge was. With the bloom pass
    removed the frame composites transparent — rgba(0,0,0,0) read back off the drawing
    buffer — so there is no step left to hide.

    Kept anyway, because it costs nothing and covers the case where a pass goes back
    into the composer. Opaque out to 90% of the radius and gone by 100%, which at
    1440×900 puts the fade in the last ~50px of a 504px radius; the sphere is ~380px
    and nothing is drawn outside it any more, so the fade only ever touches empty
    frame.

    mix-blend-mode was the first attempt and does nothing here: this element has a
    `z-index`, which makes it an isolated group, so a blending canvas inside it
    composites against its own empty parent rather than against the hero.
  */
  mask-image: radial-gradient(closest-side, #000 90%, transparent 100%);
}

.globe-stage:active {
  cursor: grabbing;
}

/*
  The same idea along the top edge, for the bar.

  A bar with no ground of its own puts the nav straight onto the globe, and the
  globe's top limb is the brightest thing on the page — "WHY LIBRARY" was
  illegible against it. So the hero darkened under its own bar instead of the bar
  reaching for a background: one gradient here, rather than a text shadow on every
  label in the bar.

  Half of that job has moved. The bar now carries its own translucent ground and an
  18px blur (see `.topbar` in base.css), which is what makes the labels legible, and
  two full-strength tints stacked would read as an opaque black slab across the top
  of the hero — the one thing a glass bar is supposed not to be. So the stops come
  down to roughly 0.4 and the pair composite to about 0.68 behind the nav: the limb
  still goes dark, the stars still show through, and the bar still reads as
  something you are looking through rather than at.

  Below `.hero-sky`, which is the reason it is a separate layer from `::after`
  instead of another stop in it. The sky paints on top at full strength, so the
  stars run behind the contact strip and the nav while the limb underneath goes dark.

  Height is the bar plus a fade of about the same again. Ending abruptly at 92px
  would just trade a vertical edge for a horizontal one.
*/
.hero::before {
  content: '';
  position: absolute;
  inset: 0 0 auto;
  height: calc(var(--topbar-h) + 96px);
  z-index: 2;
  background: linear-gradient(
    180deg,
    rgba(8, 8, 8, 0.42) 0%,
    rgba(8, 8, 8, 0.36) 40%,
    rgba(8, 8, 8, 0.2) 72%,
    transparent 100%
  );
  pointer-events: none;
}

/*
  Fades the globe into black behind the headline so white type stays readable.

  Two passes, and they answer two different pieces of copy. The 100deg pass is the
  original one: near-solid ink down the left, clearing completely before the globe's
  outer halo so the scrim never cuts a visible vertical edge across it. The 180deg
  pass is for the three steps along the foot — they run the full width, so their
  right-hand columns are past where the diagonal has given up, and the only place
  left to darken is under them.

  Declared second so it composites over the diagonal, and it starts at 62% — below
  the headline block at every viewport, so nothing above the strip is dimmed by it.
  It is the same move as `::before` at the top of the section, in the same colour and
  for the same reason: type over the lit limb of a sphere is not readable, and one
  gradient here is cheaper than a shadow on every line of it.
*/
.hero::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  background:
    linear-gradient(
      180deg,
      transparent 62%,
      rgba(8, 8, 8, 0.62) 80%,
      rgba(8, 8, 8, 0.88) 92%
    ),
    linear-gradient(100deg, var(--ink) 15%, rgba(8, 8, 8, 0.72) 30%, transparent 49%);
  pointer-events: none;
}

.hero-copy {
  position: relative;
  z-index: 4;
  max-width: 700px;
}

.hero h1 {
  /*
    Below --t-display, which every other h1 and section heading on the site takes,
    and the only headline set off the scale — so the reduction is written here rather
    than in the token.

    20% under it at every width: 76px → 61px on a large desktop, and the same ratio
    on the fluid middle. At the full display size this headline was 40% of the height
    of the copy column and the first thing it did was crowd the paragraph under it;
    the aim is a headline that is still clearly the largest thing in the frame and no
    longer the only thing. Two lines on any desktop, three on a phone.

    The floor moves with it, and less far: 33px is what keeps the phone headline off a
    fourth line without the tracking closing up, and the 440px rule below still
    switches it to greedy wrapping there.
  */
  font-size: clamp(33px, 5vw, 61px);
  /*
    Width in em, not px: the font-size is fluid, so a px box lets the line breaks
    shift between viewports. In em the ratio holds. Retune if the headline copy
    changes.

    11em holds "intelligently managed" and cannot hold "Global sourcing,
    intelligently", which is what makes this a two-line headline breaking at its own
    comma without a <br> in the copy — see HERO.headline. The balanced wrap every
    heading takes in base.css does the rest.
  */
  max-width: 11em;
  /*
    The largest of the hero's copy gaps, and the only one that separates two
    different sizes of type rather than two blocks of the same size. The gaps below
    it get smaller as the type does, which is what makes four blocks in one column
    read in order instead of as one wall.
  */
  margin-bottom: 26px;
}

/*
  The two sentences that say what the company does, directly under the headline. 34em
  inside the 700px the column is: the first sentence names the span, the second names
  the three inputs, and the measure is tuned so each of them takes two lines. At 31em
  the second sentence ran to three and left a two-word last line under it.
*/
.hero-sub {
  font-size: var(--t-lead);
  line-height: 1.55;
  color: var(--fg-sub);
  max-width: 34em;
  margin-bottom: 22px;
}

/*
  What the buyer gets, in three statements, on one line.

  A row and not a display line. This was a single sentence in the display face at
  32px for a while, and as one line of three clauses it worked; as three separate
  statements it cannot be, because at that size the row is 900px wide and wraps
  into a second line of leftovers on any laptop. So the emphasis moves from size to
  weight and contrast: 600 in full --fg, one step above the paragraph it follows,
  set in the display face so it still belongs to the headline group rather than to
  body copy.

  Equal weight across all three on purpose — they are three parallel facts, and
  making one of them louder would turn the other two into its qualifiers. That
  includes the one with the number in it: "Up to 25% savings." is the largest claim
  of the three and setting it larger would read as a headline with two footnotes.

  The gap is the only separator. Each statement ends in a full stop and the space
  between them is wider than any word space in them, which is enough; a dot or a
  rule between the three would be a third kind of divider in a hero that already
  has one, and that one belongs to the steps at the foot.

  `flex-wrap` rather than a breakpoint: what forces the break is the copy against
  the column, so the answer should be too. The row gap only exists once wrapped.
*/
.hero-benefits {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 6px 30px;
  font-family: var(--font-display);
  font-size: clamp(17px, 1.35vw, 19.5px);
  font-weight: 600;
  line-height: 1.3;
  /* The same negative tracking every display size takes in base.css. */
  letter-spacing: -0.013em;
  color: var(--fg);
  margin-bottom: 26px;
}

/*
  Two buttons on one line, top-aligned rather than centred. Both are 54px, so on one
  line the alignment is academic; it matters once the pair wraps, where centring
  would indent the second button under the first.

  One gap in both directions now. It used to be 26px vertical against 14px
  horizontal, because the primary carried an annotation under it and an equal gap put
  that line as close to the wrong button as to its own. With the annotation gone
  there is nothing between the two but air, and 14px is the distance that reads as
  one pair of choices whether they sit side by side or stacked.
*/
.hero-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 14px;
}

/*
  The hero pair is the one place on the site where a button is not a control.

  Every other `.btn` is mono capitals — the bar's, on every screen of every page —
  and that is what a control looks like here. The bar's carries the same words as the
  primary below it now, which makes the pair the clearest case of the rule: one label,
  set as a control up there and as a sentence down here. These two sit one line
  under "Free to use. Up to 25% savings. Vetted suppliers." in the same face, and
  they are a sentence the reader is being asked to agree with, so they are set in
  the display sans, in sentence case, at reading size. In 12px mono capitals the
  offer read as a UI affordance.

  ── Two choices, ordered ────────────────────────────────────────────────────

  A filled slab and an outlined button of the same height, padding and radius:
  two real choices, one of them obviously first. The pair went through a phase as
  a button and an underlined link, on the argument that an outline beside a fill
  reads as two competing buttons. On this ground it does not. The primary is the
  only pure white area in the frame and the secondary is a hairline at 27% white —
  the fill is the hierarchy, and it does not need the second action demoted to a
  footnote to be read first.

  What that phase cost was the person who would rather talk to someone than fill
  in a form. That is not a footnote; it is the other half of how this company is
  hired.

  Both are the buyer's. The second used to be "Become a supplier", which asked a
  visitor to pick an audience before being offered anything; it lives in the footer.
*/
.hero-actions .btn {
  font-family: var(--font-display);
  font-size: var(--t-lead);
  font-weight: 600;
  text-transform: none;
  /* The same negative tracking every display size takes. */
  letter-spacing: -0.011em;
}

/*
  A blue slab with white copy. Taller and wider than a default button, because it is
  the one action the hero is asking for.

  It takes the near-square corner from `.btn` rather than overriding it: the shape
  is the same everywhere now, and the size is what makes this one primary.

  ── Blue, and the argument this reverses ────────────────────────────────────

  This was pure white with ink copy, and the note here defended that at length: the
  largest area of colour in the frame, on a screen whose whole argument is restraint,
  so a saturated fill was the loudest thing on it and ink-on-white bought 18:1 with no
  hue to compete with the globe. That reasoning still describes what a saturated fill
  does — it is simply not what this button is for. A hero's primary action is allowed
  to be the loudest thing in the hero; what it must not do is be loud somewhere the
  reader is trying to read.

  `--lot-blue`, the canonical accent, and not a marketing hex picked to sit near it.
  In the colour system blue means intelligence, selected, computed — which is exactly
  what is on the other side of this link: the estimator. So the hue carries the same
  meaning here as it does on a node on the globe two hundred pixels away, and the two
  are the same value from the same file.

  White on #2B8FC2 is 3.4:1, which is under AA for body text and over the 3:1 AA
  threshold for large text — this label is 19px at 600, which qualifies. It is a
  deliberate step down from the 18:1 the white slab had, and the reason it is
  acceptable here and would not be in a paragraph.

  ── What did NOT change ─────────────────────────────────────────────────────

  Height, padding, radius, type, weight, tracking, the arrow, the disc's size and
  position, the gap, the button's place in the row and the spacing around it. Only
  three declarations in this rule are new, and the pair below it is untouched, so the
  ghost button beside this one is still the same box with nothing in it.

  The bar's own "Start free brief" is white on black and stays that way: every rule in
  this section is scoped to `.hero-actions`, and the top bar's button is not in it.

  No border of its own. The 1px from `.btn` is set to the fill so it adds nothing:
  against the hero's near-black, the blue is its own edge. No gradient, no glow and no
  shadow — the fill is one flat colour.
*/
.hero-actions .btn--solid {
  min-height: 54px;
  /*
    Asymmetric, and the only asymmetric padding on a button here: the arrow below
    is a 32px disc, which carries its own optical margin, so as much padding on the
    right of it as the label has on its left would put twice the air on that side.

    Tight, and tighter than it was — 34 and 22, which came from matching the ghost
    button's own inset on the left. A filled slab and an outlined one do not need
    the same inset to look like a pair: the outline has a line where the fill has an
    edge, and the fill reads wider at the same measurement. At 34px the label sat in
    the middle of a white field with a disc adrift on the end of it. This is the
    label, its arrow, and the air they need.
  */
  padding: 0 16px 0 26px;
  /* Text to disc. Wider than a word space, narrower than the padding either side. */
  gap: 12px;
  background: var(--lot-blue);
  border-color: var(--lot-blue);
  color: var(--pure-white);
}

/*
  ── The arrow ────────────────────────────────────────────────────────────────

  A drawn arrow in a disc, inside the button rather than beside it. It was the ↗
  character in a `::after` first — the same move `.link::after` in base.css makes,
  and it needs no markup — but the weight of a glyph belongs to the font, and this
  face draws ↗ as a hairline. Beside a 600-weight label it read as a stray mark. The
  SVG in build.mjs is two paths at 1.9, which is the label's own stroke, and the
  arrow now balances the words instead of decorating them.

  16px of artwork in a 32px disc. The glyph it replaced was set at 15px and, since
  a font pads its own box, drew about 11px of ink — so this is both heavier and a
  little larger without the disc moving.

  Up-right, not right. A right arrow means "next": the same page, further along.
  This button leaves for /product-cost-estimator, and up-right is the arrow the web
  has agreed means that.

  The disc is black at 10% — a shade of the fill under it rather than a colour of its
  own, and nothing else: no border, no shadow, no second fill. It reads as a dent in
  the button rather than a control sitting on top of one, which is the whole difference
  between integrated and added beside. It was ink at 8% against the white fill this
  button used to have; darkening rather than lightening is what keeps it reading as a
  recess now that the fill is a mid-tone.

  Not orange, and not a second blue. Orange on this site means physical movement —
  routes, ships, the stage clock — and a lighter blue disc on a blue button would be
  two shades of one accent inside 32px. A neutral at 10% is the same disc it always
  was, doing the same job against a different fill.
*/
.hero-actions .btn-disc {
  /*
    Grid rather than flex for one child: `place-items: center` centres the artwork on
    both axes at once, and the SVG's own box is square, so the arrow sits on the
    disc's centre without a nudge.
  */
  display: grid;
  place-items: center;
  /* Fixed, not em-based: the disc is a shape, and it should not scale with a fluid
     label size the way the gap around it does. */
  width: 32px;
  height: 32px;
  flex: none;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.1);
  transition:
    background-color var(--dur-fast) var(--ease),
    transform var(--dur-fast) var(--ease);
}

/*
  The paths are stroked in `currentColor`, so the arrow takes the label's colour and
  crosses to white on the same transition the label does. Nothing here sets a colour
  of its own — one less rule to keep in step with the inversion below.
*/
.hero-actions .btn-disc svg {
  display: block;
}

/*
  Two and two, along the arrow's own diagonal — it moves the way it points, which is
  the only motion here that says anything. Any further and it leaves the disc; on a
  diagonal it is also worth about half of what a 3px nudge on one axis would read
  as, which is the right amount for a hover state on the page's largest white area.

  The global reduced-motion rule in base.css collapses the transition to 1ms, so
  under it the arrow is simply in its hover position with no travel.
*/
.hero-actions .btn--solid:hover .btn-disc,
.hero-actions .btn--solid:focus-visible .btn-disc {
  /* Four points deeper, not a different colour. It used to cross from ink at 8% to
     white at 14%, because the fill under it inverted from white to black at the same
     moment; the fill no longer inverts, so neither should this. */
  background: rgba(0, 0, 0, 0.14);
  transform: translate(2px, -2px);
}

/*
  Hover lightens the fill and changes nothing else — the label stays white, the border
  stays tied to the fill, and the arrow keeps its 2px diagonal travel on the same
  transition it always had.

  This replaces the inversion `.btn--solid:hover` makes everywhere else, where the fill
  empties out to the page's ground and the copy and a 1px outline take the colour the
  fill had. That move is still right for a white button — it is a stronger state change
  than a hue swap and it costs no meaning — and it is wrong for this one: emptying a blue
  slab out to black would drop the hero's primary action to a hairline outline at the
  moment the reader has committed to it.

  Still repeated here rather than inherited, and it has to be: the rest state above is
  `.hero-actions .btn--solid`, which ties the base rule's one class and one pseudo-class
  and then wins on being later in the cascade. Without this the button would not react.

  #329BCF is the one second shade of an accent in this file, and it is here because it
  was specified by value. `--lot-blue` at +4% HSL lightness, which is not reachable as an
  opacity of itself on this ground — the palette's usual pattern — so it is written out
  rather than mixed, and written out *here*, on one hover state, rather than promoted to a
  token where it could spread. If the two are ever to be one value, this is the line.

  No gradient, no glow, no shadow — the difference between rest and hover is 4% of
  lightness on one flat fill.
*/
.hero-actions .btn--solid:hover,
.hero-actions .btn--solid:focus-visible {
  background: #329bcf;
  border-color: #329bcf;
  color: var(--pure-white);
}

/*
  The same box as the primary, empty. Height, inline padding and radius are matched
  deliberately — that matching is what makes the two read as one control with two
  answers rather than as an action and a caption — and the only differences are the
  ones that order them: no fill, and a hairline border instead of an edge made of
  white.

  `--line-strong` is the border `.btn--ghost` already uses in base.css and on the
  hero's dark theme it is white at 27%. Enough to describe the shape, not enough to
  compete with a solid white slab beside it. The label keeps the pair's display sans
  at reading size from the rule above, so the two buttons speak in one voice.
*/
.hero-actions .btn--ghost {
  min-height: 54px;
  padding: 0 34px;
  border-color: var(--line-strong);
  color: var(--fg);
}

/*
  Hover fills it white and takes the copy to black — the same inversion everything
  else on the page makes. It used to land exactly on the primary's rest state, which was
  the neatest thing about it; the primary is blue now and this one is deliberately not
  following it there. A white fill is still the honest hover for an outlined button — it
  is the moment this becomes the action you chose — and two blue slabs side by side would
  undo the ordering the pair exists to state.
*/
.hero-actions .btn--ghost:hover,
.hero-actions .btn--ghost:focus-visible {
  background: var(--fg);
  border-color: var(--fg);
  color: var(--bg);
}

/*
  How the service runs, in three steps, along the whole foot of the hero.

  Full width, not the copy column's 700px: it is the second layer of the hero rather
  than the tail of the first, so it takes the same left and right edges as the
  headline above it and the wordmark in the bar — the hero's own gutters. z-index 4 is
  the copy column's, above both scrim layers and the sky.

  ── Why it is a grid of two rows and not three columns ──────────────────────

  The obvious build is three columns, each holding a title and a paragraph. It
  produces a wobble: "You send one brief." is one line and the other two titles are
  two, so one body starts 24px above its neighbours and the strip reads as three
  loose blocks rather than one row. The rows are declared on the list and each item
  takes them with `subgrid`, so every title sits in one shared band and every body
  starts on the same baseline whatever the titles do. Where subgrid is missing the
  items fall back to their own rows and the wobble returns — which is the layout
  without this rule, not a broken one.

  Row and column gaps are set separately because they do different jobs: the column
  gap is the space the hairlines float in, the row gap is the distance from a title
  to its own paragraph and has to stay tight enough that the two read as a pair.

  ── Why the dividers are pseudo-elements ────────────────────────────────────

  A `border-left` with padding beside it would make the first column wider than the
  other two by exactly that padding, and shift the second and third off the grid
  the eye is reading down. Centred in the gutter instead, all three keep the full
  track width and the first stays flush with the headline above it.

  The rule above the strip is the same hairline: what separates the three from each
  other should be what separates the set from the buttons, or the strip starts to
  look like a panel someone dropped into the hero.
*/
.hero-steps {
  position: relative;
  z-index: 4;
  list-style: none;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-template-rows: auto auto;
  column-gap: clamp(30px, 3.6vw, 56px);
  row-gap: 8px;
  /*
    The space above the rule scales with the viewport's height rather than its
    width, because what it is competing for is the fold: at 1440×900 the hero fits
    inside 100svh with nothing to spare, and a fixed 60px here is what pushes it
    past. The space under the rule does not — 32px is the distance that makes the
    hairline read as the top of a block rather than as the bottom of the buttons,
    and it is the same on every screen.
  */
  margin-top: clamp(34px, 4.4vh, 60px);
  padding-top: 32px;
  border-top: 1px solid var(--line);
}

.hero-steps li {
  position: relative;
  display: grid;
  grid-row: span 2;
  grid-template-rows: subgrid;
}

.hero-steps li + li::before {
  content: '';
  position: absolute;
  /* Half the column gap: on the centre line of the gutter, whatever it is. */
  left: calc(-0.5 * clamp(30px, 3.6vw, 56px));
  top: 1px;
  bottom: 2px;
  width: 1px;
  background: var(--line);
}

/*
  Semibold and in full --fg, at roughly the paragraph's size rather than a heading's.
  These are the loudest thing in the strip and the quietest thing in the hero, which
  is the whole of their job: findable at a glance by someone scanning for how this
  works, and invisible to someone reading the headline.

  Not an <h2> — see build.mjs. In the display face, because every other line in this
  column that carries weight is.
*/
.hero-step-title {
  font-family: var(--font-display);
  font-size: clamp(17px, 1.35vw, 20px);
  font-weight: 600;
  line-height: 1.25;
  letter-spacing: -0.011em;
  color: var(--fg);
}

/*
  Just under body size, and at 1.5 rather than the hero paragraph's 1.55: a third of
  the hero's width sets these as two lines, which is short enough to read as a
  caption to the title above it and not as a second paragraph. Set in --fg-sub, one
  step down from the titles, so the strip has its own internal order and does not
  need to borrow contrast from the hero to get it.

  The measure is capped at 30em. Past about 1600px the third of the page each column
  gets is wider than a line of 15px type should ever be, and without the cap the
  three sentences turn into three single lines running the width of the screen.
*/
.hero-step-body {
  max-width: 30em;
  font-size: clamp(14.5px, 1.05vw, 16px);
  line-height: 1.5;
  color: var(--fg-sub);
}

/* ── Hub markers (CSS2D elements injected by js/globe.js) ──────────────── */

/*
  globe.gl centres each element on the projected point, so the wrapper is
  zero-sized and every part is placed relative to that anchor.
*/
/*
  Every hub is identical apart from where it is, what it is called and whether it pulses.
  There is no colour in this block and there is nothing below that can add one.

  ── What was here, and why it is gone ──────────────────────────────────────

  A `--hub-state` custom property, bound by five `[data-location-state]` selectors, drawn
  as a coloured ring around the white core. The idea was sound on paper — a location keeps
  its white core so it never stops being findable as a location, and the ring reports the
  condition it is in — and it does not survive contact with a viewer. A 2px ring around a
  12px dot, on a sphere that is rotating, at the size the hero draws it, has no room to say
  *which* of six conditions it means. So eighteen ports wearing five hues read as
  decoration, and the feedback was precise about it: the nodes looked like they were
  getting random coloured rings. That was the correct reading of what was on screen.

  Three versions of this failure, worth keeping because they cover the whole space. Every
  marker hot orange: a permanent 36-node alarm, leaving the one genuinely moving lane
  nothing to be louder than. Then eighteen of thirty-six taking a state as their whole
  fill, which is the same failure wearing five colours — a coloured dot reads as a
  different *kind* of place, not as a place in a condition. Then this, the ring, which was
  the most careful of the three and still failed, because the problem was never the
  execution. It was the object. A place is not an event.

  Colour moved to the arcs, which are between two places, are therefore about something
  happening by construction, and are long enough to carry a hue and a direction at once.
  The location vocabulary itself is intact in js/color-system.js and styles/tokens.css,
  for the portal tables where a chip sits next to a label that says what it means.
*/
.hub {
  position: relative;
  width: 0;
  height: 0;
  pointer-events: none;
  font-family: var(--font-mono);
  transition: opacity 0.35s ease;
}

/*
  Two independent reasons to hide: the far side of the sphere, and being in a
  place the hero needs back — a label sliced by the window edge (the stage
  deliberately overhangs it), or a hub sitting up under the transparent top bar.
  Each reason sets its own flag and CSS resolves them; writing style.opacity from
  both would let them fight.
*/
.hub[data-far='true'],
.hub[data-clipped='true'] {
  opacity: 0;
}

/*
  The white core. Twelve pixels, solid `--lot-white`, every hub, no exception — including
  the hub the strip is talking about, which used to be 9px against the others' 7px. One
  size means one kind of object, and a reader who has learned that a white dot is a
  location does not have to re-learn it per node.

  12 rather than 10, and one halo rather than three shadow layers. Both come out of the
  same change. The three layers existed because box-shadow only interpolates between
  lists of equal length, so a state ring had to be able to fade in over half a second
  rather than snap — two of the three were transparent placeholders held open for a ring
  that no longer exists. With nothing to transition to, the layer count and the transition
  both go, and what is left is the object as it should have been drawn: a white core with
  one soft neutral halo behind it.

    core   12px, --lot-white
    halo   out to 22px, rgba(245,245,242,0.14)

  A 5px spread of white at 14%, which on this page composites to about #262625 — visible
  as a presence around the dot and unreadable as a ring, which is the distinction. Not a
  blur: `box-shadow` with no blur radius is a hard-edged translucent disc, and the
  difference between that and a 4px blur is the difference between an operating system and
  a cyber-security visualisation. There was a two-stop radial gradient into the signal
  orange under exactly such a blur here once, and before that a fill that took the state's
  colour.

  The node is the brightest thing on the globe by a wide margin — 21:1 against the page,
  against land that composites to about 5.5:1 — and that ratio is the entire reason the
  network reads on top of the world rather than in it.
*/
.hub-dot {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(-50%, -50%);
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--location-default);
  box-shadow: 0 0 0 5px color-mix(in srgb, var(--lot-white) 14%, transparent);
}

/*
  Neutral, and specified as such: `--lot-white` on `--lot-grey-900` at 92%. No accent, no
  coloured border, no coloured background — a label says where you are, and nothing else
  on this globe is allowed to say it either.

  The 8% of transparency is the only change here and it is doing one small job: the label
  is a solid block of the darkest grey in the system sitting on a near-black page, so
  fully opaque it reads as a hole punched in the globe wherever it overlaps the sphere.
  At 92% the continents behind it stay faintly present and the block reads as a caption
  laid over the world rather than as a gap in it. Contrast against the label's own
  background is unaffected at any practical depth — 20:1 over the page, 19:1 over land.
*/
.hub-label {
  position: absolute;
  left: 0;
  bottom: 10px;
  transform: translateX(-50%);
  padding: 3px 7px 2px;
  background: color-mix(in srgb, var(--lot-grey-900) 92%, transparent);
  color: var(--lot-white);
  font-size: 10px;
  letter-spacing: 0.08em;
  white-space: nowrap;
}

/*
  White, and now unconditionally — it used to fall back to white through
  `var(--hub-state, …)` and take a node's accent when it had one, which is a thing no node
  has any more.

  Motion is not colour, and that is why this survived the pass that removed the rings: a
  ring expanding off a location is the globe being alive, and it says nothing about status,
  so it is available to a node with no state at all — which is every node. 0.6 rather than
  0.85 because white carries about a third more luminance than the orange it was drawn for.
*/
.hub-ring {
  position: absolute;
  left: 0;
  top: 0;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--lot-white) 60%, transparent);
  animation: hub-pulse 3s cubic-bezier(0.2, 0.6, 0.3, 1) infinite;
}

@keyframes hub-pulse {
  0% {
    transform: translate(-50%, -50%) scale(0.3);
    opacity: 0.9;
  }
  100% {
    transform: translate(-50%, -50%) scale(1);
    opacity: 0;
  }
}

/*
  Unhidden by js/main.js when WebGL or the land mask is unavailable. It sits in the
  hero copy column rather than in the stage — see the note in build.mjs — so it is
  a line of text under the buttons, not an overlay, and it needs nothing but a
  measure and a quieter colour.
*/
.globe-error {
  max-width: var(--measure-tight);
  margin-top: 26px;
  font-size: var(--t-small);
  line-height: 1.7;
  /* --fg-soft, not --fg-faint: this is a sentence to be read, not a label. */
  color: var(--fg-soft);
}

/* ═══ 3 · CONFIGURATOR ═══════════════════════════════════════════════════ */

/*
  Map first.

  The two controls read as one sentence above the map — "optimize for landed
  cost, freight by sea" — the map answers it across the whole width of the page,
  the stepper under it says which of the six stages is playing, and that is the
  whole section. There is no text column beside the map: a paragraph next to a
  moving diagram is read instead of the diagram, and the diagram is the argument
  here.
*/

/*
  The one section that does not keep the 1240px measure.

  The map is the argument, and 1240px of it on a 1728px screen is a thumbnail of
  itself with 460px of empty page to its right — the story stage two sections down
  reaches the window edges, which is the width this module is competing with. So
  the shell runs from the left gutter to the right one: same left edge as the hero
  headline and every other section, roughly 93% of the window instead of 72%.

  Only the frame grows. The copy above it keeps its own measure from `.head h2`
  (18em) and `.lead` (--measure), so widening the panel does not widen a sentence.
*/
.config > .shell {
  max-width: none;
}

/*
  ── The section's own palette ────────────────────────────────────────────────

  This module is an instrument, and an instrument is read by state rather than by
  decoration. So the five brand accents keep one meaning each for the whole of it
  and nothing else in the panel is allowed to be coloured at all:

    blue    #2B8FC2  AI, active analysis, computation, routing, system progress
    green   #087F68  selected, approved, verified
    yellow  #FFB640  candidate, shortlist, pending
    orange  #FF571F  physical execution — production and movement, nothing else
    red     #F42B2F  exception, issue, risk. ONLY that. Never "this is active".

  The accents are the tokens from styles/tokens.css unchanged; what is local here
  is the *neutral* set, because the page's greys are cool (`--slate` #616161,
  `--line-strong` #c9c9c9) and this panel sits on a warm off-white map. A cool
  hairline on a warm ground reads as blue-grey at 1px, which is exactly the tone
  the accent owns.

  Aliased rather than used raw, so a rule below says what a colour *means* and the
  five lines above are the only place the meaning is stated. The ratio the whole
  section is judged on — roughly nine parts neutral to one part accent, and at most
  two accents in any one frame — is only checkable if the accents are countable.
*/
.config {
  --cfg-blue: var(--lot-blue);
  --cfg-green: var(--lot-green);
  --cfg-yellow: var(--lot-yellow);
  --cfg-orange: var(--lot-orange);
  --cfg-red: var(--lot-red);

  /* Ground, structure, and the three levels of type. */
  --cfg-ui-bg: #fafaf8;
  --cfg-border: #d8d8d4;
  --cfg-ink: #212426;
  --cfg-text-2: #6f6f6a;
  --cfg-text-3: #969690;

  /* The map: the land grid, and a partner nobody has judged yet. */
  --cfg-map-dots: #c7c3ba;
  --cfg-node: #777773;
  /* One step lighter than the primary, for the city under a partner's name. */
  --cfg-place: #8a8a86;

  /*
    Yellow as ink, as distinct from yellow as a marker.

    #FFB640 is the shortlist colour and every dot, ring and rule keeps it exactly.
    As 10px type on white it measures 1.8:1 — not dim, illegible. The one string
    that has to wear it is the shortlist count in the funnel, where the whole point
    is that the four numbers are read as a hierarchy, so it is darkened until it can
    be read and no further: this lands at 4.6:1 and still reads as the same amber as
    the dots it is counting.
  */
  --cfg-ink-yellow: color-mix(in oklab, var(--cfg-yellow), #5e3d00 62%);
}

.config-intro {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 16px clamp(24px, 3vw, 56px);
  align-items: start;
}

/*
  Tighter than the standing section head, because what follows is not prose: the
  panel has its own border and its own top margin, and the default head margin
  stacked on top of those read as a hole between the sentence and the thing the
  sentence introduces.
*/
.config-intro .head {
  margin-bottom: clamp(4px, 0.8vw, 12px);
}

/* ── The panel ─────────────────────────────────────────────────────────── */

/*
  One bordered panel holding the controls, the six stages and the map, sized to
  what is left of the viewport under the sticky header.

  Height rather than an aspect ratio, and the viewport rather than the width: the
  three parts only work together if they are on screen together — a timeline you
  have to scroll to and a map you have to scroll back to are two half-visible
  things instead of one whole one. So the panel is told how much room it has and
  the map takes whatever the two fixed rows leave, which is what `minmax(0, 1fr)`
  is doing: without the 0 the canvas's own size would push the panel taller than
  the screen it was measured against.

  The bounds are the two ends of usefulness. Under 600px the map cannot hold a
  corridor and a regional close-up; over 860px it is a mural, and the stage
  strip has drifted a screen away from the map it belongs to.

  js/supply-map.js sets data-mode="map" only once WebGL is up and the first
  configuration is drawn. Until then none of this is rendered: an empty bordered
  box with four dead buttons in it would be worse than no box.
*/
.config-experience {
  /*
    `--bar-h` is the bar as measured, published by js/nav.js; `--topbar-h` is the
    bar as designed. Fitting under the bar needs the former — on a phone the
    utility strip wraps and the real bar runs 34px past the token, which is 34px
    of panel below the fold. The token is the fallback for the frame before the
    measurement lands.
  */
  height: calc(100svh - var(--bar-h, var(--topbar-h)) - 24px);
  /*
    The floor is a compromise between two ways of being unusable. A laptop window
    577px tall has 445px under the bar, and forcing 600 into it pushes a third of
    the panel — most of the map — past the bottom of the screen. But a map is not
    much use flat either: below about 380px the stage is nearly four times wider
    than it is tall and every framing widens into a band of ocean. At 520 a short
    window scrolls 75px to reach the whole panel, and the map it finds there has
    roughly the proportions it has on a large screen.
  */
  min-height: 520px;
  max-height: 860px;
  margin-top: clamp(26px, 2.8vw, 40px);
  display: grid;
  /*
    The column is spelled out for the same reason the last row is: `auto` on either
    axis means "at least min-content", and min-content here is six stage cells that
    refuse to be narrower than a place name. On a phone that sized the single
    column to 815px inside a 344px panel and quietly clipped every row — the map
    drew at 815 and the callout was placed 300px off the right edge of the visible
    frame. `minmax(0, 1fr)` lets the rows be as narrow as the panel and hands the
    stage strip back to its own horizontal scroller.
  */
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: auto auto minmax(0, 1fr);
  border: 1px solid var(--cfg-border);
  border-radius: var(--r);
  background: var(--bg-raised);
  overflow: hidden;
}

.config:not([data-mode='map']) .config-experience {
  display: none;
}

/* ── The two controls ──────────────────────────────────────────────────── */

/*
  Row one: the sentence the map answers — "optimize for landed cost, freight by
  sea". Inside the panel rather than above it, because a control that lives
  outside the frame it changes reads as a page filter.
*/
.config-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 11px clamp(20px, 2.4vw, 40px);
  padding: 13px clamp(14px, 1.5vw, 20px) 12px;
  border-bottom: 1px solid var(--cfg-border);
}

.config-control {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 14px;
}

/* Freight is the second, smaller decision, so it sits at the far end. */
.config-control--freight {
  margin-left: auto;
}

.config-control-label {
  font-family: var(--font-mono);
  font-size: var(--t-mono-sm);
  text-transform: uppercase;
  letter-spacing: 0.09em;
  color: var(--cfg-node);
}

.config-pills {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/*
  Selected reads as graphite, never as an accent. Orange means physical movement
  and blue means the system computing, both of them on the map a metre away, and
  spending either on "this button is pressed" would make the legend a lie.

  Neutral at rest, filled when selected — rather than five tabs at full contrast
  with one of them filled black. Five objectives at the same weight are five
  equally loud claims and the eye has to hunt for the live one; holding the
  unselected four at #777773 leaves exactly one filled chip in the row.
*/
.config-pill,
.config-mode {
  border: 0;
  background: none;
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--cfg-node);
  cursor: pointer;
  transition:
    color var(--dur-fast) var(--ease),
    background-color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease);
}

/*
  Almost square rather than pills. These are the controls the section is operated
  by, and a field in a toolbar is a different object from a tag: a row of pills
  reads as filters you may have applied, a row of near-square buttons reads as a
  switch you are currently inside. 4px against a 1px border is enough that the
  corner does not look cut, and not enough to register as a curve.
*/
.config-pill {
  padding: 8px 16px 7px;
  border: 1px solid var(--cfg-border);
  border-radius: var(--r-xs);
}

.config-pill:hover {
  color: var(--cfg-ink);
  border-color: var(--cfg-node);
}

/*
  The selected objective is a grey chip that fills as its run plays.

  It used to be a filled graphite chip with a 2px blue rule riding its bottom edge:
  the chip said which objective, the rule said how far through. Two marks for one
  state, and the thinner of them was carrying the information — a 2px line at the
  foot of a 33px chip is the sort of progress you find after you have gone looking
  for it, and the map plays for the better part of a minute per objective, so how
  far through is exactly what a visitor wants and cannot get from the chip.

  So the chip *is* the clock. Grey at the moment it is selected, and graphite
  sweeping across it left to right over the run, which means the row of controls
  answers both questions at a glance and with one mark: which objective is playing,
  and how much of it is left.

  Grey rather than black, and the text stays dark ink, because a fill that crosses
  the label has to work under the whole of it — white type would be legible on the
  played side and invisible on the unplayed side, which is the failure mode a
  half-filled button invites. So the two greys are both light: the unplayed ground
  a shade off the chip's own border, the played fill a step darker than that, both
  chosen to keep #212426 above 7:1 either side of the boundary. And still no
  accent: orange means physical movement on the map a metre away and blue means the
  system computing, and spending either on "this button is pressed" would make that
  legend a lie.

  The fill is a scaled layer, not a `background-size`, and that is the difference
  between a clock and a ratchet. A percentage background is painted, so its edge
  lands on a whole device pixel: over a forty-second run across a 130px chip the
  boundary sat still for a third of a second and then jumped a pixel, three times a
  second, for the whole run. A `transform` is composited instead — the edge is
  interpolated and antialiased between pixels, so the same clock reads as one
  continuous sweep. `--played` carries four decimals for the same reason: at three
  the value itself only changed every other frame.

  The 120ms linear transition is the second half of it. `--played` comes from the
  map's own requestAnimationFrame loop, which shares a thread with a WebGL scene
  that occasionally misses a frame; a linear transition on a composited transform
  keeps moving through the gap. Linear, and short, because it is a clock: an ease
  would make the fill speed up and slow down against time it is meant to be
  reporting.

  The fallback of 1 is load-bearing: with no map there is no clock, and the selected
  tab should simply look selected — which here means fully filled, the state a
  finished run also arrives at.
*/
.config-pill[aria-selected='true'] {
  position: relative;
  /*
    The chip's own corners clip the fill, so the sweep needs no radius of its own —
    a scaled rounded rectangle would stretch its corners into ellipses, and the
    right-hand edge of a fill that is halfway across a chip should not be round at
    all. `outline` is not clipped by `overflow`, so the focus ring survives.
  */
  overflow: hidden;
  /* So the fill can sit behind the label and still above the chip's own ground. */
  isolation: isolate;
  border-color: var(--cfg-node);
  color: var(--cfg-ink);
  background-color: #e9e9e5;
}

.config-pill[aria-selected='true']::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background: #bcbcb5;
  transform: scaleX(var(--played, 1));
  transform-origin: left center;
  transition: transform 120ms linear;
}

/* One segmented control, because sea and air are two halves of one choice. */
.config-modes {
  display: inline-flex;
  border: 1px solid var(--cfg-border);
  border-radius: var(--r-xs);
  overflow: hidden;
}

.config-mode {
  padding: 8px 19px 7px;
}

.config-mode + .config-mode {
  border-left: 1px solid var(--cfg-border);
}

.config-mode:hover {
  color: var(--cfg-ink);
}

/*
  Filled, not filling: freight is a standing choice rather than something that
  plays, so it takes the objective chip's graphite ground and none of its clock.
  The two controls then read as one language — this is the one that is on — and
  neither of them has spent a colour.
*/
.config-mode[aria-selected='true'] {
  background: var(--cfg-ink);
  color: var(--lot-white);
}

/* ── The stage ─────────────────────────────────────────────────────────── */

/*
  Row three: the map. It has no size of its own — it takes what the toolbar and
  the stage strip leave, and `min-height: 0` is what allows it to shrink to that
  rather than pushing the panel taller.
*/
/*
  `clip`, not `hidden`. Markers are placed at their projected coordinates whether
  or not those land on screen, so the marker layer is thousands of pixels wide at a
  regional framing — and `hidden` would make this a scroll container holding all of
  it, one stray scrollIntoView away from sliding the map sideways. `clip` cuts it
  off without ever being scrollable.
*/
.config-stage {
  position: relative;
  min-height: 0;
  overflow: clip;
}

.config-stage canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* ── The callout ───────────────────────────────────────────────────────── */

/*
  The sentence for the stage that is playing, on the end of a line, attached to
  the place it is about.

  There is no corner card. Copy parked in the top left is a caption on a picture;
  the same words beside the cluster being searched are a label on a thing, and
  which of the two it is decides whether the map gets read or skipped.

  Off-white on the map's white, one hairline, no shadow and no blur: it sits on
  the map without pretending to float above it, and a card that is legible
  because of its edge rather than because of a gradient stays legible over a
  field of dots. Position comes from js/supply-map.js, which keeps it inside the
  frame, off the node it points at, and out of the way of the labels.
*/
.config-callout {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 4;
  /*
    Wide enough for two lines of body copy rather than four of small print. This is
    the only sentence on the map and the map runs on its own — it is read once, at
    whatever distance the visitor happens to be sitting, or not at all.
  */
  max-width: 366px;
  display: grid;
  gap: 6px;
  padding: 13px 16px 14px;
  /*
    White, one neutral hairline, 8px, and no shadow — whatever the card is about.
    A card that borrows the colour of its subject turns every frame into two
    coloured objects arguing about which is the accent; the subject is on the map,
    and the card is the caption. Its only accent is the 5px status dot below.
  */
  border: 1px solid var(--cfg-border);
  border-radius: 8px;
  background: #fff;
  box-shadow: none;
  opacity: 0;
  transition:
    opacity var(--dur) var(--ease),
    border-color var(--dur) var(--ease);
  pointer-events: none;
}

.config-callout[data-visible='true'] {
  opacity: 1;
}

/* The machine layer of the card: which stage this is. Mono, small, neutral. */
.config-callout-stage {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--cfg-node);
}

/* The human layer: the one sentence. Graphite rather than ink — it is a caption on
   a white card, and #050505 at 15px on white is louder than the map it explains. */
.config-callout-text {
  font-size: 15px;
  line-height: 1.4;
  color: var(--cfg-ink);
  text-wrap: pretty;
}

/*
  When the card is about a population rather than a place: a radar sweeping out of
  its edge, saying that the sentence covers everything currently being compared.

  Blue, because what it is drawing is the system searching — the same blue as the
  links running out to the candidates and the ring passing over them, so the three
  read as one operation seen from three distances. It used to be orange and to warm
  the card's hairline with it, which made a card about *analysis* look like a card
  about freight, and gave the frame a second coloured object to reconcile.

  The hairline stays neutral. A ring leaving an edge is enough to tie the card to
  the population; a tinted border on top of it is the same claim twice.
*/
.config-callout-radar {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
}

.config-callout[data-radar='true'] .config-callout-radar::before,
.config-callout[data-radar='true'] .config-callout-radar::after {
  content: '';
  position: absolute;
  border: 1px solid var(--cfg-blue);
  border-radius: 15px;
  animation: callout-radar 2.6s var(--ease) infinite;
}

.config-callout[data-radar='true'] .config-callout-radar::after {
  animation-delay: 1.3s;
}

@keyframes callout-radar {
  from {
    inset: -2px;
    opacity: 0.5;
  }
  to {
    inset: -15px;
    opacity: 0;
  }
}

/*
  The moment inside a stage: negotiation, assembly, final QC, clearance, or a
  person signing a decision off. A rule above it so it reads as a stamp on the
  sentence rather than as a third line of it, and a 5px dot whose colour is the
  one accent the card is allowed — see the five states under it.
*/
/*
  `hidden` needs help here: the attribute's `display: none` comes from the user
  agent stylesheet, and any author `display` on the same element outranks it.
*/
.config-callout-status[hidden] {
  display: none;
}

.config-callout-status {
  display: flex;
  align-items: center;
  gap: 7px;
  margin-top: 2px;
  padding-top: 8px;
  border-top: 1px solid var(--cfg-border);
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--cfg-text-2);
}

.config-callout-status::before {
  content: '';
  flex: none;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--cfg-node);
}

/*
  The five moments a stage can be in, as the one dot on the card.

  `data-status` is the phase's own id, written by js/supply-map.js — so the dot is
  keyed to the thing that is happening rather than to a string that could be
  reworded. The split is the section's whole colour rule in miniature: two of these
  are the platform computing or a person judging, three of them are the order
  physically being made and moved.
*/
.config-callout[data-status='negotiate'] .config-callout-status::before,
.config-callout[data-status='assemble'] .config-callout-status::before,
.config-callout[data-status='arrive'] .config-callout-status::before {
  background: var(--cfg-orange);
}

.config-callout[data-status='validate'] .config-callout-status::before,
.config-callout[data-status='qc'] .config-callout-status::before {
  background: var(--cfg-green);
}

/* ── The comparison panel ──────────────────────────────────────────────── */

/*
  The part of the work that cannot be drawn as dots: what each candidate came back
  with, side by side.

  Bottom left of the stage, which is open water under every objective at the network
  framing, and only while a search is running. It is a funnel first — forty-two, then
  twenty-one, then six — and a table of figures once those six have quoted, because
  those are two different questions and showing both at once answers neither.
*/
.config-compare {
  position: absolute;
  left: 14px;
  bottom: 14px;
  z-index: 4;
  width: 366px;
  display: grid;
  gap: 9px;
  padding: 11px 13px 12px;
  /*
    Very nearly opaque rather than opaque: the table sits over the land grid and a
    hairline of the map showing through its ground is what keeps it reading as an
    instrument over a map instead of a dialog in front of one. No shadow — the
    hairline does the lifting, and a shadow at this size only greys the dots under
    the edge.
  */
  border: 1px solid var(--cfg-border);
  border-radius: 8px;
  background: rgb(255 255 255 / 96%);
  box-shadow: none;
  opacity: 0;
  transition: opacity var(--dur) var(--ease);
  pointer-events: none;
}

.config-compare[data-visible='true'] {
  opacity: 1;
}

.config-compare-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--cfg-node);
}

/*
  The reduction itself — 42 → 21 → 12 → 6 — and the four numbers are not four
  equal facts.

  Coloured all one way it read as a decorative counter. The first two are the
  field, which is a quantity and belongs in grey. The third is the shortlist, which
  is a set of candidates. The fourth is the set the decision is made from: blue
  while the platform is still working through it, green the moment one of them is
  chosen. So the funnel says what happened to the network as well as how many
  survived, and the last number changing colour is the same event as the node on
  the map turning green.
*/
.config-compare-funnel {
  flex: none;
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
  /* Secondary rather than tertiary: these two are still numbers the reader is meant
     to read. The tertiary grey measures 2.97:1 on this ground, which is fine for a
     step number sitting above a legible step name and not fine for 11px data. */
  color: var(--cfg-text-2);
}

.config-compare-funnel b {
  font-weight: inherit;
}

.config-compare-funnel .is-short {
  color: var(--cfg-ink-yellow);
}

.config-compare-funnel .is-live {
  color: var(--cfg-blue);
}

.config-compare-funnel .is-picked {
  color: var(--cfg-green);
}

.config-compare-ladder {
  display: grid;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/*
  A rung the search has not reached yet is present but empty — the questions are
  known in advance, and showing them arrive one at a time is what makes the count
  above them mean something.
*/
.config-compare-ladder li {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-size: var(--t-small);
  line-height: 1.3;
  color: var(--cfg-ink);
  transition:
    opacity var(--dur) var(--ease),
    color var(--dur) var(--ease);
}

.config-compare-ladder li[data-on='false'] {
  opacity: 0.42;
}

.config-compare-n {
  flex: none;
  min-width: 30px;
  text-align: right;
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-variant-numeric: tabular-nums;
  color: var(--cfg-ink);
}

.config-compare-grid {
  display: grid;
  gap: 2px;
}

/*
  One row per shortlisted candidate, one column per axis. Mono and tabular, because
  the whole point of the panel is that the columns can be read down.
*/
.config-compare-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 26px 24px 26px 56px 28px;
  align-items: center;
  gap: 5px;
  padding: 3px 5px;
  border-radius: 5px;
  /* A 2px left rule is what marks the winner, so every other row reserves the
     same space and the table does not shift sideways when one arrives. */
  border-left: 2px solid transparent;
  font-family: var(--font-mono);
  font-size: var(--t-mono-sm);
  color: var(--cfg-ink);
  font-variant-numeric: tabular-nums;
  transition: background var(--dur) var(--ease);
}

/*
  The header sits a size down from the figures it names. The columns are as narrow
  as three ticks and a two-digit index allow, and an uppercase label at the row's
  own size runs past them into its neighbour.
*/
.config-compare-row[data-head='true'] {
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--cfg-text-2);
}

/*
  The axis this objective is decided on, flagged the whole way down the column.

  Blue: which axis decides is a property of the objective the platform was given,
  not of any candidate — it is the computation's own choice of column, and it is
  the same blue as the search running over the map. It was orange, which said the
  goods were somehow in the price column.
*/
.config-compare-row > [data-decides='true'] {
  border-radius: 3px;
  background: color-mix(in srgb, var(--cfg-blue) 9%, transparent);
}

.config-compare-row[data-head='true'] > [data-decides='true'] {
  background: none;
  color: var(--cfg-blue);
}

.config-compare-name {
  display: flex;
  align-items: baseline;
  gap: 5px;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
}

.config-compare-name em {
  flex: none;
  font-style: normal;
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--cfg-text-2);
}

/* The winner's tag takes the place of its role, which the row no longer needs.
   Green: SELECTED is the one word in this panel that means a decision was made. */
.config-compare-name b {
  flex: none;
  font-weight: 400;
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--cfg-green);
}

.config-compare-row[data-selected='true'] .config-compare-name em {
  display: none;
}

/*
  The chosen row: a 2px green rule on its left edge and a 5% green wash behind it.

  It used to be a full orange outline, which made the answer to the whole panel
  look like a warning — and put a second full-strength coloured box in a frame that
  already had the winning node ringed on the map. A left rule marks a row without
  drawing a box, and the wash is weak enough that the mono figures on top of it
  keep their contrast.
*/
.config-compare-row[data-selected='true'] {
  border-left-color: var(--cfg-green);
  background: color-mix(in srgb, var(--cfg-green) 5%, transparent);
}

/*
  Three ticks rather than a number out of a hundred. The section makes no claim to
  measure quality to the percentage point, and a bar chart of invented precision is
  the fastest way to lose a reader who knows the trade.
*/
.config-compare-bars {
  display: flex;
  gap: 2px;
}

.config-compare-bars i {
  width: 5px;
  height: 9px;
  border-radius: 1px;
  background: var(--cfg-border);
}

.config-compare-bars[data-n='1'] i:nth-child(-n + 1),
.config-compare-bars[data-n='2'] i:nth-child(-n + 2),
.config-compare-bars[data-n='3'] i:nth-child(-n + 3) {
  background: var(--cfg-ink);
}

/*
  Quoted, then agreed. The struck figure is the argument for doing the work.

  The agreed number is orange while the negotiation is what is happening, and
  settles to green on the row that wins it — a price being moved is work in
  progress, a price that was accepted is a commitment. Everything else in the
  column stays graphite: five orange figures stacked down a table is five things
  demanding attention, and only one of them is the answer.
*/
.config-compare-price {
  display: flex;
  gap: 4px;
}

.config-compare-price i {
  display: none;
  font-style: normal;
  color: var(--cfg-orange);
}

.config-compare-price b {
  font-weight: 400;
}

.config-compare-row[data-agreed='true'] .config-compare-price i {
  display: inline;
}

.config-compare-row[data-agreed='true'] .config-compare-price b {
  color: var(--cfg-text-2);
  text-decoration: line-through;
}

.config-compare-row[data-selected='true'] .config-compare-price i {
  color: var(--cfg-green);
}

/* The line from the card to the thing it is about. Under the card, over the map. */
.config-leader {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease);
}

/*
  One line per thing the card is about, up to four. Each is hidden by
  js/supply-map.js when it would be a stub beside the card pointing at nothing, so
  the opacity is per line rather than per group.
*/
.config-leader-line {
  stroke: var(--cfg-node);
  stroke-width: 1;
  transition: opacity var(--dur-fast) var(--ease);
}

/*
  Docked, the card is at the bottom edge and the anchor can be at the top of the
  frame, so the leader crosses the whole map — over dotted land the whole way. A
  hairline of slate sinks into that pattern at that length, and the leader is the
  only thing saying which place the card is about, so it is drawn heavier here.
*/
.config-leader[data-dock='bottom'] .config-leader-line {
  stroke: var(--cfg-text-2);
  stroke-width: 1.5;
}

/*
  Docked: on a narrow stage there is no "beside". A 280px card in a 350px frame
  covers whatever it points at wherever it is put, so it goes to the bottom edge
  and keeps the leader line, which is the part that says which place it is about.
*/
.config-callout[data-dock='bottom'] {
  max-width: none;
}

/*
  Node labels are DOM, positioned each frame from the projected coordinate, so
  the type stays crisp at any device pixel ratio and never inherits the canvas's
  resolution cap.
*/
.config-markers {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/*
  A zero-sized anchor sitting exactly on the projected coordinate, with the dot
  centred on it and the label offset from it. The label's offset is written by
  js/supply-map.js, which nudges colliding labels above or below their dot —
  Amsterdam and Schiphol are seven kilometres apart.
*/
.node {
  position: absolute;
  z-index: 2;
  width: 0;
  height: 0;
  opacity: 0;
  transition: opacity var(--dur) var(--ease);
}

.node[data-visible='true'] {
  opacity: 1;
}

/*
  The committed default. A node that has reached the map as part of the chain has
  been chosen, so green is what it is — the states before that (candidate,
  shortlist, under evaluation) override it further down, and the two kinds that
  are not "chosen" at all, the brief's origin and a gateway the order is passing
  through, override it just below.
*/
.node-dot {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(-50%, -50%);
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--cfg-green);
  box-shadow: 0 0 0 3px var(--bg-raised);
}

/* The stage that puts a node on the map marks it, so a still stage still moves.
   The ring takes the dot's own colour — see the two overrides after the kinds. */
.node[data-active='true'] .node-dot::after {
  content: '';
  position: absolute;
  inset: -6px;
  border: 1px solid var(--cfg-green);
  border-radius: inherit;
  animation: node-ping 1.6s var(--ease) infinite;
}

@keyframes node-ping {
  from {
    opacity: 0.9;
    transform: scale(0.6);
  }
  to {
    opacity: 0;
    transform: scale(1.5);
  }
}

/*
  ── Shape is what a partner *is*. Colour is what has happened to it. ──────────

  Two independent axes, and keeping them independent is what makes the map
  readable at a glance: the eye picks the kind out of the silhouette and the state
  out of the hue, and neither question has to be asked twice.

    ○  circle   a material source — mill, fabric supplier, trim supplier
    □  square   a factory or production partner
    ◇  diamond  a logistics partner — port, airport, terminal, distribution centre

  Which leaves colour free to carry the argument, and only the argument. It used
  to carry both: a building was ink because it was a building, and a trim supplier
  was yellow because it was trim, so "yellow" meant one thing here and another in
  the shortlist twelve pixels away.
*/

/*
  The brief's origin. Blue and always blue — it is the only node on this map that
  is not a supplier at all: it is where the requirement was written, which is the
  system's own end of the conversation, and it never becomes selected because it
  was never a candidate.
*/
.node[data-kind='design'] .node-dot {
  width: 12px;
  height: 12px;
  background: var(--cfg-blue);
}

/* A trim supplier is a material source, so it is a circle — it takes the base
   dot unchanged, and is here only to say that is deliberate. */

/* The one node the whole chain converges on, so it is the largest square. The
   orange ring it used to carry is gone: the factory is a partner that was chosen,
   and the goods leaving it are already drawn as orange routes. */
.node[data-kind='factory'] .node-dot {
  width: 13px;
  height: 13px;
  border-radius: 2px;
  background: var(--cfg-green);
}

/*
  A gateway — port or airport — is the one node that is orange rather than green,
  and the reason is that it is the only kind nothing is *selected* at. A port is
  not a partner the brief picked; it is a place the order physically passes
  through, which is exactly what orange means everywhere else in this panel.
*/
.node[data-kind='gateway'] .node-dot {
  width: 11px;
  height: 11px;
  border-radius: 1.5px;
  background: var(--cfg-orange);
  transform: translate(-50%, -50%) rotate(45deg);
}

.node[data-kind='gateway'][data-active='true'] .node-dot::after {
  border-color: var(--cfg-orange);
}

/* Also a logistics partner, and the end of the chain: a diamond, and green,
   because arriving here is the outcome the whole section was building toward. */
.node[data-kind='fulfilment'] .node-dot {
  width: 9px;
  height: 9px;
  border-radius: 1.5px;
  transform: translate(-50%, -50%) rotate(45deg);
}

.node-label {
  position: absolute;
  left: 0;
  /* Overwritten per node by the collision pass; this is the default offset. */
  top: 13px;
  transform: translateX(-50%);
  font-family: var(--font-mono);
  font-size: var(--t-mono-sm);
  line-height: 1.25;
  text-align: center;
  white-space: nowrap;
  color: var(--cfg-ink);
}

/* The ping belongs to the marker, so it takes the marker's colour. */
.node[data-kind='design'][data-active='true'] .node-dot::after {
  border-color: var(--cfg-blue);
}

/*
  The city under the partner's name. One step back from the name and set slightly
  tighter — a place is context for a partner, not a second label, and at 0.08em the
  two lines read as equals. No accent, ever: colouring a city name says something
  happened to the city.
*/
.node-place {
  display: block;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--cfg-place);
}

/*
  Narrow stage: the role goes and the place name takes over as the label, styled
  as one — a node whose label is already a place name (a fulfilment centre) reads
  identically, so the map does not end up with two kinds of label on it.

  js/supply-map.js sets `data-compact` from the measured stage width rather than
  from a media query, because the stage is a fraction of the viewport and it is
  the stage that has to hold the labels.
*/
.config-markers[data-compact='true'] .node[data-named='true'] .node-role {
  display: none;
}

.config-markers[data-compact='true'] .node-place {
  text-transform: none;
  letter-spacing: 0.01em;
  color: var(--cfg-ink);
}

/* ── Candidates, shortlist, selection ──────────────────────────────────── */

/*
  The pool: every location the search could have used. Small, grey and unnamed,
  because they are a population rather than a list — and because the argument of
  the whole section is the reduction from this to three, which only lands if the
  starting field reads as more than can be read.
*/
.node[data-pool='true'] .node-dot {
  width: 6px;
  height: 6px;
  /*
    #777773 — the neutral. A partner nobody has judged yet has no state, and a node
    with no state has no colour.

    Filled rather than outlined, and that is the one place this deviates from a
    1px-ring reading of the neutral: the land under it is a grid of warm-grey dots
    at 2px, and a 6px ring drawn in a tone one step off the terrain is terrain. The
    white halo cuts a hole in the grid around each one and does the separating from
    the other side. The larger named nodes further down *are* ring-and-fill, where
    there is room for it to read.
  */
  background: var(--cfg-node);
  box-shadow: 0 0 0 2.5px var(--bg-raised);
  /* Hoverable: the marker layer above the canvas is otherwise transparent. */
  pointer-events: auto;
}

/* A 6px target is not a target. This is the part the pointer actually finds. */
.node[data-pool='true'] .node-dot::before {
  content: '';
  position: absolute;
  inset: -8px;
  border-radius: 50%;
}

.node[data-visible='false'] .node-dot {
  pointer-events: none;
}

/* Shape as well as tone: a candidate factory is a square, like a chosen one. */
.node[data-pool='true'][data-kind='factory'] .node-dot {
  width: 7px;
  height: 7px;
  border-radius: 1.5px;
  box-shadow: 0 0 0 2px var(--bg-raised);
}

/*
  ── The five states of a search ───────────────────────────────────────────────

  Form first, hue last, and the hues are the section's own:

    candidate / scanned   neutral #777773. Nothing has been decided about it.
    dropped               neutral, hollow. Considered and set aside.
    shortlist             yellow  #FFB640. A candidate. Pending, not chosen.
    compare               blue    #2B8FC2. The platform is actively evaluating it
                                  — quoting, negotiating, scoring.
    selected              green   #087F68. Chosen, and validated.

  Which means the whole material and factory search plays in exactly two accents,
  neutral to yellow to blue to green, and the frame never carries more than two of
  them at once. It used to end on orange for the six under comparison, which put
  the colour of freight on a stage where nothing had moved yet.

  Rejected candidates stay on the map. A shortlist has to be legible against what
  it beat or the reduction reads as a cut rather than as a decision — and the floor
  on the faded states is set by the dot grid underneath rather than by taste: fade
  a 6px marker much past this and it is gone, not quiet.
*/
.node[data-visible='true'][data-state='candidate'] {
  opacity: 0.72;
}

.node[data-visible='true'][data-state='dropped'] {
  opacity: 0.66;
}

/*
  Set aside, and shaped that way: hollow reads as considered, not as missing. It
  is deliberately still readable — the shortlist only means something against the
  field it was drawn from, so a dropped candidate has to stay countable rather
  than dissolve back into the land grid.

  Qualified on `data-pool` to outweigh the factory rule above, which sets a halo
  of its own and would otherwise leave a dropped factory as a white square.
*/
.node[data-pool='true'][data-state='dropped'] .node-dot {
  width: 7px;
  height: 7px;
  background: var(--bg-raised);
  box-shadow:
    0 0 0 2.5px var(--bg-raised),
    inset 0 0 0 1.75px var(--cfg-node);
}

/* Read, and still neutral. One step larger and one step darker than unread, which
   is the whole of what "the platform has looked at this" is allowed to claim. */
.node[data-state='scanned'] .node-dot {
  width: 7px;
  height: 7px;
  background: var(--cfg-node);
}

/* On the shortlist: yellow, and this is the only thing yellow means here. */
.node[data-state='shortlist'] .node-dot {
  width: 9px;
  height: 9px;
  background: var(--cfg-yellow);
  box-shadow: 0 0 0 2.5px var(--bg-raised);
}

.node[data-state='shortlist'][data-kind='factory'] .node-dot {
  width: 9px;
  height: 9px;
  border-radius: 1.5px;
}

/*
  Under comparison: quoted, being negotiated with, scored, not yet chosen.

  Blue with a radar leaving it — this is the platform working, which is precisely
  what blue is for, and it is the same blue as the ring sweeping the pool and the
  links running out to these six. The state has to be loud: the question the whole
  stage answers is which of these is being considered, and every quieter answer — a
  darker grey, a larger dot — was invisible in a field of forty candidates the
  moment it was on the map next to them. It was orange, which spent the colour of
  freight on a stage where nothing had moved.
*/
.node[data-state='compare'] .node-dot {
  width: 10px;
  height: 10px;
  background: var(--cfg-blue);
  box-shadow:
    0 0 0 2.5px var(--bg-raised),
    0 0 0 4px color-mix(in srgb, var(--cfg-blue) 22%, transparent);
}

.node[data-state='compare'][data-kind='factory'] .node-dot {
  border-radius: 1.5px;
}

.node[data-state='compare'] .node-dot::after {
  content: '';
  position: absolute;
  inset: -7px;
  border: 1px solid var(--cfg-blue);
  border-radius: inherit;
  animation: node-radar 2.1s var(--ease) infinite;
}

@keyframes node-radar {
  from {
    opacity: 0.75;
    transform: scale(0.5);
  }
  to {
    opacity: 0;
    transform: scale(1.9);
  }
}

/*
  A candidate's name, on hover or focus and once it is on the shortlist. Not
  before: twenty names at once is not a network, it is a table, and the pool is
  meant to be read as a quantity.
*/
.node[data-pool='true'] .node-label {
  opacity: 0;
  color: var(--cfg-text-2);
  transition: opacity var(--dur-fast) var(--ease);
}

.node[data-pool='true'][data-state='shortlist'] .node-label,
.node[data-pool='true'][data-state='compare'] .node-label,
.node[data-pool='true']:hover .node-label,
.node[data-pool='true']:focus-visible .node-label {
  opacity: 1;
}

/* Brought up to the primary tone, not tinted. §31: a supplier's name never carries
   an accent — the dot beside it is already saying what state it is in. */
.node[data-pool='true'][data-state='compare'] .node-label {
  color: var(--cfg-ink);
}

/*
  The figures a candidate came back with, under its name: the quoted price index,
  the agreed one once it has been negotiated, and the lead time. Tabular, so six of
  them stacked down the map can be compared by eye, and gone the moment the
  candidate is no longer being quoted — a number on a dot with no question attached
  to it is a number nobody can act on.
*/
.node-quote {
  display: block;
  margin-top: 1px;
  font-size: 10px;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  /*
    Graphite, not orange.

    It was orange, on the reading that a figure still under negotiation is work in
    progress. But there are six of these on screen at once during material match,
    and orange is the colour of freight on this map: six orange labels during a
    stage where nothing has moved yet is the loudest possible statement of the
    wrong thing. The negotiation is already carried by the card's status dot, by
    the pulsing blue rings, and — where the brief actually asks for it — by the
    quote-to-agreed figure inside the table.
  */
  color: var(--cfg-ink);
}

/* Settled. The negotiation is over, so the figure stops asking to be looked at. */
.node[data-state='selected'] .node-quote,
.node[data-state='settled'] .node-quote {
  color: var(--cfg-text-2);
}

.node-quote:empty {
  display: none;
}

/*
  Selected. This is the end of the argument, so it is the loudest thing on the map:
  a larger dot, a solid green ring and a soft one outside that — border and fill,
  both green. It has to win against forty neutral candidates, six of which are blue
  and pulsing, and against the orange routes now leaving it.

  Green rather than orange, which is the single most consequential swap in this
  pass: the reader's eye lands on the answer and the answer is the colour of
  "approved", not the colour of "in transit".
*/
.node[data-state='selected'] .node-dot {
  background: var(--cfg-green);
  box-shadow:
    0 0 0 3px var(--bg-raised),
    0 0 0 4.5px var(--cfg-green),
    0 0 0 8px color-mix(in srgb, var(--cfg-green) 14%, transparent);
}

.node[data-state='selected'][data-kind='material'] .node-dot,
.node[data-state='selected'][data-kind='trim'] .node-dot {
  width: 12px;
  height: 12px;
}

/*
  The one kind that does not go green when it is chosen.

  A gateway is selected in the sense that the freight mode picks it, but what it
  means on the map is "the order is physically here" — and that is the whole job of
  orange. Turning every node green at the end would also flatten the map into one
  colour at the exact moment the reader wants to see the difference between a
  partner that was approved and a port the goods are passing through.
*/
.node[data-state='selected'][data-kind='gateway'] .node-dot {
  background: var(--cfg-orange);
  box-shadow:
    0 0 0 3px var(--bg-raised),
    0 0 0 4.5px var(--cfg-orange),
    0 0 0 8px color-mix(in srgb, var(--cfg-orange) 14%, transparent);
}

/*
  And the other one: the brief's origin.

  Amsterdam is `selected` from the first frame because the chain starts there, but
  it was never a candidate and nothing approved it — it is where the requirement
  came from, which is the platform's own end of the map. So it keeps its blue
  through every stage, and is the one mark whose colour never changes.
*/
.node[data-state='selected'][data-kind='design'] .node-dot {
  background: var(--cfg-blue);
  box-shadow:
    0 0 0 3px var(--bg-raised),
    0 0 0 4.5px var(--cfg-blue),
    0 0 0 8px color-mix(in srgb, var(--cfg-blue) 14%, transparent);
}

.node[data-kind='design'][data-active='true'] .node-dot::after {
  border-color: var(--cfg-blue);
}

/*
  And the name with it. A chosen partner is the one label on this map that has to be
  readable without being looked for, so it is set on its own plate: the alternative
  is ink type over a field of land dots and five other labels.
*/
.node[data-state='selected'] .node-label,
.node[data-state='backup'] .node-label {
  padding: 2px 6px 3px;
  border-radius: 4px;
  background: color-mix(in srgb, #fff 88%, transparent);
}

.node[data-state='selected'] .node-role {
  color: var(--cfg-ink);
}

/*
  The qualified second factory in the resilience configuration: approved, and
  never used. Hollow, because nothing is ever routed through it — that is the
  point of it, and filling it in would claim two factories are running.
*/
.node[data-state='backup'] .node-dot {
  background: var(--bg-raised);
  box-shadow:
    0 0 0 3px var(--bg-raised),
    inset 0 0 0 1.5px var(--cfg-node);
}

.node[data-state='backup'] .node-label {
  color: var(--cfg-text-2);
}

/* ── The analysis layer ────────────────────────────────────────────────── */

/*
  What searching looks like: a ring leaving the place the brief was written and
  passing over the candidates, and then a thin line to each one that survived.
  Blue, hairline, and beneath the markers — the physical network is the loud thing
  on this map, and analysis drawn as heavily as freight reads as freight.
*/
.config-pulse {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 1;
  border: 1px solid var(--cfg-blue);
  border-radius: 50%;
  pointer-events: none;
}

.config-link {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 1;
  height: 1px;
  transform-origin: 0 50%;
  background: var(--cfg-blue);
  pointer-events: none;
}

/*
  The correspondence. While the shortlist is quoting and being negotiated with,
  messages run out along those same links and back again — which is the honest
  picture of this part of the work: it is not a calculation, it is a fortnight of
  email with fourteen factories.

  Outlined rather than filled, and small: six of them crossing the map at once as
  solid blocks would read as freight, and freight on this map is orange and dashed.
*/
.config-note-fly {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 2;
  color: var(--cfg-blue);
  opacity: 0;
  pointer-events: none;
}

.config-note-fly svg {
  display: block;
  fill: var(--bg-raised);
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linejoin: round;
  stroke-linecap: round;
}

.config-note-fly svg path {
  fill: none;
}

/*
  The vessel, aircraft or truck on the leg that is moving right now.

  It rides the head of the drawn line and turns to its heading, which is why the
  glyphs are plan views: a side-on ship rotated onto a westbound heading would
  arrive in Rotterdam upside down. The negative margin centres the glyph on the
  coordinate so that the transform written by js/supply-map.js only has to carry
  position and rotation.
*/
.config-vehicle {
  position: absolute;
  left: 0;
  top: 0;
  /* Half the glyph, so the JS transform only carries position and rotation. */
  margin: -13px 0 0 -13px;
  /*
    Foreground, not accent: the glyph rides an orange dashed line, and drawing it
    in the same orange turned the ship into a thicker piece of the line.
  */
  color: var(--cfg-ink);
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease);
}

.config-vehicle[data-visible='true'] {
  opacity: 1;
}

.config-vehicle svg {
  display: block;
  fill: currentColor;
  /* Reads over the land dots without needing a plate behind it. */
  filter: drop-shadow(0 0 2px var(--bg-raised)) drop-shadow(0 0 4px var(--bg-raised));
}

/* ── The stepper ───────────────────────────────────────────────────────── */

/*
  Row two: where in the answer we are, and how to get to any part of it.

  Above the map rather than below it. It is a readout of the story and a way into
  it, so it belongs next to the controls it responds to — under the map it is
  marooned at an edge you have to go looking for, and on a laptop that edge is
  usually off the bottom of the screen.

  The height is fixed enough to reserve real space in the panel: this row is
  never allowed to be squeezed out by the map, because a stage strip that appears
  and disappears is worse than no stage strip.
*/
.config-track {
  display: flex;
  align-items: center;
  gap: clamp(12px, 1.4vw, 22px);
  min-height: 74px;
  padding: 0 clamp(14px, 1.5vw, 20px);
  border-bottom: 1px solid var(--cfg-border);
}

.config-stepper {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 2px;
  list-style: none;
}

/*
  Each stage is a control as well as a readout: pressing one stops the story and
  shows that stage. The rule above the label is two backgrounds rather than a
  border — a full-width track and a fill that grows across it as the stage plays,
  written from the same clock as the routes so the two cannot disagree.

  The fill is blue and the track is the neutral hairline. Blue because what the
  rule reports is the platform's own progress through the work — it is the only
  thing in the panel that is about the system rather than about a supplier or a
  shipment — and it was orange, which put the colour of freight on the timeline of
  a stage where, for the first three stages, nothing moves at all.
*/
.config-step {
  width: 100%;
  display: grid;
  gap: 4px;
  justify-items: start;
  text-align: left;
  padding: 11px 12px 0 0;
  border: 0;
  /* Named, because the panel is white and the user agent's `buttonface` grey is
     not: without this each stage reads as a filled block. */
  background-color: transparent;
  background-image:
    linear-gradient(var(--cfg-blue), var(--cfg-blue)),
    linear-gradient(var(--cfg-border), var(--cfg-border));
  background-repeat: no-repeat;
  background-position: top left;
  background-size:
    0 2px,
    100% 2px;
  cursor: pointer;
}

/*
  No transition on that fill. It is written every frame from the animation's own
  clock, and a 220ms ease re-aimed sixty times a second is a low-pass filter: the
  rule would trail the ship down the route and never quite reach the end of it.
*/

.config-step-index {
  font-family: var(--font-mono);
  font-size: var(--t-mono-sm);
  letter-spacing: 0.09em;
  color: var(--cfg-text-3);
  transition: color var(--dur-fast) var(--ease);
}

.config-step-name {
  font-family: var(--font-display);
  font-size: clamp(13px, 1.15vw, 15.5px);
  font-weight: 700;
  letter-spacing: -0.012em;
  line-height: 1.2;
  color: var(--cfg-text-2);
  transition: color var(--dur-fast) var(--ease);
}

.config-step:hover .config-step-name {
  color: var(--lot-black);
}

.config-step[data-state='done'] .config-step-name {
  color: var(--cfg-ink);
}

.config-step[aria-current='step'] .config-step-name {
  color: var(--lot-black);
}

/* The number, and only the current one. Six blue numbers would be six current
   stages; the point of the accent here is that exactly one thing wears it. */
.config-step[aria-current='step'] .config-step-index {
  color: var(--cfg-blue);
}

/* Squared off with the objective tabs — it is the third control in the panel, and
   like them it is neutral: replaying is not a state the chain can be in. */
.config-replay {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 9px 16px 8px;
  border: 1px solid var(--cfg-border);
  border-radius: var(--r-xs);
  background: #fff;
  font-family: var(--font-mono);
  font-size: var(--t-mono-sm);
  text-transform: uppercase;
  letter-spacing: 0.09em;
  color: var(--cfg-node);
  white-space: nowrap;
  cursor: pointer;
  transition:
    color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease);
}

.config-replay:hover {
  color: var(--cfg-ink);
  border-color: var(--cfg-node);
}

.config-replay svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.1;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* ═══ 3b · AI × VETTED NETWORK × HUMAN EXPERTISE ══════════════════════════ */

/*
  One film in three acts, in three layouts. Which layout is live is `data-mode` on
  `.ai-story`, written by js/ai-story.js; what it means is written here.

    static   Five editorial blocks in normal flow, canvas hidden. This is what
             ships in the HTML, what reduced motion keeps, what a device too small
             to run WebGL gets, and what a lost context falls back to. Every word
             and the photograph stay on the page — nothing is hidden in this mode.
    story    Desktop. One pinned stage, full bleed, the canvas filling it. The act
             copy is an overlay anchored to a corner the scene leaves empty, and
             only the act the scroll is inside is visible. A thin three-act line
             along the bottom.
    flow     Phone. The canvas pinned shallow under the bar, the acts scrolling
             over it as opaque captions. No progress control, no scroll trapping.

  All three are the same markup. The act is a complete editorial block — label,
  headline, and the one sentence it is allowed — so the animated version is a
  re-layout of the fallback rather than a second copy of it, and the two cannot
  drift.

  ── The environment ─────────────────────────────────────────────────────────

  `--story-bg` and `--story-fg` are written on the track every frame by
  js/ai-story.js: white paper and ink for the old world, near-black and white after
  the intelligence layer arrives. The canvas is transparent and draws over that,
  so the stage behind it, the copy on top of it and the progress line under it all
  turn together. Nothing here transitions — the value is a function of scroll
  position, and a transition on it would lag the frame it belongs to.

  Unlike the rest of this file, this section keeps its own responsive and
  reduced-motion rules here rather than in the shared media queries at the bottom.
  Three layouts of one component are only readable next to each other.
*/

.ai {
  border-top: 1px solid var(--line);
}

/* ── Introduction ─────────────────────────────────────────────────────── */

/*
  Short on purpose. Three lines and one sentence, because the argument
  is made by the thing underneath it — anything explained here is explained twice.
*/
.ai-intro .head {
  margin-bottom: clamp(30px, 3.6vw, 54px);
}

.ai-intro h2 {
  max-width: 20em;
}

/*
  Wider than the page's standard measure, and the only place that is true.
  This one sentence is a list — ten kinds of document read in a row — and set to
  33em it broke into five short lines that scanned as a stack of fragments rather
  than as one accumulating clause. At 38em it is three lines, which is the shape
  the sentence is arguing about.
*/
.ai-intro .lead {
  max-width: 38em;
  color: var(--fg-sub);
}

/* ── The track ────────────────────────────────────────────────────────── */

/*
  Full bleed. The section pads itself to the gutter like every other one, and the
  stage is the one thing on the page that has to reach the window edges: a
  cinematic frame inside a 1240px column with margins is a diagram in a box, which
  is the composition this section was rebuilt to stop being.

  The custom properties are declared here with their old-world values so the static
  section is coloured correctly before any script runs, and so removing them on
  destroy hands the markup back exactly as it was found. The two card opacities
  start at zero because in story mode the cards are timed in, and an undeclared
  `var()` in an `opacity` makes the whole declaration invalid — which shows the card
  at full strength on whatever frame renders before the first scroll pass.
*/
.ai-story {
  position: relative;
  margin-inline: calc(var(--gutter) * -1);
  --story-bg: var(--pure-white);
  --story-fg: var(--ink);
  --story-night: 0;
  --human: 0;
  --record: 0;
  --check: 0;
  --gain: 0;
  --final: 0;
  --revised: 1;
  --signed: 1;
  --progress: 0;

  /*
    The three accents the DOM overlay spends, scoped to this section.

    They cannot be `--info` and `--signal`. Those are theme tokens resolved against
    `--bg`, and this section's background is `--story-bg`, which is the opposite of
    `--bg` for two thirds of the film: on a light-theme page `--info` is a #2855F6 that
    lands on near-black at 2.6:1. So the accents are declared here, against the
    environment they are actually read on, and they are the brand's own — `--lot-blue`
    for what the layer computed, `--lot-orange` for what a person changed, `--lot-green`
    for what has been approved.

    Each is two values mixed by `--story-night`, which js/ai-story.js already writes
    every frame as the paper world hands over to the dark one. Neither end passes on both
    grounds: brand blue is 3.6:1 on paper as 12px mono and needs no lift there, and 5.6:1
    on night which is under AA at that size; brand orange is 3.2:1 on paper. So each
    accent has a paper value and a night value, one lifted toward white and one darkened
    toward ink, same hue, and the mix is continuous with the environment change rather
    than a switch at some threshold. Measured on the ground each is used on: blue 3.6:1
    paper / 9.6:1 night, orange 5.7:1 / 6.3:1, green 5.0:1 / 8.2:1.
  */
  --story-ai: color-mix(in srgb, #80bcda calc(var(--story-night) * 100%), var(--lot-blue));
  --story-human: color-mix(in srgb, var(--lot-orange) calc(var(--story-night) * 100%), #b53f18);
  --story-verified: color-mix(in srgb, #6bb2a4 calc(var(--story-night) * 100%), var(--lot-green));
  /*
    And a fourth, on the same construction, for the one thing the DOM overlay says in
    red: a requirement a chain has just missed. It is the canvas's own `fail` — the
    colour of the cross on the node and of the sentence under it — lifted for type,
    because #F42B2F is 5.0:1 on near-black and this is 12px mono with tracking. The
    night value is 6.8:1 and the same red family; the paper value is 5.8:1 on white,
    for the static section where nothing is failing and the row never lights anyway.

    Red is only ever a constraint that did not hold. It is not risk in general and it
    is not decoration — see the verdict comment in js/ai-story.js.
  */
  --story-fail: color-mix(in srgb, #f4696b calc(var(--story-night) * 100%), #c8181c);
}

/*
  The recommendation card's three beats are animated only in the pinned film. Off
  the track there is no scroll to time them against, so they rest at their end
  state: the override struck, the signature there.
*/
.ai-story[data-mode='story'] {
  --revised: 0;
  --signed: 0;
}

/* ── Static: the shipped section ──────────────────────────────────────── */

.ai-story__canvas {
  display: none;
}

/*
  The description of a picture nobody is being shown. It is the same sentence the
  screen-reader narrative opens with, so a visitor without WebGL reads the point
  of the story rather than finding a gap where it was.
*/
.ai-story__fallback {
  max-width: var(--measure);
  margin: 0 var(--gutter) clamp(30px, 4vw, 52px);
  padding-left: clamp(14px, 1.4vw, 22px);
  border-left: 1px solid var(--line);
  font-size: var(--t-lead);
  line-height: 1.6;
  color: var(--fg-sub);
}

.ai-story[data-mode='static'] .ai-story__acts {
  display: grid;
  gap: clamp(44px, 6vw, 84px);
  margin-inline: var(--gutter);
}

.ai-story[data-mode='static'] .ai-act + .ai-act {
  padding-top: clamp(44px, 6vw, 84px);
  border-top: 1px solid var(--line);
}

/*
  Hidden in static and flow, and not because it is decoration: the buttons scroll
  a track, and in these two modes there is no track to scroll.
*/
.ai-story[data-mode='static'] .ai-story__progress {
  display: none;
}

/* ── The act copy, in every mode ──────────────────────────────────────── */

.act-copy {
  display: grid;
  gap: clamp(10px, 1.2vw, 16px);
  max-width: var(--measure);
}

.act-label {
  font-family: var(--font-mono);
  /* Never below 12px, in any mode, at any viewport. */
  font-size: var(--t-mono);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--story-fg) 58%, transparent);
}

.act-headline {
  font-size: clamp(25px, 2.9vw, 42px);
  font-weight: 500;
  letter-spacing: -0.016em;
  line-height: 1.12;
  color: var(--story-fg);
}

.act-line {
  max-width: 42ch;
  font-size: clamp(15px, 1.15vw, 17.5px);
  line-height: 1.55;
  color: color-mix(in srgb, var(--story-fg) 72%, transparent);
}

/* ── The recommendation, and the person who signs it ──────────────────── */

/*
  A small table beside the route, and it is an actual `<table>`: what a
  recommendation is down the side, the machine's answer in one column, the decision
  in the next. Blue for the machine's column and orange for the human's, the same
  two colours the film uses everywhere else, so the pairing reads before a value is.

  Two things it is not, and both were tried here. Not a photograph: a picture of a
  technician measuring a jacket said "people work here", which nobody doubts, and it
  showed inspection where the headline promises a decision. And not two stacked
  blocks — rows, then four lines of prose on why a person is in the loop — because
  the prose restated the sentence directly beside it and took half the frame to do
  it. Columns say in two words what that block said in forty.

  Small on purpose. The scene's subject is the chain on the map, and the table is
  what the chain's four decisions look like written down; at the width the card used
  to take it competed with the geography instead of annotating it.
*/
.ai-rec {
  display: grid;
  gap: 12px;
  margin: clamp(26px, 3vw, 40px) 0 0;
}

.ai-rec__grid {
  width: 100%;
  border-collapse: collapse;
  /*
    Fixed, so both value columns are equal and the decision column does not shift
    left when the value in it is `Bursa` rather than `Hai Phong · Sea`. Column
    widths therefore come from the head row — which is why the term column's width
    is set on the empty corner cell there, not on the row headers under it.
  */
  table-layout: fixed;
}

.ai-rec__label {
  margin-bottom: 9px;
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-align: left;
  color: var(--story-ai);
}

.ai-rec__corner {
  width: 7.5em;
}

.ai-rec__grid th {
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-align: left;
}

/*
  The two column heads are the legend for every row under them, so they carry the
  weight and the hairline. Without the rule the head read as a fifth row.
*/
.ai-rec__grid th[scope='col'] {
  padding: 0 12px 7px 0;
  font-weight: 500;
  border-bottom: 1px solid color-mix(in srgb, var(--story-fg) 20%, transparent);
}

.ai-rec__grid th[data-side='machine'] {
  color: var(--story-ai);
}

.ai-rec__grid th[data-side='human'] {
  color: var(--story-human);
}

.ai-rec__grid th[scope='row'] {
  padding: 9px 12px 0 0;
  font-weight: 400;
  vertical-align: baseline;
  color: color-mix(in srgb, var(--story-fg) 52%, transparent);
}

.ai-rec__grid td {
  padding: 9px 12px 0 0;
  font-size: var(--t-small);
  line-height: 1.35;
  vertical-align: baseline;
  color: var(--story-fg);
}

/*
  The one row nobody moved, spanning both columns. Held at the machine's own weight
  rather than the human's: it is not a revision, it is the condition the three
  revisions above were made to protect.
*/
.rec-held {
  color: color-mix(in srgb, var(--story-fg) 88%, transparent);
}

/*
  Only the rows a person actually moved are struck — the outcome is the row that
  survives review, and striking it too said the opposite of the scene. The strike is
  a rule this element draws itself rather than `line-through`, because it has to
  grow with `--revised`: in the film it is a gesture, not a state that was always
  there. Off the track it rests drawn.
*/
.rec-value {
  position: relative;
  display: inline-block;
}

.ai-rec__grid tr[data-revised] .rec-value {
  color: color-mix(in srgb, var(--story-fg) 58%, transparent);
}

.ai-rec__grid tr[data-revised] .rec-value::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  top: 52%;
  height: 1px;
  background: var(--story-human);
  transform: scaleX(var(--revised, 1));
  transform-origin: left center;
}

.rec-revised {
  color: var(--story-human);
  opacity: var(--revised, 1);
}

/*
  The signature. A short rule, the word, and the role behind it — the closest a
  web page gets to a name on a document, and the point of the whole scene.
*/
.ai-rec__sign {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 10px;
  margin: 2px 0 0;
  opacity: var(--signed, 1);
}

.sign-mark {
  flex: none;
  width: 26px;
  height: 1px;
  background: var(--story-verified);
  transform: translateY(-4px);
}

.sign-word {
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--story-verified);
}

.sign-role {
  font-size: var(--t-small);
  color: color-mix(in srgb, var(--story-fg) 58%, transparent);
}

/* ── Three suppliers, one structure ───────────────────────────────────── */

/*
  A comparison table, and it is an actual `<table>`: six fields down the side, one
  column per supplier. The claim of the scene is that these three are commensurable,
  and rows and columns are the only layout that states that rather than asserting it.

  It replaced a single supplier's card. Same vocabulary as the recommendation two
  scenes later — mono terms, blue for the machine's answer, no border, no radius — so
  a reader meets the treatment here and recognises the recommendation as the thing
  the layer builds out of records like these.
*/
.ai-records {
  display: grid;
  gap: 10px;
  margin: clamp(26px, 3vw, 40px) 0 0;
}

.ai-records__grid {
  width: 100%;
  border-collapse: collapse;
  /*
    Fixed, so the three supplier columns are equal and the eye can run down one
    without re-finding it. Auto layout gave `Lining and backing` a narrower column
    than `Taped 3-layer shell` and the table read as three unrelated lists.

    The consequence is that every column width comes from the *first* row, which is
    the head — so the term column's width is set on the empty corner cell there, not
    on the row headers under it.
  */
  table-layout: fixed;
}

.ai-records__corner {
  width: 9em;
}

.ai-records__label {
  caption-side: top;
  padding-bottom: 12px;
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-align: left;
  color: var(--story-ai);
}

/*
  Width comes from `.ai-records__corner`; this row only carries the type. The value
  columns get everything the terms do not, because the compliance value is 24
  characters and should hold one line wherever the frame allows it.
*/
.ai-records__grid th[scope='row'] {
  padding: 7px 14px 7px 0;
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-weight: 400;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-align: left;
  vertical-align: baseline;
  color: color-mix(in srgb, var(--story-fg) 52%, transparent);
}

/*
  Figtree 600 — the one place in this section that weight is used, and it is here
  because a column head is the thing a reader has to find again after looking away.
  Imported: see FONTS in build.mjs, which asks Google for 400;500;600;700;800.
*/
.ai-records__grid th[scope='col'] {
  padding: 0 14px 9px 0;
  font-weight: 600;
  font-size: var(--t-small);
  line-height: 1.25;
  text-align: left;
  vertical-align: bottom;
  color: var(--story-fg);
  border-bottom: 1px solid color-mix(in srgb, var(--story-fg) 26%, transparent);
}

.record-name {
  display: block;
}

.record-role {
  display: block;
  margin-top: 2px;
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-weight: 400;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--story-fg) 48%, transparent);
}

.ai-records__grid td {
  padding: 7px 14px 7px 0;
  font-size: var(--t-small);
  line-height: 1.4;
  vertical-align: baseline;
  color: var(--story-fg);
}

.ai-records__grid thead td {
  padding: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--story-fg) 26%, transparent);
}

.ai-records__grid tbody tr:first-child th[scope='row'],
.ai-records__grid tbody tr:first-child td {
  padding-top: 10px;
}

.ai-records__more {
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--story-fg) 44%, transparent);
}

/*
  The same table two scenes later, in the frame that decides. Three changes and no
  more, because the point of reusing the block is that the reader already knows how to
  read it: the cells go mono, one column head is marked, one cell fails.

  Mono because these are figures. NODE_RECORDS' values are phrases — `Taped 3-layer
  shell`, `REACH · BSCI · PFAS-free` — and Figtree is right for those; every value here
  is a number, and six rows of numbers in three columns are read down the column, which
  wants the one width mono gives every digit. `--t-small` rather than the `--t-mono`
  the term column uses: these are the figures the whole section has been building
  toward and they are read, not sensed. Measured at 860px, `MFN + SAFEGUARD` is the
  longest value at 139px in a 237px column.
*/
.ai-finals .ai-records__grid td {
  font-family: var(--font-mono);
  font-size: var(--t-small);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/*
  And a wider term column than the supplier table's, because these terms are longer.
  `SUPPLIER PRICE` is fourteen characters and mono at 0.08em tracking runs 8.2px each,
  so it wanted 128px in the 126px `9em` gives — three rows of the six broke onto a
  second line and the table read as ten rows of uneven height. 10em is 140px, which
  holds all six, and leaves 197px a column where the longest value is 153px.
*/
.ai-finals .ai-records__corner {
  width: 10em;
}

/*
  The recommendation, above the route name rather than below the role.
  `th[scope='col']` is bottom-aligned, so a third line under the role would have pulled
  C's two lines down and left `Lowest quote` sitting level with `Recommended` — one
  column visibly out of register with the other two. Above it, the names and roles line
  up across all three and C simply carries a line the others do not.

  Blue, and the only blue in the table. That is the whole signal: this section has spent
  five scenes teaching that blue is what the layer computed, and the supplier record two
  scenes back deliberately marked nothing so that this would land as news. The 2px rule
  is what makes it a column rather than a word — it is the head of the recommended
  chain, not a badge floating over a metric.
*/
.ai-finals .ai-records__grid th[data-mark] {
  padding-top: 9px;
  border-top: 2px solid var(--story-ai);
}

.record-mark {
  display: block;
  margin-bottom: 5px;
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--story-ai);
}

/*
  And the one cell that fails: 18.9% duty against a brief that allows 12%, on the chain
  with the lowest supplier quote of the three. Red is used here exactly as it is used on
  the map — a real check, really missed — and this is the only red on the frame. The
  glyph carries the state as well as the hue; unlike the requirement list's, it is never
  animated, because this cell is failing before the table arrives and still failing when
  it leaves.
*/
.ai-finals .ai-records__grid td[data-failed] {
  color: var(--story-fail);
}

.ai-finals .check-fail {
  margin-left: 7px;
  color: var(--story-fail);
}

/*
  The search result, as three figures.

  The one place in this section where a number is set at display size, and it is the
  last frame of the story: the reader has seen the chain, and what is left to say is
  what it is worth. The three cards above it — record, requirements, recommendation —
  are mono term columns beside a cobalt spine, and this is deliberately not a fourth
  one. It keeps the spine and the mono term, and puts the figure where the value used
  to be, because a comparison a reader has to perform is not a result.

  The before is not thrown away. It sits under each figure in mono, so `−8%` is
  checkable against `€48.65 → €44.79` without being the thing that is read first.
*/
.ai-gain {
  display: grid;
  gap: 8px;
  margin: clamp(26px, 3vw, 40px) 0 0;
}

.ai-gain__label {
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--story-ai);
}

.ai-gain__figures {
  display: grid;
  gap: 18px;
  margin: 4px 0 0;
}

.ai-gain__figure {
  display: grid;
  gap: 2px;
  padding-left: 13px;
  border-left: 2px solid color-mix(in srgb, var(--story-ai) 62%, transparent);
}

.ai-gain__figure dt {
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--story-fg) 52%, transparent);
}

.ai-gain__figure dd {
  display: grid;
  gap: 1px;
  margin: 0;
}

/*
  Display type, and the only display type in this section that is not a headline.

  --t-h2 rather than --t-display: this sits beside the closing headline, which takes
  --t-display itself, and a figure at the same size as `One brief. One verified supply
  chain.` competes with it for the frame instead of answering it. `3 of 4` is the widest
  of the three at four characters and a space, so the column holds it at every story
  width without tabular figures forcing the others narrow.
*/
.ai-gain .gain-value {
  font-family: var(--font-display);
  font-size: var(--t-h2);
  font-weight: 800;
  line-height: 0.98;
  letter-spacing: -0.015em;
  color: var(--story-fg);
  font-variant-numeric: tabular-nums;
}

/*
  The comparison the figure is a summary of, kept underneath rather than dropped. Mono
  and dim, because it is evidence for the number above it and not a second claim: a
  reader who wants to know where −8% came from finds €48.65 → €44.79 on the next line,
  and a reader who does not is not made to read it.
*/
.ai-gain .gain-note {
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--story-fg) 46%, transparent);
}

/*
  The one thing that got worse, as a sentence.

  Not a fourth figure — see the comment on IMPROVEMENT. Set in the body face rather
  than in mono so it reads as a caveat a person wrote rather than as a fourth row of
  the readout, and dim, because it is the smallest of the four things being said.
*/
.ai-gain__foot {
  margin: 2px 0 0;
  font-size: var(--t-small);
  line-height: 1.45;
  color: color-mix(in srgb, var(--story-fg) 58%, transparent);
}

/*
  The brief, as seven requirements, while the search is failing chains against them.

  Same vocabulary again — mono term column, a spine, no border, no radius — with two
  differences from the three cards above, both of them arguments rather than taste.

  The spine is neutral, not cobalt. Blue in this section means the intelligence layer
  computed it; these seven were true before it ran, and a blue spine would hand the
  reader's own requirements to the machine.

  Term and limit share a line rather than stacking, which is what makes seven rows a
  checklist instead of a wall. The two comparison cards stack because each of their
  rows is a pair of figures and an arrow; a row here is one value, and `MINIMUM ORDER
  ≤ 4,200 UNITS` read across is a sentence.
*/
.ai-checks {
  display: grid;
  gap: 8px;
  margin: clamp(26px, 3vw, 40px) 0 0;
}

.ai-checks__label {
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--story-fg) 60%, transparent);
}

.ai-checks__rows {
  display: grid;
  gap: 7px;
  margin: 4px 0 0;
  padding-left: 13px;
  border-left: 2px solid color-mix(in srgb, var(--story-fg) 28%, transparent);
}

/*
  `--hot` is how much of a failure this row is currently carrying, written per frame by
  js/ai-story.js on the row whose check a verdict just went against. Nought here so the
  static section and the first frame of the film are not a list of failures.
*/
.ai-checks__row {
  --hot: 0;
  display: grid;
  /*
    9.8em, because the two longest terms are thirteen characters — `MINIMUM ORDER` and
    `DUTY EXPOSURE` — and mono at 0.08em tracking runs about 0.68em each. At 8.6em both
    wrapped, which put a lone `ORDER` on its own line directly under a red row and made
    one requirement look like two. The column is fixed rather than intrinsic so the seven
    values stay in one vertical line; that is the whole point of a list of limits.
  */
  grid-template-columns: minmax(0, 9.8em) minmax(0, 1fr);
  gap: 0 12px;
  align-items: baseline;
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.ai-checks__row dt {
  font-weight: 500;
  color: color-mix(in srgb, var(--story-fail) calc(var(--hot) * 100%), color-mix(in srgb, var(--story-fg) 50%, transparent));
}

.ai-checks__row dd {
  margin: 0;
  color: color-mix(in srgb, var(--story-fail) calc(var(--hot) * 100%), color-mix(in srgb, var(--story-fg) 82%, transparent));
}

/*
  And the same state as a glyph, because a row that is only a hue is a row some
  readers do not get. Inline, so its width is reserved whether it is showing or not
  and a verdict landing cannot reflow the list under the reader's eye.
*/
.ai-checks .check-fail {
  margin-left: 7px;
  color: var(--story-fail);
  opacity: var(--hot);
}

/* ── Story mode: the pinned film ──────────────────────────────────────── */

/*
  900vh across eight scenes. The four expert judgments occupy a tenth of the
  track, which at this height is about 90vh — enough for each one to land and be
  read, and short enough that the section ends. Eight scenes at 700vh gave the
  two shortest of them under 70vh each, which is less than a screen of scroll to
  say a headline in.
*/
.ai-story[data-mode='story'] {
  min-height: 900vh;
}

.ai-story[data-mode='story'] .ai-story__sticky {
  position: sticky;
  /*
    The measured bar, not the token: --bar-h is what the header actually is at this
    width, and the token is what it is drawn to be. Parking the frame under the
    token's value puts its top row behind the bar.
  */
  top: var(--bar-h, var(--topbar-h));
  height: calc(100svh - var(--bar-h, var(--topbar-h)));
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  background: var(--story-bg);
}

.ai-story[data-mode='story'] .ai-story__stage {
  position: relative;
  overflow: hidden;
}

/*
  No border, no radius, no inner frame. The stage is a window, and every hairline
  drawn around it turns the film back into an illustration of a film.
*/
.ai-story[data-mode='story'] .ai-story__canvas {
  display: block;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.ai-story[data-mode='story'] .ai-story__fallback {
  display: none;
}

.ai-story[data-mode='story'] .ai-story__acts {
  position: absolute;
  inset: 0;
  /* The canvas owns the pointer, if it ever wants it. */
  pointer-events: none;
}

/*
  Every act is a full-bleed grid over the stage with one cell in it, and the copy
  and the photograph are both placed into that cell. So they are positioned
  against the frame the camera is filling rather than against each other, and
  `data-anchor` can move the copy to whichever corner the scene has left empty
  without the photograph moving with it.
*/
.ai-story[data-mode='story'] .ai-act {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-areas: 'frame';
  padding: clamp(20px, 3.4vh, 42px) var(--gutter) clamp(18px, 3vh, 36px);
  opacity: 0;
  visibility: hidden;
  transition:
    opacity var(--dur) var(--ease),
    visibility var(--dur) var(--ease);
}

.ai-story[data-mode='story'] .ai-act[data-active] {
  opacity: 1;
  visibility: visible;
}

.ai-story[data-mode='story'] .act-copy,
.ai-story[data-mode='story'] .ai-rec,
.ai-story[data-mode='story'] .ai-checks,
.ai-story[data-mode='story'] .ai-gain,
.ai-story[data-mode='story'] .ai-records {
  grid-area: frame;
  /* Above the bottom-centre scrim, which is an absolutely positioned ::before. */
  position: relative;
  z-index: 1;
}

/*
  A one-directional wash of the stage's own colour, cheap and only as strong as it
  has to be: type over a drawing needs a floor under it, and a panel, a blur or a
  card would each be a fourth thing in the frame. It fades with its act, because it
  is part of the act rather than part of the stage.
*/
.ai-story[data-mode='story'] .ai-act[data-anchor$='left'] {
  background: linear-gradient(
    97deg,
    color-mix(in srgb, var(--story-bg) 90%, transparent) 0%,
    color-mix(in srgb, var(--story-bg) 52%, transparent) 26%,
    transparent 54%
  );
}

/*
  Stronger for the one anchor that is `left` rather than a corner. Vertically centred
  copy sits at the height the recommended chain runs at, and the pan that opens the
  right of the frame for the photograph carries Rotterdam — the chain's western end —
  to about a tenth of the way across, directly behind the headline. At the shared
  strength a saturated cobalt hairline still read through four lines of white type.
*/
.ai-story[data-mode='story'] .ai-act[data-anchor='left'] {
  background: linear-gradient(
    97deg,
    color-mix(in srgb, var(--story-bg) 97%, transparent) 0%,
    color-mix(in srgb, var(--story-bg) 80%, transparent) 30%,
    transparent 58%
  );
}

/*
  A band across the top of the stage rather than a wash over the whole of it, and the
  only anchor that needs the distinction. The live landed-cost calculation is anchored
  at 0.57 of the stage height, directly below this copy and inside the same left
  column — so a full-frame wash was still 60% of the stage colour where the numbers
  are, and it held the eleven cost lines at a third of their strength. The panel the
  scene is named after was the hardest thing on the frame to read.

  Moved onto a pseudo-element so it can end where the copy ends: `.act-copy` already
  sets `z-index: 1` for the bottom-centre scrim, so the type stays above it.
*/
.ai-story[data-mode='story'] .ai-act[data-anchor='top-left'] {
  background: none;
}

.ai-story[data-mode='story'] .ai-act[data-anchor='top-left']::before {
  content: '';
  position: absolute;
  inset: 0 0 auto 0;
  height: 55%;
  background: linear-gradient(
    97deg,
    color-mix(in srgb, var(--story-bg) 90%, transparent) 0%,
    color-mix(in srgb, var(--story-bg) 52%, transparent) 26%,
    transparent 54%
  );
  /* Full strength through the last line of copy, gone before the first cost line. */
  mask-image: linear-gradient(to bottom, #000 0 80%, transparent 100%);
}

/*
  Deeper and more opaque than the side scrims, because this is the one anchor whose
  copy has the drawing behind it rather than beside it.

  It used to reach half the stage height, and had to: a scanning plane travelled down
  the frame and whatever paper it had not reached yet sat directly under the headline,
  so a wash that stopped at 44% left four lines of white type on a stack of white
  documents. The reading replaced that plane with one document in close-up and took the
  pile down to an eighth, which removes the premise and leaves the cost: measured on the
  frame, the wash reached 50% of the stage and took the bottom third of the close-up
  from 254 to 99 — the one drawing in the section that has to be pristine, greyed by a
  floor put down for the paper behind it. Now 36%, which over a near-black stage is all
  the floor white type needs.

  A pseudo-element rather than the act's own background, because it has to be able
  to leave while its act stays: `--settle` lifts it as the map and the chain leave at
  0.9, after which the stage under this wash is empty and the closing line is the
  only thing in it — see `paint()` in js/ai-story.js.
*/
.ai-story[data-mode='story'] .ai-act[data-anchor='bottom-center']::before {
  content: '';
  position: absolute;
  inset: 0;
  opacity: calc(1 - var(--settle, 0));
  background: linear-gradient(
    to top,
    var(--story-bg) 0%,
    color-mix(in srgb, var(--story-bg) 90%, transparent) 12%,
    color-mix(in srgb, var(--story-bg) 44%, transparent) 24%,
    transparent 36%
  );
}

/*
  Wide enough that the headline's authored breaks are the breaks that happen.
  Each scene's headline is written as two lines, and at the previous 46ch the
  longest of them — "Thousands of supply chains tested." — needed 637px and got
  486, so five of the eight scenes broke again inside a line and one arrived as
  four lines with "picture." alone on the last. 660px clears the widest of them
  at every width the section is composed for; the percentage only binds below
  about 1140px, where the photograph opposite has shrunk with it.
*/
.ai-story[data-mode='story'] .act-copy {
  max-width: min(660px, 58%);
}

/* The sentence keeps a reading measure. Only the headline needed the width. */
.ai-story[data-mode='story'] .act-copy .act-line {
  max-width: 46ch;
}

.ai-story[data-mode='story'] .ai-act[data-anchor='bottom-left'] .act-copy {
  align-self: end;
  justify-self: start;
}

.ai-story[data-mode='story'] .ai-act[data-anchor='top-left'] .act-copy {
  align-self: start;
  justify-self: start;
}

.ai-story[data-mode='story'] .ai-act[data-anchor='left'] .act-copy {
  align-self: center;
  justify-self: start;
}

/*
  Centred, and the only act that is. It is the transformation: the scanning plane
  crosses the whole viewport, so there is no empty corner to hang a headline in and
  the frame is symmetrical for the one moment in the story that it is.
*/
.ai-story[data-mode='story'] .ai-act[data-anchor='bottom-center'] .act-copy {
  align-self: end;
  justify-self: center;
  /* Centred, so it has no photograph to avoid and can take a little more. */
  max-width: min(640px, 62%);
  text-align: center;
}

.ai-story[data-mode='story'] .ai-act[data-anchor='bottom-center'] .act-line {
  margin-inline: auto;
}

/*
  Opposite the copy, over the one part of the new-world frame the routes leave
  empty, and faded in and out by `--human` rather than by the act's own opacity:
  the judgment act is on screen for a quarter of the track and the decision is
  only being made for part of it.
*/
.ai-story[data-mode='story'] .ai-rec {
  align-self: center;
  justify-self: end;
  /*
    Narrower than the card it replaced, which was `min(34%, 420px)`. Three columns of
    short values need less width than two stacked blocks of prose did, and the 44px
    it gives back is 44px of the mill's own name: `Shaoxing, China` is drawn on the
    canvas with its right edge a few pixels off this element's left one, and the
    reserve that keeps them apart takes the label away rather than moving it.
  */
  width: min(30%, 372px);
  margin: 0;
  opacity: var(--human);
  /*
    Type over a drawing needs a floor under it, and dotted geography behind 14px
    values is exactly as unreadable as it sounds. The stage's own colour, held at most
    of full strength and let go at the bottom edge, is enough — a floor, not a card.
    No border and no radius: an edge would make this a panel, and it is an annotation
    on the map beside it.
  */
  padding: 14px 16px 16px;
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--story-bg) 88%, transparent) 0%,
    color-mix(in srgb, var(--story-bg) 88%, transparent) 86%,
    transparent 100%
  );
}

/*
  The table takes the bottom of the stage, full width, with the headline top left.

  It used to be one supplier in the top-right corner at `min(32%, 396px)`, which is
  the only corner a 396px card fits and the wrong place for a comparison: three
  columns need the width, and the bottom third of this frame is the emptiest part of
  it — Africa and South America in dim map dots and nothing else. The headline sits
  top left and the two never meet.

  A flat floor rather than the fade the single card used. Six rows over a dotted map
  is the one thing in this section that has to be read rather than sensed, and the
  card's gradient left its last two rows sitting directly on map dots.

  Faded by `--record` rather than by the act's own opacity, because the act runs for
  a tenth of the track and the table only wants the half of it before the search
  starts drawing probes across the same frame.
*/
.ai-story[data-mode='story'] .ai-records {
  align-self: end;
  justify-self: stretch;
  width: auto;
  max-width: 860px;
  /*
    The floor bleeds to the viewport edge while the type stays on the act's own gutter,
    so the caption and the term column line up with the headline above them. Padding
    inside the element instead would have inset the table 18px past the headline, which
    at this size is visible and reads as a mistake.
  */
  margin: 0 calc(var(--gutter) * -1);
  padding: 16px var(--gutter) 18px;
  opacity: var(--record);
  background: color-mix(in srgb, var(--story-bg) 92%, transparent);
}

/*
  Same slot, same floor, its own clock — and it is in a different act, so `--record`
  would hold it at nought forever. `--final` is the override; everything else about the
  placement is inherited above deliberately, because the reader met this shape at the
  bottom of the frame when the suppliers became comparable and meets it there again when
  three chains are left.

  It shares the act with the result panel, which keeps the top right. That is the reading
  order of the scene: which three survived and why C, across the bottom, then what
  choosing C bought, in the corner. They do not compete for space — the panel is 412px at
  the top, the table 860px at the floor — and the lower half of this frame is the same
  empty map the record filled two scenes earlier: three shortlisted chains bundle across
  the middle and nothing sits under them.
*/
.ai-story[data-mode='story'] .ai-finals {
  opacity: var(--final);
  /*
    One difference in the floor, and it is the routes' fault. The record's floor bleeds a
    gutter past its own table on both sides, which costs nothing in a frame that is
    otherwise empty map. Here the canvas hangs `Route A — Lowest quote` on the curve at
    826px, and 92% of the background over the first 33px of it clipped the label to
    `TE A — LOWEST QUOTE`.

    So the right edge feathers out instead of running to the bleed. Measured: the
    rightmost glyph in the whole block is the last word of the `more` sentence at 761px,
    so the paint is solid to 770 and gone by 820 — every figure on a full floor, the
    label on bare map, and no hard vertical seam where the dots resume. The left bleed
    stays, because that edge is the viewport.
  */
  background: linear-gradient(
    to right,
    color-mix(in srgb, var(--story-bg) 92%, transparent) calc(100% - 90px),
    transparent calc(100% - 40px)
  );
}

/*
  The requirements take the top right — the same corner, and the same width, as the
  result panel below. One slot, two panels, in the order the reader needs them: what
  every chain has to clear while the chains are being tested, then what the search
  came back with. They never overlap; `--check` is out by 0.684 and `--gain` in at 0.700.

  Not the bottom left, which is where the map's key lives until the search ends, and
  not the lower half of the frame at all: every probe this scene draws crosses the map's
  lower two thirds on its way from Asia to Rotterdam.

  Faded by `--check` rather than by the act's own opacity, for the same reason the
  record and the result panel are: the act opens on a frame where the first chain is
  still being drawn, and a list of requirements arriving before there is anything to
  test against them is a caption waiting for its picture. It is gone before the act
  ends, because the thousandths either side of an act change belong to the headline
  crossfade and nothing else should be moving through it.

  The floor holds to 92% rather than the result panel's 84%: seven rows reach 240px down
  the frame where four reach 150px, so the same fade put the duty row on bare map dots.
*/
.ai-story[data-mode='story'] .ai-checks {
  align-self: start;
  justify-self: end;
  width: min(34%, 412px);
  margin: 0;
  padding: 14px 16px 18px;
  opacity: var(--check);
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--story-bg) 88%, transparent) 0%,
    color-mix(in srgb, var(--story-bg) 88%, transparent) 92%,
    transparent 100%
  );
}

/*
  The result is a strip under the chain, not a card in a corner.

  Top right is where the two panels before it sat, and on this one act it is the only
  place the drawing is still using: the recommended chain resolves straight across the
  middle of the frame, and its last two nodes — LOGISTICS, DELIVERY — ran underneath a
  34%-wide card, so the figures were printed over the thing they are figures about.
  Bottom right is no better. The card is ~340px tall on a ~790px stage; bottom-aligned,
  its top lands within a few pixels of the chain's own labels.

  So the three figures take the full width of the band between those labels and the
  headline, one column each, left to right. That band was empty in every earlier frame of
  this act and it is the last thing read before the headline underneath it.

  Stacked above the headline in normal flow rather than positioned at a percentage of the
  stage. A percentage cannot know its own height: measured, `top: 53%` left 65px between
  the strip and the headline at 1600×900 and 9px at 1120×800, because the strip grows
  downward as its columns wrap while the headline stays where it is. Bottom-aligning the
  pair instead makes the strip grow upward into the empty band, which is the direction
  there is room in. `order` is what puts it above the copy it follows in the markup.

  Faded by `--gain` rather than by the act's own opacity because the act opens at 0.88 and
  the figures arrive at 0.906 — after the bottom scrim has lifted and the geography has
  gone. Bringing them in with the headline would put the figures over the tail of the
  map's exit.
*/
.ai-story[data-mode='story'] .ai-act:has(.ai-gain) {
  grid-template-areas: none;
  grid-template-columns: minmax(0, 1fr);
  align-content: end;
  row-gap: clamp(18px, 3.4vh, 40px);
}

.ai-story[data-mode='story'] .ai-act:has(.ai-gain) .act-copy {
  grid-area: auto;
}

.ai-story[data-mode='story'] .ai-gain {
  grid-area: auto;
  order: -1;
  gap: 13px;
  margin: 0;
  opacity: var(--gain);
}

/*
  One spine per column rather than one down the side of a stack: the spine is what marks
  this as the section's card, and a three-up readout keeps it by repeating it.

  Three columns at every story width, including the narrowest — story mode reaches down
  to 901px, where a column is ~300px and the widest note, `From 18.9% · EVFTA`, holds one
  line of 12px mono. Dropping to two would trade width for height in the one band on this
  stage that has no height to spare: the strip sits between the chain's own node labels
  and the closing headline.
*/
.ai-story[data-mode='story'] .ai-gain__figures {
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 16px 26px;
  margin: 0;
}

/*
  The caveat spans the whole strip rather than sitting under one of the three columns:
  it is about the chain, not about the quality band it happens to follow.
*/
.ai-story[data-mode='story'] .ai-gain__foot {
  margin: 0;
  max-width: 62ch;
}

/* ── The progress line ────────────────────────────────────────────────── */

/*
  One thin line, one marker, two labels. No numbers, no chapter count, no legend:
  two is what actually changes across the story — the supply chain is either the
  fragmented one it starts in or the intelligent one it argues for, and everything
  else the story shows happens inside the second. See PROGRESS_ACTS.

  The buttons scroll the track rather than jumping the animation, which is what
  makes the story reachable from the keyboard and what makes the back gesture
  behave.
*/
.ai-story__progress {
  display: grid;
  gap: clamp(9px, 1.2vh, 14px);
  padding: clamp(12px, 1.8vh, 20px) var(--gutter) clamp(14px, 2.2vh, 24px);
}

.progress-rail {
  position: relative;
  display: block;
  height: 9px;
}

.progress-rail::before {
  content: '';
  position: absolute;
  inset: 50% 0 auto;
  height: 1px;
  background: var(--story-fg);
  opacity: 0.18;
}

/*
  How far through. `--progress` is the same number the marker rides, so the fill can
  never disagree with the dot at the end of it, and a reader who has scrolled back
  watches it retreat.
*/
.progress-fill {
  position: absolute;
  inset: 50% auto auto 0;
  width: calc(var(--progress) * 100%);
  height: 1px;
  background: var(--story-fg);
  opacity: 0.45;
}

/*
  The nine scenes, as divisions of the rail rather than as labels under it. Each is
  a 5px notch at its act's own start, at the width of that act, so the divisions are
  unequal in exactly the way the film is — the transformation is a fifth of the track,
  the candidate network is a thirtieth, and the two closing moments are a twelfth each.

  The one the reader is inside is lit by `[data-act]` on the track. That attribute is
  already written every frame for the copy overlay, so nine static rules replace what
  would otherwise be nine custom properties recomputed per scroll event.
*/
.progress-scene {
  position: absolute;
  top: 50%;
  height: 5px;
  margin-top: -2px;
  border-left: 1px solid var(--story-fg);
  opacity: 0.22;
}

.progress-scene:first-of-type {
  border-left-color: transparent;
}

.ai-story[data-act='0'] .progress-scene[data-i='0'],
.ai-story[data-act='1'] .progress-scene[data-i='1'],
.ai-story[data-act='2'] .progress-scene[data-i='2'],
.ai-story[data-act='3'] .progress-scene[data-i='3'],
.ai-story[data-act='4'] .progress-scene[data-i='4'],
.ai-story[data-act='5'] .progress-scene[data-i='5'],
.ai-story[data-act='6'] .progress-scene[data-i='6'],
.ai-story[data-act='7'] .progress-scene[data-i='7'],
.ai-story[data-act='8'] .progress-scene[data-i='8'] {
  height: 9px;
  margin-top: -4px;
  opacity: 0.75;
}

.progress-marker {
  position: absolute;
  top: 50%;
  left: calc(var(--progress) * 100%);
  width: 9px;
  height: 9px;
  margin: -4.5px 0 0 -4.5px;
  border-radius: 50%;
  background: var(--story-fg);
}

/*
  Two segments for two buttons, at the two spans PROGRESS_ACTS declares: 0–0.32 and
  0.32–1.00. A label sits at the start of the stretch of line it names, so the marker
  is inside the segment whose label is lit. This used to be three columns for two
  buttons — a leftover from a three-act cut — which squeezed `INTELLIGENT SUPPLY
  CHAIN` into a fifth of the rail and left the right half of it empty.
*/
.progress-acts {
  display: grid;
  grid-template-columns: 32% 68%;
}

.ai-story__step {
  padding: 0;
  border: 0;
  background: none;
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  font-weight: 500;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  text-align: left;
  color: color-mix(in srgb, var(--story-fg) 42%, transparent);
  cursor: pointer;
  transition: color var(--dur) var(--ease);
}

.ai-story__step[aria-pressed='true'] {
  color: var(--story-fg);
}

.ai-story__step:focus-visible {
  outline: 2px solid var(--story-fg);
  outline-offset: 3px;
}

/* ── Flow mode: the phone ─────────────────────────────────────────────── */

/*
  Two elements taken out of the layout, which is the whole trick. `flowProgress()`
  reads each act's bounding box to decide where in the story the page is, so the
  acts have to be in normal flow — and they are wrapped in the sticky frame and the
  stage. `display: contents` promotes the canvas, the acts and the progress control
  to children of the track, and the acts become an ordinary column again.

  js/ai-story.js measures the canvas rather than the stage for exactly this reason:
  an element with no box reports a rect of zero.
*/
.ai-story[data-mode='flow'] {
  background: var(--story-bg);
}

.ai-story[data-mode='flow'] .ai-story__sticky,
.ai-story[data-mode='flow'] .ai-story__stage {
  display: contents;
}

.ai-story[data-mode='flow'] .ai-story__canvas {
  display: block;
  position: sticky;
  top: var(--bar-h, var(--topbar-h));
  width: 100%;
  height: 52svh;
}

.ai-story[data-mode='flow'] .ai-story__fallback,
.ai-story[data-mode='flow'] .ai-story__progress {
  display: none;
}

/*
  Each act is a screenful and a bit of scroll with its caption at the bottom of it,
  so the caption enters from the bottom of the window, crosses the reading line at
  62% — which is what decides the scroll position the scene is drawn at — and
  leaves over the top of the canvas. The background is opaque because it passes in
  front of the drawing rather than beside it.
*/
.ai-story[data-mode='flow'] .ai-act {
  position: relative;
  z-index: 1;
  display: grid;
  align-content: end;
  min-height: 124svh;
  padding: 0 var(--gutter) clamp(26px, 6vh, 60px);
}

.ai-story[data-mode='flow'] .act-copy,
.ai-story[data-mode='flow'] .ai-rec,
.ai-story[data-mode='flow'] .ai-checks,
.ai-story[data-mode='flow'] .ai-gain,
.ai-story[data-mode='flow'] .ai-records {
  padding-top: clamp(14px, 2.4vh, 22px);
  background: var(--story-bg);
}

.ai-story[data-mode='flow'] .act-copy {
  max-width: none;
  border-top: 1px solid color-mix(in srgb, var(--story-fg) 22%, transparent);
}

/*
  Flow mode has no pinned stage to place the table beside, so it sits under the copy
  and shares its column — where it is simply type, with no floor to draw and no
  corner to defend. Full width there, and the term column can afford to be wider
  than the 7.5em the desktop frame allows.
*/
.ai-story[data-mode='flow'] .ai-rec {
  width: auto;
  margin-top: 0;
  gap: 10px;
}

.ai-story[data-mode='flow'] .ai-rec__corner {
  width: 9em;
}

/*
  Full opacity here, unlike desktop. `--record` exists to keep the table out of the
  frame the probes are drawn in, and in flow mode it is not in that frame — it is
  below it, in the act's own column, and scrolls away on its own.
*/
.ai-story[data-mode='flow'] .ai-records {
  margin-top: 0;
  gap: 7px;
  opacity: 1;
}

/* Same as the record, for the same reason: below the frame rather than inside it. */
.ai-story[data-mode='flow'] .ai-gain {
  margin-top: 0;
  gap: 7px;
  opacity: 1;
}

/*
  Same again. The requirements are type, so in flow mode they are simply type: below the
  copy, in the act's column, scrolling away on their own — no corner to defend and no
  window to keep, since the act above them has left by the time the next one arrives.
  The rows still light, because `--hot` is a property on the row rather than a position
  on the frame, and this is the one place in flow mode where a rejection is visible at
  all: the reason sentences the canvas prints beside a node are drawn at a scale the
  narrow band cannot carry.
*/
.ai-story[data-mode='flow'] .ai-checks {
  margin-top: 0;
  gap: 6px;
  opacity: 1;
}

/*
  Narrow enough that the three figures stack, which is the right shape for them: a phone
  reads one claim at a time. Tighter than the desktop stack because each figure is now
  three lines of its own and the act still has to fit one screen.
*/
.ai-story[data-mode='flow'] .ai-gain__figures {
  gap: 12px;
}

.ai-story[data-mode='flow'] .ai-records__corner {
  width: 6.5em;
}

/*
  Except the finalists', whose terms are longer — and only while there is room for it.
  A tablet's table is 738px, so 140px of term column still leaves 199px a column and the
  six terms hold one line each. A 390px phone has dropped to two columns of 124px by
  then, and taking 49px off those to unwrap a term would wrap the figures instead, which
  is the trade the other way round. So the phone keeps the 6.5em above and reads its
  terms on two lines.
*/
@media (min-width: 641px) {
  .ai-story[data-mode='flow'] .ai-finals .ai-records__corner {
    width: 10em;
  }
}

.ai-story[data-mode='flow'] .ai-records__grid th[scope='row'] {
  padding-right: 10px;
}

.ai-story[data-mode='flow'] .ai-records__grid td,
.ai-story[data-mode='flow'] .ai-records__grid th[scope='col'] {
  padding-right: 10px;
}

/* ── The conclusion ───────────────────────────────────────────────────── */

/*
  Dark, and full bleed to the bottom of the section, because the film ends on
  near-black and a white band under it would read as the page changing its
  mind. The negative bottom margin cancels the section's own padding so the band
  reaches the edge and hands straight over to the mission section.
*/
.ai-outro {
  margin-inline: calc(var(--gutter) * -1);
  margin-bottom: calc(var(--section-y) * -1);
  padding: clamp(58px, 7vw, 104px) var(--gutter) var(--section-y);
  background: var(--bg);
  color: var(--fg);
}

.ai-close__statement {
  max-width: 26em;
  font-size: clamp(23px, 2.5vw, 36px);
  font-weight: 500;
  letter-spacing: -0.014em;
  line-height: 1.2;
}

/* ── Narrow ───────────────────────────────────────────────────────────── */

@media (max-width: 900px) {
  .ai-story[data-mode='static'] .ai-story__acts {
    gap: clamp(36px, 8vw, 52px);
  }

  .ai-story[data-mode='static'] .ai-act + .ai-act {
    padding-top: clamp(36px, 8vw, 52px);
  }

  .ai-rec,
  .ai-gain {
    max-width: 520px;
  }

  .ai-records__corner {
    width: 6.5em;
  }

  .ai-records__grid th[scope='row'] {
    padding-right: 10px;
  }

  .ai-records__grid td,
  .ai-records__grid th[scope='col'] {
    padding-right: 10px;
  }
}

/*
  Two columns, not three shrunk to fit — and the threshold is a width, not a mode: a
  844px phone in landscape is flow mode and holds three columns comfortably, while a
  560px static one does not.

  Measured with the third column forced on, `table-layout: fixed` never overflows a
  cell, so the ceiling is legibility rather than clipping: at 700px and up every value
  is one line; at 640px the longest four wrap to two; at 560px almost all of them do
  and `3,000-unit minimum` splits across lines in all three columns at once. So the
  middle supplier is dropped at 640 rather than every value made smaller than it can
  be read at. The two that stay are still the argument: the schema holds, and the
  cheapest quote in the network disqualifies itself on the first row.
*/
/*
  The caption that goes with two columns rather than three, swapped on the same
  threshold that drops the column — both strings are in the DOM at every width so the
  static and reduced-motion readings keep the sentence they are given.
*/
.ai-records__narrow {
  display: none;
}

@media (max-width: 640px) {
  .ai-records [data-optional] {
    display: none;
  }

  .ai-records__narrow {
    display: inline;
  }
}

/* ── Reduced motion ───────────────────────────────────────────────────── */

/*
  js/ai-story.js never enters story or flow mode under reduced motion, so this is
  the second line of the same defence: whatever `data-mode` says, no sticky track,
  no crossfade, no overlay. Eight scenes and a photograph simply read.
*/
@media (prefers-reduced-motion: reduce) {
  .ai-story {
    min-height: 0;
  }

  .ai-story .ai-story__sticky,
  .ai-story .ai-story__stage {
    display: block;
    position: static;
    height: auto;
    overflow: visible;
    background: none;
  }

  .ai-story .ai-story__canvas {
    display: none;
  }

  .ai-story .ai-story__fallback {
    display: block;
  }

  .ai-story .ai-story__acts {
    display: grid;
    position: static;
    gap: clamp(44px, 6vw, 84px);
    margin-inline: var(--gutter);
    pointer-events: auto;
  }

  .ai-story .ai-act {
    display: block;
    position: static;
    inset: auto;
    min-height: 0;
    padding: 0;
    background: none;
    opacity: 1;
    visibility: visible;
    transition: none;
  }

  .ai-story .ai-act + .ai-act {
    padding-top: clamp(44px, 6vw, 84px);
    border-top: 1px solid var(--line);
  }

  .ai-story .act-copy {
    max-width: var(--measure);
    text-align: left;
    background: none;
    border-top: 0;
  }

  /* Nothing is being timed here, so the table rests at its end state. */
  .ai-story .ai-rec {
    --revised: 1;
    --signed: 1;
    width: auto;
    margin-top: clamp(26px, 3vw, 40px);
    padding: 0;
    background: none;
    opacity: 1;
  }

  .ai-story .ai-records {
    width: auto;
    max-width: none;
    margin-top: clamp(26px, 3vw, 40px);
    padding: 0;
    background: none;
    opacity: 1;
  }

  .ai-story .ai-story__progress {
    display: none;
  }
}

/* ═══ 3c · MISSION ═══════════════════════════════════════════════════════ */

/*
  The last section of the page's light half, and the quietest thing on it.

  Everything above it is a grid — cards, panels, tabs, a 500vh scroll story. This
  is type and one still map, on the same 1240px shell as the rest. The restraint is
  the design: it is the only section that states what the company is for, and it has
  to read as a pause rather than as another feature.

  Type is deliberately lighter than the rest of the page. Every other heading is
  Figtree 800; the mission statement is 450, which is what makes a 50px sentence
  read as a statement rather than as a headline selling something.

  ── It was ink black, and paper is the point ────────────────────────────────

  Until 2026-09-06 this was `data-theme="dark"`: white type and two lit accents on a
  near-black field. Read on its own it was the best-looking section on the page, and
  read in sequence it was three problems. `#one-product` above it and `#platform`
  below it made it the middle of a three-section dark run at the end of the page, so
  the black stopped being a signal and became the ground. It put the loudest
  treatment on the one section whose job is to be a pause. And the map's two accents
  glowing out of a black field read as a live operations screen, which is the claim
  this section is *not* making — it says where the network is, once, and stops.

  ── The ground, and why grey rather than white ──────────────────────────────

  `--paper-grey`, the same ground as `#process` and the category rail, taken through
  the same mechanism every other light section here uses: rebind the semantic tokens
  locally, keep `data-theme="light"`, and let the components below go on reading
  `--bg`, `--fg-sub`, `--line` and friends. No second theming path.

  Grey rather than the light theme's #FFFFFF because of the boundary above, and that
  boundary is two different things depending on whether the script ran. With it,
  `#one-product`'s pinned stage is fully night by the time its track ends, so what
  meets this section is near-black and the cut is as hard as the old dark band's was
  — this is now a light band between two dark ones, which is the composition. Without
  it, that section is its own `--paper-white` #FAFAF8 ground, and #FFFFFF against it
  is a 1.045:1 step: not a boundary, a rendering artefact. `--paper-grey` makes the
  same seam 1.132:1 — still quiet, but a tone change rather than a rounding error, and
  it keeps the page's black → grey → near-white → grey rhythm going one more beat.
  Neither number carries a boundary on its own, so in the static case the hairline is
  what marks it, with the tone change behind it; in the scrolled one the hairline is
  doing what a hairline is for, confirming a change the eye has already seen.

  Grey buys the map something too. Every value in it is now *darker* than its
  ground, and a mid-grey ground gives the land dots room to sit under the nodes
  without disappearing: on #FFFFFF the field had to be pushed to a tone that read as
  a smudge before it read as dots.

  ── What the grey costs, measured ───────────────────────────────────────────

  Against #ECECEA, read out of the browser rather than off a swatch:

    statement / manifesto link   --ink            16.93:1
    support paragraph, labels    --paper-sub       4.73:1
    COORDINATION under Amsterdam --info            4.77:1
    Amsterdam node               --info            4.77:1
    the ring on it               --network         4.82:1
    land dot field               --paper-line-str  1.67:1   (target: ~1.6, the ink
                                                             version and the §3 map)
    supply nodes                 --accent          2.85:1   ← under 1.4.11's 3:1

  Every string on the section passes AA. The orange does not pass 1.4.11, and the
  number is the reason this paragraph exists rather than a comment saying it looks
  fine. #F15A2A is already the darker of the two oranges the palette has — the
  canonical `--lot-orange` #FF571F is *lighter* and measures worse — so there is no
  darker orange to reach for, and a hand-mixed one is a defect by the rule at the top
  of tokens.css. `--paper-white` would carry it at 3.22:1, and that is the one-line
  change if this is ever revisited; it costs the ramp into `#platform` and the seam
  above, which is the trade that was made and not a thing that was missed.

  What makes it defensible here and would not make it defensible on a chart: 1.4.11
  applies to graphics *required to understand the content*, and nothing in this map is
  only in the map. The figure is named with the whole of it — every region, and
  Amsterdam as the coordinating one — so a reader who cannot separate the dots from
  the field still gets the section's claim in full. The dots are the illustration of a
  sentence, not the only place the sentence is made. Nothing else on this page may
  cite that; a value a reader has to *extract* from a drawing needs the 3:1.
*/

.mission {
  /*
    The paper family, bound the way `.process` and `.adv` bind it. `--fg` needs no
    entry: the light theme's is already `--ink`.
  */
  --bg: var(--paper-grey);
  --line: var(--paper-line);
  --line-strong: var(--paper-line-strong);
  --fg-sub: var(--paper-sub);
  /*
    Non-text marks only — here, the dotted landmass, and nothing else in the section
    reads it. `--fg-faint`'s own light value #A5A5A5 measures 2.1:1 on this ground
    against the ink version's 1.6:1, and at stroke-width 4.2 across 2,421 dots that
    difference is the whole continent stepping forward in front of the nodes. The
    hairline value lands at 1.6:1 on the nose, which is also where the configurator's
    land grid sits on its own paper.
  */
  --fg-faint: var(--paper-line-strong);
  border-top: 1px solid var(--line);
}

/*
  Statement left, paragraph right, over the full shell.

  Stacked in one measured column the two blocks were ~560px tall together, which
  pushed the map entirely below the fold and made the section read as two screens.
  Side by side they occupy the height of the taller one, and the top third of the
  map arrives in the same viewport — which is the composition: the claim, the
  reason, and the evidence at a glance.

  The columns are uneven on purpose. The statement is set at 50px and needs the
  wider half; the paragraph is body copy and wants a 60-character measure, not
  half the shell.
*/
.mission-head {
  display: grid;
  grid-template-columns: minmax(0, 1.16fr) minmax(0, 0.84fr);
  gap: clamp(32px, 5vw, 88px);
  align-items: end;
}

.mission-statement {
  font-size: clamp(28px, 3.5vw, 50px);
  font-weight: 450;
  letter-spacing: -0.016em;
  line-height: 1.14;
  margin: 0;
}

/*
  A reading width inside its column, hung from the bottom so its last line sits
  on the statement's baseline rather than floating in the middle of a taller
  neighbour.
*/
.mission-support {
  max-width: 480px;
  padding-bottom: 0.4em;
}

.mission-support p {
  font-size: var(--t-lead);
  line-height: 1.6;
  color: var(--fg-sub);
  margin-bottom: clamp(26px, 3vw, 38px);
}

/*
  Tight, and deliberately tighter than the section's other vertical rhythm: the
  rule is a hinge between the statement and the map, not a gap between two
  sections. The map is the point of the second half and should be reachable
  without a scroll through empty ink.
*/
.mission-rule {
  height: 1px;
  border: 0;
  margin: clamp(34px, 3.6vw, 56px) 0 clamp(24px, 2.6vw, 40px);
  background: var(--line);
}

/* ── The map ──────────────────────────────────────────────────────────── */

/*
  Flat, static and unclickable, by intention: the hero has a globe and section 3
  has an interactive configurator, so a third map that moves would be the third
  answer to a question nobody asked twice. This one says one thing — where the
  network is — and then stops.

  The frame's aspect ratio is the SVG's viewBox, which is what lets the labels be
  HTML positioned in percentages: `meet` would letterbox and the two coordinate
  systems would drift apart. At 1240px that is a 482px-tall map.
*/
.mission-map {
  margin: 0;
}

.mission-map__frame {
  position: relative;
  aspect-ratio: 1440 / 560;
}

.mission-map__svg {
  display: block;
  width: 100%;
  height: 100%;
}

/*
  2,421 dots in one path. They are drawn as zero-length subpaths with a round cap,
  so the stroke width is the dot diameter — 4.2 of the 10 units between centres,
  which reads as a dot field rather than as a halftone of a solid continent.
*/
.mission-map__land {
  fill: none;
  stroke: var(--fg-faint);
  stroke-width: 4.2;
  stroke-linecap: round;
}

/*
  Two colours, and they are the page's existing two: orange is physical, blue is
  coordination and information. Read through the semantic tokens rather than as
  hexes, which is the only reason the section could change ground at all — `--accent`
  is `--signal` in both themes, and `--info` is the same route blue the configurator
  uses, darkened from the dark theme's #8FA6FF back to #2855F6 by the theme itself.

  Both node circles carry a ring of the section's own ground, so they punch out of
  the dot field instead of merging with it. That was `--ink` while the ground was
  ink; `--bg` is what it always meant.
*/
.map-node__dot {
  stroke: var(--bg);
  stroke-width: 2.6;
}

.map-node[data-kind='supply'] .map-node__dot {
  fill: var(--accent);
}

.map-node[data-kind='coordination'] .map-node__dot {
  fill: var(--info);
}

/*
  The purple, and the only purple on the site. It marks the one node that is both
  halves of the mission at once — the coordination point and the thing being
  orchestrated. A second one would make it a category.

  `--network` rather than the #6E2CA8 that was here: the token is the light theme's
  #7C3AED, which is the value the network hue was picked at — 5.7:1 on white, chosen
  to sit at route blue's weight so neither wins on brightness. The hand-mixed dark
  purple was tuned to survive an ink field and is 2.2:1 on paper.
*/
.map-node__ring {
  fill: none;
  stroke: var(--network);
  stroke-width: 2.4;
}

/*
  ── There was a caption block here, in mono, and it is deleted ──────────────

  `.mission-map__caption` was a hairline rule under the map with two things over it:
  a two-swatch legend (`.mission-map__legend`, `.mission-map__swatch`) and one
  sentence about the network. Removed on 2026-09-06 with the markup in missionMap().

  It is worth saying why, because the legend was the defensible half. A map with two
  colours in it wants a key. But this map has exactly two colours and one of them
  appears once, labelled on the map itself — Amsterdam carries COORDINATION under its
  name — so the key was restating in a swatch what the drawing already says in a
  word. And the pair of them turned the section's closing edge into a figure caption
  in a report, three lines of 10px mono under the one composition on this page that
  is meant to be looked at and not read. The figure's accessible name went `sr-only`
  rather than away; see missionMap().
*/

/*
  Labels are HTML over the SVG, not `<text>`: SVG type scales with the viewBox, so
  11px at 1440 would be 8px at 1024. Positioned in percentages of the same frame,
  which is exact because the frame is locked to the viewBox ratio above.
*/
.map-label {
  position: absolute;
  transform: translateY(-50%);
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: var(--t-mono-sm);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.09em;
  line-height: 1.35;
  color: var(--fg-sub);
  pointer-events: none;
}

/* Hangs to the left of its node, which is what keeps Porto off Barcelona. */
.map-label[data-anchor='end'] {
  transform: translate(-100%, -50%);
}

/*
  Blue, because the word under AMSTERDAM is the only place the map spells out what
  the blue node means — and, since the legend went, the only place it is named at
  all. `--info` rather than the dark theme's #8FA6FF, which is 1.9:1 on paper.
*/
.map-label__role {
  display: block;
  color: var(--info);
}

/* ── Map entry ────────────────────────────────────────────────────────── */

/*
  Continents, then nodes in regional groups west to east, then one pulse each,
  then still. `--step` is the group index, so the stagger is data-driven rather
  than a list of selectors, and the whole sequence is over in about 1.5s.

  Scoped to `html[data-js]` like every other reveal on the page: without the
  script the finished map is simply there.
*/
html[data-js='true'] .mission-map [data-layer] {
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease);
}

html[data-js='true'] .mission-map.is-in [data-layer] {
  opacity: 1;
}

html[data-js='true'] .mission-map.is-in .map-node,
html[data-js='true'] .mission-map.is-in .map-label {
  transition-delay: calc(var(--step, 7) * 105ms + 240ms);
}

/* One pulse, once. It is a reveal, not a heartbeat. */
.map-node__pulse {
  fill: none;
  stroke-width: 1.6;
  opacity: 0;
}

.map-node[data-kind='supply'] .map-node__pulse {
  stroke: var(--accent);
}

.map-node[data-kind='coordination'] .map-node__pulse {
  stroke: var(--info);
}

html[data-js='true'] .mission-map.is-in .map-node__pulse {
  animation: mission-pulse var(--dur-slow) var(--ease-inout) 1 both;
  animation-delay: calc(var(--step, 0) * 105ms + 520ms);
  transform-box: fill-box;
  transform-origin: center;
}

@keyframes mission-pulse {
  0% {
    opacity: 0.75;
    transform: scale(1);
  }
  100% {
    opacity: 0;
    transform: scale(3.4);
  }
}

/* ── Narrow ───────────────────────────────────────────────────────────── */

/*
  One column again. Two 44%-wide columns at this width give the statement a
  four-word measure, and nothing is gained by keeping the map in the same
  viewport on a screen that cannot hold either half comfortably.
*/
@media (max-width: 900px) {
  .mission-head {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(30px, 5vw, 44px);
    align-items: start;
  }

  .mission-support {
    max-width: none;
    padding-bottom: 0;
  }
}

/*
  Below this the map is around 250px tall and a 10px label is 40% of a country.
  The nodes stay, the labels go — coverage is still legible, a pile of overlapping
  names would not be.
*/
@media (max-width: 760px) {
  .map-label {
    display: none;
  }

  .mission-map__land {
    stroke-width: 5;
  }

  /*
    The radii are set on the elements so they survive a browser without the CSS
    geometry properties; this is the one place they are worth overriding, and a
    browser that ignores it keeps the desktop sizes rather than breaking.
  */
  .map-node__dot,
  .map-node__pulse {
    r: 7.5;
  }
}

/* ── Reduced motion ───────────────────────────────────────────────────── */

/*
  base.css already collapses the transition to 1ms, but not the delays or the
  pulse — a 1ms fade that still waits 1.2s is its own kind of animation. The map
  arrives complete and at once.
*/
@media (prefers-reduced-motion: reduce) {
  html[data-js='true'] .mission-map [data-layer] {
    opacity: 1;
  }

  html[data-js='true'] .mission-map.is-in .map-node,
  html[data-js='true'] .mission-map.is-in .map-label,
  html[data-js='true'] .mission-map.is-in .map-node__pulse {
    animation: none;
    transition-delay: 0s;
  }
}

/* ═══ 7 · PLATFORM ═══════════════════════════════════════════════════════ */

/*
  Full-bleed light band: a section head and one diagram. The diagram carries the
  section, so everything here exists to keep it legible.

  ── The ground was ink black until 2026-09-07 ───────────────────────────────

  Asked for on that date, and the reason given was reading rather than taste: a
  near-black band made the section read as a separate dark software demo dropped
  into the middle of a light site, and every value in it had to be tuned to the
  bottom of the contrast range to survive the ground. The resting state of the ring
  is what that cost. The diagram's first claim is "the platform has eighteen named
  capabilities" and its second is "these three are working right now"; on black the
  first claim had 3.9:1 of edge and 5.4:1 of name to make itself with, and the
  section was still being read as four modules floating in a dark circle.

  On paper the same drawing makes both claims without a glow anywhere: the inactive
  module is a white disc with a 3.5:1 grey ring, a 7.2:1 icon and a 5.4:1 name, and
  active is a *hue* change on top of that rather than a brightness one.

  ── What did NOT change with it ─────────────────────────────────────────────

  The six meanings below, the geometry, the eight stages, the module set and the
  centre. Only the ink moved. Six meanings on a light ground are the same six
  meanings — a colour vocabulary that re-assigned itself when the background changed
  would not be a vocabulary — so every entry in the table still points at the same
  brand accent, and the only edits the flip needed were the ones that flip *with* a
  ground: an ink that was white becomes `--ink`, a value lightened towards white for
  legibility on black is now darkened towards black for legibility on paper, and the
  four hardcoded near-blacks (`#0f0f0f`, `#0b0b0b`, `#101216`, `#6a6a6a`) are gone in
  favour of the tokens they should have been.

  Orange is still only the expert team and human judgment, and blue is still only the
  software: the temptation the flip creates is to spend orange decoratively because
  it is handsome on white, and the whole diagram stops meaning anything the moment a
  second thing is orange.

  ── Six meanings, and the brand's own six accents ───────────────────────────

  Colour in this diagram follows the function, never the stage. A step is rarely
  one colour: SOURCING dispatches an RFQ, reads a price feed, hears back from a
  mill and confirms a certification, and those are four different things.

    ai      blue    software, and information being processed
    human   orange  the expert team — judgment, review, a supplier conversation
    exec    purple  an approved action being dispatched into the network
    hold    yellow  under consideration: shortlisted, pending confirmation
    ok      green   confirmed, approved, passed
    alert   red     an exception. Rare, and never decorative.

  The values are the theme's functional accents, aliased to `--pf-*` names that
  say what they mean here. They used to be section-local hex — `#2855f6` for
  blue, `#6e2ca8` for purple — which is a second palette next to the brand's,
  and the reason the section never quite looked like the rest of the site.

  Purple is `--network`, not `--lot-purple`: tokens.css fences that value out of
  everything that reports a status, and this is a status. Green and blue also
  need lightened variants where they have to be read as 12px words; both are
  mixed from the same token so a retune moves the pair together.

  ── Colour is never the only difference ─────────────────────────────────────

  A reader who cannot separate the blue from the orange still has to be able to
  separate an AI path from a human one, so the three tones a line can carry also
  differ in style:

    ai        solid, thin        round packet
    human     dashed            diamond packet
    exec      solid, thicker    square packet

  The other three never reach a line: hold, ok and alert only ever appear on a
  node or on a tag, where a word is sitting beside the colour saying the same
  thing.

  ── Type size comes from the layout, not from here ──────────────────────────

  One SVG user unit is one CSS pixel in this section, and `content/platform.js`
  sizes every label against the box it is laying out — 12px at desktop, 11px
  where the arcs tighten. Those sizes arrive as `font-size` attributes. Do not
  set `font-size` on the SVG text classes: it would silently detach the rendered
  width from the width the collision pass reserved for it.
*/

.pf {
  /*
    The page gutter, like every other section: this one is full bleed and used to
    set its own 20/32px inset, which put the intro 32px to the left of the hero
    headline instead of level with it.
  */
  --pf-pad: var(--gutter);
  /*
    The paper family, bound the way `.adv`, `.process` and `.mission` bind it, and
    `--paper-white` rather than the light theme's `--pure-white` because the diagram
    is a technical drawing and a drawing on pure white is a screenshot. `--fg` needs
    no entry: the light theme's is already `--ink`.

    `--bg` rather than a `background` declaration, because `.section` in base.css
    already paints `var(--bg)` and transitions it — the dark section overrode both
    that and `color`, which is how it ended up with a ground no theme knew about.
  */
  --bg: var(--paper-white);
  --line: var(--paper-line);
  --line-strong: var(--paper-line-strong);
  --fg-sub: var(--paper-sub);

  --pf-ai: var(--lot-blue);
  /*
    ── The four text-safe accents ─────────────────────────────────────────────

    Measured on `--paper-white` #FAFAF8, which is what every word in this section is
    printed on: purple 5.6:1 and green 4.7:1 clear 4.5:1 as the token stands, so a
    tag or a row can use it flat. Blue #2B8FC2 at 3.5:1, orange #FF571F at 3.0:1,
    yellow #FFB640 at 1.7:1 and red #F42B2F at 4.0:1 cannot, so those four get a
    darkened sibling — and it is `black` in the mix now where the dark theme mixed
    `white`, which is the whole of what the flip did to this block.

    The four are variants of the same hue, not new colours: `--pf-hold-text` is the
    yellow at the only strength 11px type can carry it, and the two that already
    existed (`--pf-ai-text`, `--pf-ok-text`) are why the pattern is here at all. The
    alternative was six inline `color-mix()` calls, four of them written twice.

      --pf-ai-text     #1F678C  5.6:1
      --pf-human-text  #B83F16  5.6:1
      --pf-hold-text   #94692B  4.9:1
      --pf-alert-text  #C82327  5.6:1

    Non-text keeps the flat token: an edge, a packet and an arrowhead are 3:1 objects
    and all six accents clear that, so a line and the word beside it stay the same
    colour to a reader while each is measured against its own bar.
  */
  --pf-ai-text: color-mix(in srgb, var(--lot-blue) 72%, black);
  --pf-human: var(--lot-orange);
  --pf-human-text: color-mix(in srgb, var(--lot-orange) 72%, black);
  --pf-exec: var(--network);
  --pf-hold: var(--lot-yellow);
  --pf-hold-text: color-mix(in srgb, var(--lot-yellow) 58%, black);
  --pf-ok: var(--lot-green);
  --pf-ok-text: var(--lot-green);
  --pf-alert: var(--lot-red);
  --pf-alert-text: color-mix(in srgb, var(--lot-red) 82%, black);
  /*
    ── The ink ladder ────────────────────────────────────────────────────────

    Three steps, all of them from the paper family, all of them past AA on
    `--paper-white` as 11–12px mono: 19.2:1, 7.2:1 and 5.4:1. It used to be #FFFFFF /
    #A1A1A1 / #747474 — where the third step was 4.4:1 and the section's least
    readable text — and the point of taking the family's own three values instead is
    that `--paper-sub` and `--paper-draw` are already calibrated against this ground
    by tokens.css, so a retune there moves the diagram with the rest of the page.
  */
  --pf-fg: var(--ink);
  --pf-fg-sub: var(--paper-draw);
  --pf-fg-mute: var(--paper-sub);
  /*
    Structure, and not text: the base edge stroke. It was also the resting colour of
    the eight printed stages, which is a conflation the flip made untenable — #292929
    on black was 1.4:1 either way and nobody had to choose, whereas a hairline subtle
    enough for eighteen converging spokes is nowhere near a grey a stage number can be
    printed in. The stages read from the ink ladder now; this draws lines only.
  */
  --pf-idle: var(--paper-line-strong);
  /** Dividers and hairline separators: the section's rules, at the page's weight. */
  --pf-edge: var(--paper-line);

  /*
    ── The eighteen modules, state A ─────────────────────────────────────────

    A capability the ring is not using right now still has to be *readable*: the
    diagram's first claim is "this is the whole capability set", and only its
    second is "these two or three are working". The dark section reached this
    conclusion once already and got as far as the ground would carry it — a
    graphite disc with a neutral grey edge, at 3.93:1 for the edge, 4.99:1 for the
    icon and 5.43:1 for the name, up from 1.28 / 2.96 / 3.70 before that. It was
    the top of what a near-black field allows without the disc starting to glow.

    On paper the same three claims are cheap. State A is now a *white* disc — the
    page's own "a panel is a bordered area, not a card floating on a darker ground"
    — and the ring around it is what says the module is there:

      edge  --lot-grey-400 #858585   3.5:1 on the ground, 3.7:1 on its own disc
      icon  --paper-draw   #555550   7.2:1   (a 1.5px stroke, bar is 3:1)
      name  --paper-sub    #686864   5.4:1   (12px mono, bar is 4.5:1)

    Icon a step *darker* than the name, not lighter, and for the reason the dark
    theme had it a step brighter: 1.5px of stroke carries less ink than 12px of
    type, so equal values do not read equal. The flip inverts the direction of the
    correction, not the correction.

    The edge is `--lot-grey-400` and it is the one value here from outside the paper
    family, because the family has nothing between `--paper-line-strong` #B9B9B3 at
    1.9:1 — an ultra-light hairline that is exactly what eighteen discs must not
    disappear into — and `--paper-draw` #555550 at 7.2:1, which drawn round all
    eighteen at 1.2px reads as a printed rule and makes the resting ring louder than
    the lit one. #858585 is the rung between them and this section already consumes
    five canonical accents, so it is not a new dependency.

    Active is still unmistakable, and still by hue rather than by brightness alone:
    a grey ring becomes a blue one at 5.2:1 over a pale blue fill, a grey icon
    becomes `--pf-ai-text` at 5.6:1, and a grey name becomes `--ink` at 19:1.
  */
  --pf-mod-fill: var(--pure-white);
  --pf-mod-line: var(--lot-grey-400);
  --pf-mod-icon: var(--paper-draw);
  --pf-mod-text: var(--paper-sub);

  padding: clamp(88px, 9vw, 120px) var(--pf-pad);
}

/* ── Head ────────────────────────────────────────────────────────────────── */

/*
  There is nothing here, and that is the fix.

  This section used to bring its own `.pf-intro` — a 52-to-96px heading at weight
  600 with its own line-height and tracking, and a 19-to-26px lead. Every other
  section on the page uses `.head` from base.css: `--t-h2` at weight 800, 0.96
  line-height, `--t-lead` at 1.55. So the platform heading rendered at a size
  between the hero's and everybody else's, which is exactly how you build a second
  hero halfway down a page. It renders through `head()` now like the rest of them,
  `.lead` already takes `--fg-sub`, and `data-theme="dark"` already rebinds that.

  The closing statement that sat under the diagram is gone too — its markup, its
  copy and this rule. It was display-size, and it restated the heading.
*/

/* ── Case line and the printed plan ──────────────────────────────────────── */

/*
  Which product the diagram is running, and where in the plan it has got to.

  The diagram cannot say either by itself. A highlight moving over eighteen names
  reads as activity rather than as consequence, and the interesting fact about a
  first production — that Virtual Design and Sampling run hard while Transport
  stays dark — is invisible unless you know it is a first production.

  The title row reserves its height by holding all three titles at once, hidden,
  so it is always as tall as the tallest and never reflows when the case changes.
  Measured rather than guessed: a two-line reservation was 40px of dead space at
  719px and one line short at 340px.
*/
.pf-case {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: baseline;
  column-gap: 18px;
  row-gap: 6px;
  margin-top: clamp(48px, 5vw, 64px);
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 1.35;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.pf-case-no {
  color: var(--pf-fg-mute);
  white-space: nowrap;
}

/* The product itself is a sentence, not a label, so it keeps sentence case and the
   body face — the mono around it is doing the annotating. */
.pf-case-title {
  display: grid;
  font-family: var(--font-display);
  font-size: clamp(15px, 1.15vw, 17px);
  letter-spacing: 0;
  text-transform: none;
  color: var(--pf-fg-sub);
}

/* One cell, four occupants: the live title and the three that size the row. */
.pf-case-title > span {
  grid-area: 1 / 1;
}

.pf-case-ghost {
  visibility: hidden;
  user-select: none;
}

/*
  The plan: eight stages, all of them printed, with the running one lit.

  Every step is in the DOM at every moment and only its state changes, so the row
  never reflows as the loop advances — and the visitor can read the whole plan in
  one go, before the animation has taken them anywhere. A step ahead of the marker
  is dim, a step behind it has been done and stays legible, and the current one is
  lit in one colour, whatever the beat is doing.

  Eight, and they are the eight the buyer portal shows. It printed eleven, four of
  which — SOURCING, QUESTIONS, COSTING, APPROVAL — are steps inside choosing a
  factory rather than stages of an order, so the strip told a buyer they track four
  things they do not. Those four are in `.pf-act` now.
*/
.pf-plan {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 7px 12px;
  margin-top: 18px;
  list-style: none;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.2;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/*
  Three states, and all three of them are now printed rather than implied.

  `next` used to be `--pf-idle` #292929 — 1.4:1, a stage a reader could not read at
  all until the marker reached it, which is the opposite of what a printed plan is
  for. The three read from the ink ladder instead, in the order the dark section
  meant: `next` at 5.4:1 is the whole plan, `done` at 7.2:1 is the part that has
  happened, `now` at 19:1 plus the blue rule under the word is where the diagram is.
*/
.pf-plan-step {
  display: flex;
  align-items: baseline;
  gap: 6px;
  color: var(--pf-fg-mute);
  transition:
    color 0.45s ease,
    opacity 0.45s ease;
}

/* A hairline between steps, so eleven words read as one sequence rather than as
   eleven labels. On the pseudo-element, so no separator markup exists to wrap
   onto a line of its own. */
.pf-plan-step + .pf-plan-step::before {
  content: '';
  align-self: center;
  width: 10px;
  height: 1px;
  margin-right: 6px;
  background: var(--pf-edge);
}

.pf-plan-no {
  font-size: 9px;
  letter-spacing: 0.04em;
  color: var(--pf-fg-mute);
  transition: color 0.45s ease;
}

.pf-plan-step[data-state='done'] {
  color: var(--pf-fg-sub);
}

.pf-plan-step[data-state='now'] {
  color: var(--pf-fg);
}

/*
  The marker: one blue, on the number and under the word, at every step.

  It used to take the tone of the beat, so the rail turned orange on SAMPLES and
  purple on PRODUCTION. That reads as a per-stage colour code — it invites a legend
  the section does not have, and it spends orange and purple on position, which is
  the one thing in the composition that has nothing to do with either. Position is
  all the rail is for, and the current step is already distinguishable from the ones
  behind it by weight and by the rule under the word, so the colour is free to mean
  what it means everywhere else: this is the software's account of where the work is.
*/
.pf-plan-step[data-state='now'] .pf-plan-no {
  color: var(--pf-ai-text);
}

.pf-plan-step[data-state='now'] .pf-plan-word {
  padding-bottom: 3px;
  border-bottom: 1px solid var(--pf-ai-text);
}

/*
  What is happening *inside* the marked stage.

  This is where the eleven-step plan's detail went. Collapsing eleven top-level
  steps to eight is only an improvement if nothing true was lost with the three:
  FACTORY MATCHING holds the rail for four beats while this line moves through the
  RFQ, the quotes, the capacity checks, the cost comparison, the shortlist and the
  negotiation. The stage is what a buyer tracks; the action is what is happening.

  Quieter than the lit step above it and louder than the ones behind it, which is
  its place in the reading order: the strip says where the work is, this says what
  it is doing, and the rule underneath closes both as one block above the diagram.

  Its height comes from every action it can print, stacked hidden in one grid cell —
  the device `.pf-case-title` uses, for the same reason. The strings run from 14 to
  70 characters and the line sits directly on top of the composition, so a row
  sized to the current action would shove the diagram down every 2.6 seconds.
*/
.pf-act {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: start;
  column-gap: 10px;
  margin-top: 11px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--pf-edge);
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.35;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--pf-fg-sub);
}

/* Tied to the marker above it by taking the marker's blue — as a 10px rule, the
   same mark the plan already uses between its steps, rather than another word. */
.pf-act::before {
  content: '';
  width: 10px;
  height: 1px;
  margin-top: 0.62em;
  background: var(--pf-ai-text);
}

.pf-act-value {
  display: grid;
}

/* One cell, every occupant: the live action and the ones that size the line. */
.pf-act-value > span {
  grid-area: 1 / 1;
}

.pf-act-ghost {
  visibility: hidden;
  user-select: none;
}

@media (max-width: 719px) {
  .pf-case {
    grid-template-columns: 1fr;
  }

  .pf-case-title {
    grid-row: 2;
  }

  /* Numbers only would be unreadable and words only would lose the count, so on a
     phone the plan keeps both and wraps to as many rows as it needs. */
  .pf-plan {
    gap: 8px 10px;
    font-size: 10px;
    letter-spacing: 0.06em;
  }

  .pf-plan-step + .pf-plan-step::before {
    width: 6px;
    margin-right: 4px;
  }

  .pf-act {
    column-gap: 8px;
    font-size: 10px;
    letter-spacing: 0.06em;
  }

  .pf-act::before {
    width: 6px;
  }
}

/* ── Stage ───────────────────────────────────────────────────────────────── */

.pf-stage {
  position: relative;
  margin-top: clamp(24px, 2.5vw, 32px);
}

.pf-svg {
  display: block;
  width: 100%;
  height: auto;
  /*
    The baked geometry is the 1440 case. Left to grow it would scale up on a
    wider screen, and scaling an SVG scales its type: 12px labels would render
    at 16px and the whole composition would read as a zoom. `js/platform.js`
    re-lays-out against the real box instead and sets `data-mounted`, at which
    point the element is sized in real pixels and the cap has to come off.
  */
  max-width: 1376px;
  overflow: visible;
}

.pf[data-mounted] .pf-svg {
  max-width: none;
}

/*
  One caption area, always present, never resized by its content — the required
  mobile behaviour, kept on every width so the same code path runs everywhere.
  Two lines of reserved height: on a phone the name and the description stack.
*/
.pf-caption {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 12px;
  align-items: baseline;
  /*
    Held open, because the description arrives on hover and a strip that grows
    when you point at something moves the closing line under your cursor. No rule
    above it: empty for most of the section's life, a border would read as a
    divider that had been left behind rather than a place a caption goes.
  */
  min-height: 2.8em;
  margin-top: 22px;
  font-size: var(--t-small);
  line-height: 1.5;
}

.pf-caption-name {
  font-family: var(--font-mono);
  font-size: var(--t-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--pf-fg);
}

.pf-caption-desc {
  color: var(--pf-fg-sub);
}

/*
  From 720px up the caption moves inside the composition. Below the diagram it was
  below the fold at every desktop size — the visual is as tall as the window, so a
  strip underneath it is only on screen when the thing you are pointing at is not —
  and a description that arrives where nobody is looking has not been shown.

  Bottom right is the one corner the geometry leaves empty in both desktop
  regimes: the right third's lowest content is the Inspection label, and it clears
  the bottom edge by 109px at 1024 and 135px at 1920. Still one fixed area, and
  absolute now, so it cannot move the closing line no matter how long the
  description is. Transparent to the pointer because it lies over the diagram, and
  it is decorative anyway — the names are on the nodes and in the index.
*/
/*
  Inside the composition by default, in flow only in the stacked one. That way
  round because the default also has to be right before the script has run: keyed
  the other way, the caption sat in flow until mount and then jumped into the
  corner, taking 61px of section height with it.
*/
.pf-caption {
  position: absolute;
  right: 0;
  bottom: 0;
  justify-content: flex-end;
  width: min(34ch, 30%);
  margin-top: 0;
  text-align: right;
  /* Over the diagram, and decorative — the names are on the nodes and in the
     index — so it must not take the pointer off a node underneath it. */
  pointer-events: none;
}

/*
  The column has no corner to spare: its lowest content clears the bottom edge by
  23px, where the ring composition leaves 109–135px. So on a phone the caption
  goes back under the diagram, which is the fixed caption area the phone wants
  anyway — directly below the ring you just tapped.
*/
.pf[data-regime='stack'] .pf-caption {
  position: static;
  justify-content: flex-start;
  width: auto;
  margin-top: 22px;
  text-align: left;
  pointer-events: auto;
}

/*
  Room for the column before the script has decided there will be one.

  The static file bakes the desktop geometry, so on a phone the untouched SVG is a
  350×179 letterbox and mounting replaces it with a column three times that — most
  of a screen of jump. The stacked height is a function of width alone, so it can be
  reserved: everything above and below the ring is fixed at 867px, and the ring is
  twice a radius of `min(stage / 2 - 24, 210)`. Plus the 61px the caption takes once
  it is back in flow. `100vw - 92px` is that stage width at the 22px gutter, which is
  the gutter in the only band where the radius is not already at its 210 cap — above
  a 520px viewport the whole expression is the constant 1287.

  1002px, not the 899px this said before: three cards and their titles need more
  stage than nineteen dots did, so `ringLayout` now returns null below a 902px stage,
  and 902 is 90% of 1003. Reserving the ring's height where the script builds a
  column is the 600px jump this rule exists to prevent, in the other direction.
*/
@media (max-width: 1002px) {
  .pf-stage {
    min-height: calc(928px + min(100vw - 92px, 420px));
  }
}

/*
  And the same reservation for the ring, where it is simply the band the script
  measures: `min(920, max(680, innerHeight * 0.78))` is this clamp. Without it the
  baked 1376×702 viewBox letterboxes to whatever the width implies — 502px at
  1024 — and mounting pushes the page down by the difference.
*/
@media (min-width: 1003px) {
  .pf-stage {
    min-height: clamp(680px, 78vh, 920px);
  }
}

/* ── Diagram · connections ───────────────────────────────────────────────── */

.pf-edge {
  fill: none;
  stroke: var(--pf-idle);
  stroke-width: 1;
  stroke-linecap: round;
  transition:
    stroke var(--dur) var(--ease),
    stroke-width var(--dur) var(--ease),
    opacity var(--dur) var(--ease);
}

/*
  The spokes are the densest set — 18 of them converging — so they sit lower.

  Lower than the base edge, which on paper means lighter rather than darker: mixed
  back into the ground until it is 1.25:1, the same weight the section shipped these
  at on black (#232323 on #080808 is 1.27:1). Eighteen radial lines are the one thing
  in the composition that turns into a spiderweb the moment they are given enough
  contrast to be looked at rather than felt, and they are structure — the modules and
  the flows are what carries meaning.
*/
.pf-edge--spoke {
  stroke: color-mix(in srgb, var(--pf-idle) 52%, var(--bg));
}

/*
  The three main connections: one per peripheral card, and the only line each card
  has. A step under the trunks because they are the longest lines in the frame and
  the trunks are the ones a party actually travels through — but only a step, since
  these three carry most of the flow labels.

  There were nineteen of these, one per row, drawn permanently. Three is not a
  simplification of that drawing; it is the accurate count. A row is not connected
  to the platform on its own — the card is.
*/
.pf-edge--card {
  stroke: color-mix(in srgb, var(--pf-idle) 78%, var(--bg));
}

/*
  Three tones, three weights, and the weights are as close as they can be while
  still being told apart: 1.2, 1.4 and 1.6. The purple line used to be 2.2, which
  is a pipe rather than a connection, and it made every dispatch look like the
  loudest event on the page whether or not it was.
*/
.pf-edge.is-ai {
  stroke: var(--pf-ai);
  stroke-width: 1.2;
}

.pf-edge.is-human {
  stroke: var(--pf-human);
  stroke-width: 1.4;
  stroke-dasharray: 6 4;
}

.pf-edge.is-exec {
  stroke: var(--pf-exec);
  stroke-width: 1.6;
}

/* Focusing one function pushes everything it does not touch back down. */
.pf-svg.is-isolating .pf-edge:not(.is-lit) {
  opacity: 0.35;
}

.pf-svg.is-isolating .pf-edge.is-lit {
  stroke: var(--pf-ai);
  stroke-width: 1.6;
}

/* ── Diagram · the AI layer ──────────────────────────────────────────────── */

/*
  One circle, and it is the boundary of the AI layer rather than an object in its
  own right: an edge findable without being drawn.

  Four things used to be here with it — a second dashed "data ring", a 16px halo
  that brightened on research steps, a dash that swept the circumference, and a
  gradient plane that scanned across it. None of them reported anything. Together
  they were most of the movement in the frame, and all of it was competing with the
  lines that were saying something.
*/
.pf-ring {
  /*
    No interior wash, and no blue in the stroke either.

    It was 34% of `--pf-ai` — a blue circle 700px across, which on a light ground
    stops reading as a boundary and starts reading as the largest active thing in the
    frame. Blue in this diagram means one specific claim: *this capability is working
    right now*. A permanent blue ring round all eighteen makes that claim about the
    whole set at every moment, and the three modules that are genuinely lit have to
    out-shout it.

    So it is neutral: `--pf-idle`, the same value the two trunks are drawn in, at
    1.9:1. Visible as a circle, subordinate to every module standing on it, and it
    changes nothing about the interior — the wash was never visible as a tint on
    black either, only as the hard-edged plate that every tag needed to punch the
    spokes out from behind a word.
  */
  fill: none;
  stroke: var(--pf-idle);
  stroke-width: 1;
}

/*
  Nothing labels the circle, and that is deliberate.

  There was a plated `AI SOURCING PLATFORM` on its upper edge — a title for the
  whole composition sitting on one of its parts, and the brightest permanent text
  in the frame after the expert team. Eighteen named software capabilities on a ring
  around a circle marked LIBRARY OF TRADE · EXPERT TEAM already say what the ring
  is; a caption saying it again only competes with the centre for the subject. It
  has no replacement: `.pf-ai-label` and `.pf-ai-name` are gone with it.
*/

/* ── Diagram · the expert team ───────────────────────────────────────────── */

.pf-core-disc {
  fill: var(--pf-human);
}

/*
  A hairline outside the disc, not a glow. The 14px orange bloom this replaces went
  to 40% opacity on the approval beat, which is the largest and brightest thing the
  section ever drew — for a step whose whole point is that one person made a
  decision. A 1px ring 7px out is the same statement at a tenth of the volume.

  Both opacities are up, because the arithmetic reverses: 28% of orange over black
  composites *away* from the ground and stays a visible warm hairline, while 28% over
  #FAFAF8 composites towards white and disappears. 55% and 100% put the resting ring
  and the approval ring back where they were relative to the ground — still a
  hairline, still not a glow, and the disc it surrounds is unchanged.
*/
.pf-core-ring {
  fill: none;
  stroke: var(--pf-human);
  stroke-width: 1;
  opacity: 0.55;
  transition: opacity var(--dur-slow) var(--ease);
}

.pf-core.is-hot .pf-core-ring {
  opacity: 1;
}

.pf-core-name {
  font-family: var(--font-mono);
  font-weight: 500;
  letter-spacing: 0.09em;
  fill: var(--ink);
  text-transform: uppercase;
}

/*
  What the team is doing at this step, inside its own circle.

  Dark on orange like the name above it, and held back so the name still reads as
  the label and this as the state — four lines of equal weight inside a 152px disc
  would compete. The rule between them is what makes it a second field rather
  than a third line of the name.

  0.78 and not the 0.7 it was, which is the one number the flip moved on this disc
  even though the disc itself did not move. `--ink` on `--lot-orange` is 6.33:1 at
  full strength, and 0.7 of it composites to 4.23:1 — under the bar for a 10px mono
  line, and the arithmetic was the same before the flip, so this was simply wrong on
  black too and nobody had measured it. 0.78 lands at 4.90:1 and is still visibly a
  step behind the name, which was the whole point of holding it back.

  Always a human verb: READING BRIEF, REVIEWING SHORTLIST, NEGOTIATING, APPROVING.
  Never SEARCHING or COMPARING — those are the ring's, and the whole point of the
  two of them being separate objects is that they are doing different work.
*/
.pf-core-now {
  font-family: var(--font-mono);
  font-weight: 500;
  letter-spacing: 0.1em;
  fill: var(--ink);
  opacity: 0.78;
  text-transform: uppercase;
}

.pf-core-rule {
  stroke: var(--ink);
  stroke-width: 1;
  opacity: 0.3;
}

/* ── Diagram · platform functions ────────────────────────────────────────── */

.pf-hit {
  fill: transparent;
  stroke: none;
}

.pf-fn {
  cursor: pointer;
}

/*
  Eighteen modules, and at most four of them lit.

  That ratio used to be the argument for making the other fourteen almost invisible:
  a graphite disc with a 22% blue edge and a #6a6a6a name, so that a blue fill and a
  white name would have somewhere to go. It went too far. The section's first job is
  to show that the platform *has* eighteen named capabilities, and at 1.28:1 the
  inactive edge did not survive being looked at — the ring read as four modules
  floating in a dark circle, and a reader had to sit through the loop to find out
  what the other fourteen were.

  Three states, and the separation is hue and emphasis rather than presence:

    A  idle       white fill, medium grey edge, dark grey icon, grey name — no blue
    B  is-on      blue edge, pale blue fill, dark blue icon, ink name
    C  is-active  the same blue system, one step darker and one step thicker

  Edge contrast against the ground runs 3.5 : 5.2 : 6.0, so the three read in order at
  any size, and none of them is a glow. The ratios are in the `.pf` token block.

  State C is the pointer's and the keyboard's, not the loop's: the driver only ever
  sets `is-on`, so the diagram never nominates one of its lit modules as the primary
  one. That is deliberate — three modules working at once is what the beat means, and
  ranking them would be a claim the layout cannot support — and C is what a reader
  gets when they ask about a specific module themselves.
*/
.pf-fn-disc {
  fill: var(--pf-mod-fill);
  stroke: var(--pf-mod-line);
  /*
    1.2 rather than 1: a 1px circle at r=26.5 lands between device pixels most of
    the way round, and antialiasing spends about half the contrast the colour buys.
  */
  stroke-width: 1.2;
  transition:
    fill var(--dur) var(--ease),
    stroke var(--dur) var(--ease);
}

/*
  1.5 rather than 1.4 for the same reason, and it is the reason `stroke-width` is
  set here and not per icon: one weight for all eighteen is the thing that makes
  them a family. `--pf-mod-icon` is a shade *darker* than the name it sits under,
  because a 1.5px stroke reads lighter than 12px type of the same colour — the same
  correction the dark section made in the other direction, when darker meant fainter.
*/
.pf-fn-icon {
  fill: none;
  stroke: var(--pf-mod-icon);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: stroke var(--dur) var(--ease);
}

/*
  The one exception, and it is here rather than in the path data so the family's
  weight stays in one place. Virtual Design is a single long curve plus two small
  squares: 59 units of path against `warehouse`'s 77 and `finance`'s 88, most of it
  smooth, so at the family's 1.5 it reads a step lighter than its neighbours do.
*/
.pf-fn-icon--vector {
  stroke-width: 1.7;
}

.pf-fn-name {
  font-family: var(--font-mono);
  letter-spacing: 0.08em;
  fill: var(--pf-mod-text);
  transition: fill var(--dur) var(--ease);
}

/*
  Lit is lit is blue. There used to be a second lit state — `is-approved`, purple —
  which the driver applied to every module on the production step, so four discs
  changed what they claimed to be because the plan had moved on. The ring is the AI
  layer at every step of every case; what changes is which modules are in use.

  The edge is `--pf-ai` darkened with 22% black, not the token flat. Flat it is 3.5:1,
  which is exactly where state A's grey edge already sits — the two would be the same
  weight in different hues, and a reader who cannot separate the hues would have
  nothing left. At 78/22 it is 5.2:1, a 1.5× step, and still unmistakably the same
  blue. On black this mix went the other way, towards white, for the identical reason.

  The fill is 12% of that blue in white: a wash rather than a surface. Requirement is
  a pale-blue tint that says "this disc is the lit kind" from across the composition
  without turning eighteen circles into eighteen coloured buttons, and 12% is the most
  it can take before the dark blue icon standing on it starts losing contrast.
*/
.pf-fn.is-on .pf-fn-disc {
  fill: color-mix(in srgb, var(--pf-ai) 12%, var(--pure-white));
  stroke: color-mix(in srgb, var(--pf-ai) 78%, black);
  stroke-width: 1.5;
}

/*
  Blue rather than the ink the name takes, and this is the one place the flip changed
  a *decision* rather than a value. On black, lit meant white for both the icon and
  the name: white was the brightest thing available and there was nothing above it. On
  paper there is — ink — so the two can say different things. The name is what a
  reader is looking for and takes the strongest value in the section, and the icon is
  the mark that says which system the module belongs to, so it takes the blue.
*/
.pf-fn.is-on .pf-fn-icon {
  stroke: var(--pf-ai-text);
}

.pf-fn.is-on .pf-fn-name {
  fill: var(--pf-fg);
}

/* The state the pointer or the keyboard is on, over and above being lit. */
.pf-fn.is-active .pf-fn-disc {
  fill: color-mix(in srgb, var(--pf-ai) 20%, var(--pure-white));
  stroke: color-mix(in srgb, var(--pf-ai) 66%, black);
  stroke-width: 1.8;
}

.pf-fn.is-active .pf-fn-icon {
  stroke: var(--pf-ai-text);
}

.pf-fn.is-active .pf-fn-name {
  fill: var(--pf-fg);
}

/*
  Holding one module dims the rest. 0.8 rather than the 0.55 the dark section used and
  the 0.4 before that: state A is a white disc with a #686864 name on it, and 0.55 of
  that composites to 2.4:1 — which puts the whole ring back where this pass found it
  for as long as a pointer is resting on one node. At 0.8 it is 3.5:1.

  Still short of the 4.5:1 the resting state clears, and knowingly: this state exists
  only while a pointer or the keyboard is *on* a module, the thing being pointed at is
  the subject, and the alternative — dimming the disc and the icon but not the name —
  costs a second pair of selectors to make an interaction cue out of two channels
  instead of three. The resting presentation is what the contrast table measures.
*/
.pf-svg.is-isolating .pf-fn:not(.is-active) {
  opacity: 0.8;
}

.pf-fn:focus {
  outline: none;
}

.pf-fn:focus-visible .pf-fn-disc {
  stroke: var(--pf-fg);
  stroke-width: 2;
}

/* ── Diagram · portals ───────────────────────────────────────────────────── */

.pf-portal {
  cursor: pointer;
}

/*
  Both portals are structure: the two doors the traffic goes through, graphite with
  a hairline, identical to each other and coloured by neither side.

  They used to be a blue disc on the left and an orange disc on the right, both
  lightened towards white — which said the Customer Portal was part of the AI layer
  and the Vendor Portal was a second expert team. The doors are not what is
  happening; the lines running through them are, and those lines carry the tone. A
  door has one job here, which is to be visibly the way in.
*/
/*
  `--paper-grey` is the family's other surface and it is here because a door is not a
  panel: the three cards are white and the two portals are a step off it, which is the
  only difference the composition draws between the two kinds of object standing on the
  ground.

  The edge is `--lot-grey-400` #858585 at 3.5:1 — the same value as an idle module, and
  the step down to the modules is bought with weight rather than with colour: 1px here
  against their 1.2. `--lot-grey-300` #A8A8A5 was tried first, on the argument that a
  200px circle needs a lighter ring than a 53px one, and it measured 2.3:1. That is the
  argument losing to a rule: a portal is a `cursor: pointer` object with its own
  `:focus-visible`, so its boundary is an interactive control's boundary and 3:1 is not
  negotiable for it the way it would be for the cards' decorative outline. Thinner at
  the same tone still reads as the quieter of the two rings, which is all the hierarchy
  needed. `--line-strong` was doing this job and the light theme's is #C9C9C9 at 1.7:1,
  which on a circle that large is a smudge.
*/
.pf-portal-disc {
  fill: var(--paper-grey);
  stroke: var(--lot-grey-400);
  stroke-width: 1;
  transition:
    fill var(--dur) var(--ease),
    stroke var(--dur) var(--ease);
}

/*
  Deliberately below the peripheral names in weight, not above. The two portals are
  fourth in the hierarchy — behind the expert team, the active capabilities and the
  flows that are running — and at `--pf-fg-sub` they were the brightest permanent
  text in the frame, which read as the doors being the subject.
*/
.pf-portal-name {
  font-family: var(--font-mono);
  font-weight: 500;
  letter-spacing: 0.08em;
  fill: var(--pf-fg-mute);
  text-transform: uppercase;
}

/* In use: a stronger edge, and nothing else. `--pf-fg-mute` rather than the
   subtler-sounding `--pf-fg-sub`, which at 1.4px around a 200px circle is the
   hardest ring in the frame and pulls ahead of the orange team. */
.pf-portal.is-hot .pf-portal-disc {
  stroke: var(--pf-fg-mute);
  stroke-width: 1.4;
}

.pf-portal:focus {
  outline: none;
}

.pf-portal:focus-visible .pf-portal-disc {
  stroke: var(--pf-fg);
  stroke-width: 2.4;
}

/* ── Diagram · the three peripheral cards ────────────────────────────────── */

/*
  One card system, drawn three times: what the buyer supplies, what the software
  goes and reads on its own, and who physically executes. Same fill, same border,
  same radius, same mono heading, same row treatment, same active state — they are
  three answers to the same kind of question, so any difference between them would
  be a claim the diagram does not mean to make.

  What stood here instead was nineteen marks — thirteen dots and six glyphs — each
  with a leader line to a name and a permanent connection into the middle. Nineteen
  lines is not a picture of a relationship, it is a picture of a cable tray, and no
  beat ever used more than two of the nineteen at once. Three cards carry the same
  information: the traffic is between the platform and that side of the trade, and
  which rows are in it is said by the rows themselves.
*/
.pf-card-box {
  /*
    Opaque, a step off the section's own ground, and under everything else in the
    frame. The fill is doing two jobs: it groups the rows into one object, and it is
    what lets a card sit close to the ring without a spoke reading as running under
    it. Both survive the flip unchanged — `--pure-white` against a `--paper-white`
    ground is the page's own "a panel is a bordered area of the page rather than a
    lighter card floating on a darker one", which is exactly what a 1.03:1 step plus a
    hairline buys and why there is no shadow, no radius change and no gradient here.

    Border at `--paper-line-strong` rather than `--pf-edge`: the divider token is the
    page's rule weight (1.4:1) and it is right for the two horizontal rules in the
    section, but a 1.4:1 outline round a white block on off-white is a card with no
    edge at all. 1.9:1 is a fine grey line that can be seen and still cannot compete
    with anything inside it.
  */
  fill: var(--pure-white);
  stroke: var(--paper-line-strong);
  stroke-width: 1;
}

.pf-card-title {
  font-family: var(--font-mono);
  font-weight: 500;
  letter-spacing: 0.16em;
  fill: var(--pf-fg-mute);
  text-transform: uppercase;
}

/*
  A row is its name, and nothing else.

  No dot, no glyph, no bullet, no rule in the gutter: `Target cost` in grey and
  `Target cost` in ink is the whole state change, and a marker in front of it
  would say nothing the word does not while five of them read as a styled list.
  The six glyphs this replaces were load-bearing when the buyer's inputs were six
  loose nodes in open space — they are not, inside a titled card that names them.

  Muted is the resting state and it is most of the loop: a beat lights one to three
  rows across all three cards. Six factories and mills all named at full strength was
  a list of everyone we work with; two of them is which two are in this conversation.

  `--pf-fg-mute` and not the #6A6A6A this was: the hardcode happened to measure 5.4:1
  on white, which is the same as the token, so nothing here moved by a value a reader
  can see — but a muted row and a portal name and a card title are the same decision
  written down three times, and only one of the three was reachable from the ladder.
*/
.pf-row-name {
  font-family: var(--font-mono);
  letter-spacing: 0.06em;
  fill: var(--pf-fg-mute);
  transition: fill var(--dur) var(--ease);
}

/*
  ── A row's state, which is not the same thing as its traffic ──

  Active is ink: the row is one of the one-to-three things this beat is about.
  Where the beat says more than that, the row takes the tone that says it, and the
  tone is the row's own rather than its connector's. That distinction is most of
  what this section had to learn — on the production beat the factory row is purple
  because the order has just been dispatched to it and the mill row above it is
  green because its fabric has already arrived, and one colour for the phase could
  not say that.

  Yellow, green and red only ever land here and on the tags, never on a line, and in
  both places the words are sitting in the colour saying the same thing: `Factories`
  in yellow is beside `SHORTLISTED`, in green beside `AQL PASS`. That is what lets
  three more colours in without the diagram needing a legend.

  All five now come from the text-safe accents in the `.pf` block rather than from an
  inline mix each: four of the six were written out twice, in slightly different
  proportions, which is how the yellow ended up as the one tone in the section nobody
  had measured. On paper it is the tone that needs the most correction — flat
  `--lot-yellow` on #FAFAF8 is 1.7:1 — and a row saying SHORTLISTED in an unreadable
  amber is worse than a row saying it in grey.

  Order matters: `is-on` always arrives with a tone class beside it, so ink is
  written first and the five that mean something more overwrite it.
*/
.pf-row.is-on .pf-row-name,
.pf-row.is-ai .pf-row-name {
  fill: var(--pf-fg);
}

.pf-row.is-human .pf-row-name {
  fill: var(--pf-human-text);
}

/* Flat, and the only one of the six that can be: `--network` in the light theme is
   `--violet` #7C3AED, which tokens.css already tuned to 5.7:1 on white for exactly
   this — a 12px word that has to be read. The dark theme's 70/30 lift is what the
   same token needed over ink. */
.pf-row.is-exec .pf-row-name {
  fill: var(--pf-exec);
}

.pf-row.is-hold .pf-row-name {
  fill: var(--pf-hold-text);
}

.pf-row.is-ok .pf-row-name {
  fill: var(--pf-ok-text);
}

.pf-row.is-alert .pf-row-name {
  fill: var(--pf-alert-text);
}

/* ── Diagram · travelling data and captions ──────────────────────────────── */

/*
  One element type for all three, because the shape is what distinguishes them
  when the colour cannot: `rx` rounds the AI packet to a circle, the human
  packet is the same square turned 45°, and the dispatched packet stays square.

  Only three, and only these three. A packet is direction — which way the traffic
  is going and whose it is — and a state cannot travel: nothing is "confirmed" in
  transit between two points.
*/
.pf-packet {
  fill: var(--pf-ai);
}

.pf-packet--human {
  fill: var(--pf-human);
}

.pf-packet--exec {
  fill: var(--pf-exec);
}

/*
  Which way. Stroked rather than filled so it does not read as a fourth packet
  shape, and one weight lighter than the line it sits on so it stays a cue on the
  connection rather than a mark of its own. It does not move: the packets already
  supply the motion, and a second travelling thing per line was exactly the
  simultaneous movement this section had too much of.
*/
.pf-arrow {
  fill: none;
  stroke: var(--pf-ai);
  stroke-width: 1.3;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.pf-arrow--human {
  stroke: var(--pf-human);
}

.pf-arrow--exec {
  stroke: var(--pf-exec);
}

/*
  Cuts the lines out from behind the word, at 0.9 so what it covers is still faintly
  there — a tag is temporary and should not punch a hole in the frame.

  `--bg` and not `--ink`: this is the section's own ground painted back over itself,
  which is what a plate is, and naming the ink was only ever right while the two were
  the same value. Getting this wrong is the loudest possible failure — every flow label
  in the diagram would arrive inside a hard-edged black box.
*/
.pf-tag-plate {
  fill: var(--bg);
  opacity: 0.9;
}

/*
  The function names, for the feeds' connector where it has to cross their arc.
  Opaque rather than the 0.9 the others use: these sit in empty margin, so there
  is nothing behind them worth letting through, and the line they are cutting is
  the one thing that must not read through the word.
*/
.pf-plated rect {
  fill: var(--bg);
}

.pf-tag {
  font-family: var(--font-mono);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  fill: var(--pf-ai-text);
}

/*
  All six tones reach a tag, because a tag is the one place in the diagram where the
  colour is not carrying the meaning on its own — `QUOTE RECEIVED`, `SHORTLISTED`,
  `GRS CERTIFIED` and `SEA MISSES WEEK 9` say what they are in words, and the colour
  is only there to sort them at a glance.

  Every one of them reads from a text-safe variant rather than from the flat accent.
  Orange and red were being lifted 18% toward white to survive the ink; the same word
  on paper needs the opposite treatment, so they take the darkened siblings the token
  block defines. The hue is the same in both directions — what moves is the lightness
  it needs to clear 4.5:1 against whichever ground it is sitting on.
*/
.pf-tag--human {
  fill: var(--pf-human-text);
}

.pf-tag--exec {
  fill: var(--pf-exec);
}

.pf-tag--hold {
  fill: var(--pf-hold-text);
}

.pf-tag--ok {
  fill: var(--pf-ok-text);
}

.pf-tag--alert {
  fill: var(--pf-alert-text);
}

@media (prefers-reduced-motion: reduce) {
  /*
    Nothing animates, so nothing needs to transition either — the composition
    baked into the markup is the finished state, and it already shows one step of
    one real order: four blue modules, a purple order going out to the factory and
    a green confirmation back from the mill.
  */
  .pf-edge,
  .pf-core-ring,
  .pf-fn-disc,
  .pf-fn-icon,
  .pf-fn-name,
  .pf-row-name,
  .pf-portal-disc {
    transition: none;
  }
}

/* ═══ 10 · FINAL CTA ═════════════════════════════════════════════════════ */

/*
  Nothing here. The whole section — `.cta`, `.cta-routes`, `.cta-inner`, its
  heading, `.cta-body`, `.cta-actions` — is in base.css, because three pages now
  close with it and this file is loaded by one of them.

  The last two moved when /faq was built and shipped its copy block with no
  measure, no lead size and no gap between the buttons, which is the same failure
  base.css already records for the route SVG on /manifesto.
*/

/* ═══ RESPONSIVE ═════════════════════════════════════════════════════════ */

/* ── Tablet ────────────────────────────────────────────────────────────── */

/*
  Where the globe stops leaving the copy column alone.

  The stage is sized in vh and placed at 85vw, which on a wide screen puts the
  sphere's left edge at about x=800 — clear of the 700px column with room to spare.
  Squeeze the window and the two close on each other from both sides at once: the
  column keeps its 700px while the sphere both moves left with the viewport and
  grows relative to it, and by 1100px the sphere's bright lower limb is running
  through the third step of the strip at the foot of the hero. The scrim cannot
  answer that — it clears at 49% so the limb stays lit, and pushing it further right
  to cover the copy would dim the one part of the image worth looking at.

  So the sphere gets centred on the right edge instead and takes a size in the same
  proportion. Half a globe on the right of a small laptop is the composition every
  width of this hero already has; it just becomes literal here. The column gets its
  clearance back, and it is the paragraph and the three steps together that get it —
  the paragraph has been overhanging the sphere's dark limb at these widths since
  long before the strip existed.
*/
@media (max-width: 1180px) {
  .globe-stage {
    left: 100vw;
    width: 102vh;
    height: 102vh;
  }
}

/* ── Stacked hero ──────────────────────────────────────────────────────── */

/*
  Where the globe gives up standing beside the copy and stands above it instead.

  Side by side needs two things at once: a 700px column and a sphere that starts to
  the right of it. Below about 900px there is no arrangement that provides both —
  the column alone is most of the window — so the sphere goes over the top, the copy
  keeps the full width underneath, and the scrim turns from a left-to-right wipe
  into a top-to-bottom one. A portrait tablet has been getting the desktop layout
  and the paragraph has been running across the lit limb there for as long as this
  hero has existed; the strip at the foot of it is the third block of copy that
  cannot afford that, so the breakpoint moved up to meet it.

  ── The band ────────────────────────────────────────────────────────────────

  Two custom properties do all the work. `--globe-size` is the stage, capped
  against the viewport's height as well as its width so a short landscape window
  does not get a globe taller than itself. `--globe-band` is the height reserved
  above the copy, and everything else is a fraction of the size: the globe's centre,
  and the three stops of the scrim. Retune the size or the band and the sphere, the
  fade and the copy all move together.

  It was 106svh with the globe at 30% of it for a long time, which worked only while
  the copy was short enough to leave the top third of the hero empty. It is not any
  more — headline, paragraph, three benefits, two stacked buttons and three stacked
  steps is taller than a phone screen on its own — so a globe placed as a percentage
  of the hero lands in the middle of the headline. Measured from the top instead,
  everything above holds whatever the copy below does.
*/
@media (max-width: 900px) {
  .hero {
    --globe-size: min(116vw, 76vh);
    --globe-band: calc(var(--topbar-h) + 0.6 * var(--globe-size));
    min-height: 100svh;
    align-items: flex-end;
    padding-top: var(--globe-band);
  }

  /*
    Centred a little under a fifth of its own size above the foot of the band, which
    puts the sphere's own foot about a headline's height below it. The globe is
    deliberately larger than the band it is given: its top limb goes dark under the
    bar's gradient and its bottom limb goes dark under the copy's, so what is left in
    the clear is the middle — the continents, the hubs and the lanes between them.
  */
  .globe-stage {
    top: calc(var(--globe-band) - 0.185 * var(--globe-size));
    left: 50vw;
    width: var(--globe-size);
    height: var(--globe-size);
  }

  /*
    The copy sits below the globe here, so the scrim blacks out downwards instead of
    sideways. The stops used to be percentages of the hero, which drift the moment
    the copy changes length; measured from the band they hold, and the fade always
    covers the same part of the same sphere.

    It reaches 0.8 just past the band rather than 1.0 at it. The headline's first
    line lands on the sphere's foot whatever is done here — a globe this size does
    not fit above a hero this tall — and 38px of white at 800 over an 80%-inked limb
    is legible in a way 17px of grey would not be, which is also why the fade is
    finished by the time the paragraph starts.
  */
  .hero::after {
    background: linear-gradient(
      180deg,
      transparent calc(var(--globe-band) - 0.13 * var(--globe-size)),
      rgba(8, 8, 8, 0.8) calc(var(--globe-band) + 0.035 * var(--globe-size)),
      var(--ink) calc(var(--globe-band) + 0.19 * var(--globe-size))
    );
  }

  /*
    And on the stacked hero the bar takes most of its ground back.

    The hero asks for the lighter of the two tints, which holds as long as the hero
    is about one screen tall: then the only part of it that ever passes under the bar
    is its empty bottom padding. Stacked, it is half a screen taller than that, and
    the paragraph, the buttons and the steps all travel up through the nav on the way
    past — grey body copy crossing the wordmark.

    70%, not opaque. Between the blur and this the crossing copy arrives as a shift
    in value rather than as words, which is all that was ever wrong with it, and the
    bar is still glass at the top of the page where the sky is behind it.
  */
  .topbar[data-bar='transparent'] {
    background: color-mix(in srgb, var(--bg) 70%, transparent);
  }

  /*
    Wider than the 11em the two-column hero uses, because the column is the window
    here and 11em of it would leave the headline narrower than the paragraph under
    it. Still short of the whole width: at 13em "Global sourcing, intelligently"
    does not fit on one line, so the break stays at the comma — which is the one
    thing this measure exists to guarantee. Without a cap at all the headline sets
    as a single line running gutter to gutter.
  */
  .hero h1 {
    max-width: 13em;
  }

  /* 21 labels do not fit on a sphere this size; keep the five pulsing hubs. */
  .hub:not(.hub--active) .hub-label {
    display: none;
  }
}

/* ── Mobile ────────────────────────────────────────────────────────────── */

@media (max-width: 720px) {
  /*
    One column. Three tracks of a 360px screen is a 15-character measure, and the
    four-line paragraphs the widest laptop gives these become nine-line ones.

    The dividers turn with the layout: the hairline that stood between two columns
    becomes the hairline between two stacked steps, so the reader is still being
    shown where one step ends. Drawn as a real border here rather than the
    pseudo-element the row uses, because the reason for that pseudo-element — a
    left padding would make the first column wider than the others — does not exist
    in a single column, and a border is the plainer thing.

    Subgrid comes off with the row: aligning three titles into one band is what it
    was for, and stacked there is one title per band already.
  */
  .hero-steps {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: none;
    row-gap: 18px;
  }

  .hero-steps li {
    grid-row: auto;
    grid-template-rows: none;
    row-gap: 6px;
  }

  .hero-steps li + li {
    padding-top: 18px;
    border-top: 1px solid var(--line);
  }

  .hero-steps li + li::before {
    display: none;
  }

  /* ── Configurator ────────────────────────────────────────────────────── */

  .config-intro {
    grid-template-columns: minmax(0, 1fr);
  }

  /*
    Both controls on their own line, left-aligned. `margin-left: auto` on the
    freight toggle is what puts it at the far end of a wide row; at this width the
    row is a column, and pushing it right would leave it stranded.
  */
  .config-toolbar {
    flex-direction: column;
    align-items: stretch;
    gap: 13px;
    padding: 13px 14px;
  }

  .config-control--freight {
    margin-left: 0;
  }

  /*
    Six stages will not fit across 340px as six columns, and shrinking them to fit
    produces six unreadable slivers. So they become one row that scrolls, with
    js/supply-map.js keeping the playing stage in view — which is also what makes
    them fit next to Replay rather than under it, and the panel is holding a
    screen height it has to give to the map.
  */
  .config-stepper {
    display: flex;
    gap: 0;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-behavior: smooth;
    scrollbar-width: none;
  }

  .config-stepper::-webkit-scrollbar {
    display: none;
  }

  .config-stepper > li {
    flex: 0 0 auto;
    min-width: 116px;
  }

  .config-track {
    gap: 12px;
    min-height: 70px;
    padding: 0 14px;
  }

  .config-replay {
    padding: 8px 13px 7px;
  }

  .footer-top {
    grid-template-columns: minmax(0, 1fr);
    gap: 34px;
  }

  .footer-legal {
    margin-left: 0;
  }
}

/* ── Phone ────────────────────────────────────────────────────────────── */

/*
  Narrower than the layout breakpoint, and only for what a phone-width line does
  to type. The layout above already works here; this is a typographic correction.
*/
@media (max-width: 440px) {
  /*
    Headings balance their line lengths by default (base.css), and on a four-line
    headline that is four short lines of even width with a hole down the right of
    the screen. Wrapping greedily fills each line before starting the next, which
    on a phone is the only way a headline this long still reads as one statement.

    Scoped this tightly on purpose: above this width the balance is the better of
    the two, so the override must not reach up into those widths.
  */
  .hero h1 {
    text-wrap: wrap;
  }
}

/* ── Reduced motion ───────────────────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  .hub-ring {
    animation: none;
    opacity: 0;
  }

  /*
    The map still draws — a still frame of the whole corridor, with the stage strip
    stepping through it — and every stage still shows its candidates, its shortlist
    and what it selected. It just does it without moving: js/supply-map.js drops
    autoplay, the camera moves, the search sweep, the dash crawl and the glide that
    follows a moving callout. These are the rest.
  */
  .node[data-active='true'] .node-dot::after {
    animation: none;
    opacity: 0;
  }

  /*
    The radar becomes a ring. Which candidates are being compared is load-bearing
    here — it is the state the panel's figures belong to — so it is stated as a
    standing outline rather than dropped with the animation that carried it.
  */
  .node[data-state='compare'] .node-dot::after {
    animation: none;
    inset: -5px;
    opacity: 0.65;
  }

  .node,
  .node-label,
  .config-vehicle,
  .config-callout,
  .config-compare,
  .config-compare-ladder li,
  .config-compare-row,
  .config-leader,
  .config-leader-line {
    transition: none;
  }

  /* Vessels and aircraft only exist mid-journey, and there is no journey here. */
  .config-vehicle {
    display: none;
  }

  /* Nor does correspondence: the messages are drawn in flight or not at all. */
  .config-note-fly {
    display: none;
  }
}
