/* ===========================================================================
   V4 · delta over precision-v3.css
   ---------------------------------------------------------------------------
   The page loses both of its upper calls to action. What is left in the hero
   is one link into the argument, and it takes the accent so the band still
   has somewhere obvious to go.
   ======================================================================== */

/* The nav is the wordmark alone now. Without a second child, `space-between`
   has nothing to distribute and the mark would sit on its own at the left
   edge of a bar that is otherwise empty, which is fine, but the bar should
   not keep the height it needed for a button. */
.p-nav__inner--bare { justify-content: flex-start; }

/* One action, so it centres on its own rather than sitting where the pair
   used to leave it. */
.p-hero__ctas--single { justify-content: center; }

/* --- The accent link -----------------------------------------------------
   Lime, at the client's direction. It reads at 16.48:1 on the canvas, and
   `check-a11y` measures it against the densest ground the reactive-lines fan
   actually reaches rather than against the flat token.

   Hover goes to `--ink` rather than to a lighter lime: on a page where lime
   is the accent, a second lime is not a state change a reader can see. The
   underline is what carries the affordance in both states.
   ------------------------------------------------------------------------ */
.p-btn--accent {
  color: var(--accent);
}

.p-btn--accent:hover {
  color: var(--ink);
}

/* The one remaining hero action is a link, not a button, so it should look
   like one. A bare word with an arrow reads as decoration at this size in a
   band with nothing else in it. */
.p-btn--accent span:first-child {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 5px;
  text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
  transition: text-decoration-color var(--fast) var(--ease-out);
}

.p-btn--accent:hover span:first-child {
  text-decoration-color: currentColor;
}

/* ---------------------------------------------------------------------------
   404
   ---------------------------------------------------------------------------
   Lives in a stylesheet rather than a <style> block on the page so that
   `style-src` in the Content-Security-Policy can stay `'self'` — one inline
   block would force `'unsafe-inline'` across every stylesheet the site has.
   Three rules is a cheap price for that.
   ------------------------------------------------------------------------- */

.nf {
  min-height: 100dvh;
  display: grid;
  place-content: center;
  text-align: center;
}

.nf__code {
  font-family: var(--font-mono);
  font-size: var(--text-label);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--ink-tertiary);
  margin-bottom: var(--s-4);
}

.nf__line {
  font-size: var(--text-h2);
  letter-spacing: var(--tracking-h2);
  line-height: var(--leading-h2);
  margin-bottom: var(--s-8);
}

/* ---------------------------------------------------------------------------
   FOCUS REVEAL — Originkit, ported
   ---------------------------------------------------------------------------
   Characters arrive out of focus and settle one after another. Applied to the
   four headings that carry the argument.

   THE MOTION IS ENTIRELY HERE. `scripts/focus-reveal.js` splits the text and
   stamps each character with its index; this file turns that index into a
   delay. No frame loop, no library, and `prefers-reduced-motion` already
   reaches it through the token layer's blanket duration collapse — with the
   block below removing the blur and the scale outright, because a 1ms
   transition on a 20px blur still shows as a snap.

   The characters only exist if the script ran, so there is no hidden state to
   strand: no script, no spans, and the heading is simply itself. That is why
   none of this needs an `html.js` guard.
   ------------------------------------------------------------------------- */

.p-focus-reveal {
  /* Both read back by the script to time the hero's handoff to mesh-text, so
     the numbers live in exactly one place. `--fr-stagger` is a token rather
     than a literal because the right value depends on how much text there is:
     35ms matches the supplied component and suits a heading of a few words,
     and would take a long sentence well past three seconds. */
  --fr-duration: 300ms;
  --fr-stagger: 35ms;
}

/* Words wrap; characters do not. Splitting mid-word would be the one thing a
   reader actually notices. */
.p-fr__w {
  display: inline-block;
  white-space: nowrap;
}

.p-fr__c {
  display: inline-block;
  opacity: 0;
  transform: scale(1.45);
  filter: blur(20px);
  /* ease-out-cubic, matching the supplied component rather than this page's
     --ease-out, which is a much longer tail and reads as a drift at 300ms. */
  transition:
    opacity var(--fr-duration) cubic-bezier(0.215, 0.61, 0.355, 1) var(--fr-delay, 0ms),
    transform var(--fr-duration) cubic-bezier(0.215, 0.61, 0.355, 1) var(--fr-delay, 0ms),
    filter var(--fr-duration) cubic-bezier(0.215, 0.61, 0.355, 1) var(--fr-delay, 0ms);
}

/* `--n` is stamped per character by the script. */
.p-fr__c {
  --fr-delay: calc(var(--n, 0) * var(--fr-stagger, 35ms));
}

/* THE THESIS CLOSE IS NINETY-THREE CHARACTERS, NOT A HEADING OF FOUR WORDS.
   At the default stagger its last character would start 3.3 seconds after its
   first, which is not an entrance, it is a wait. 14ms brings the whole line in
   inside about 1.6s, close to what the hero takes for a third of the text. */
.p-statement.p-focus-reveal { --fr-stagger: 14ms; }

/* The existing scroll-reveal wrapper is the trigger, so a heading below the
   fold animates when it is reached rather than finishing unseen. */
.reveal.is-revealed .p-fr__c,
.p-reveal.is-revealed .p-fr__c {
  opacity: 1;
  transform: none;
  filter: blur(0);
}

/* A blur this size on a display heading is expensive to composite. Promote
   only while it is actually moving. */
.p-focus-reveal.is-split .p-fr__c {
  will-change: opacity, transform, filter;
}

.reveal.is-revealed .p-fr__c,
.p-reveal.is-revealed .p-fr__c {
  will-change: auto;
}

@media (prefers-reduced-motion: reduce) {
  .p-fr__c {
    transform: none;
    filter: none;
    transition: opacity var(--fast) linear;
  }
}


/* ---------------------------------------------------------------------------
   DIA TEXT REVEAL — Magic UI, ported
   ---------------------------------------------------------------------------
   The turn: "It's time to level the playing field." Characters light up in
   sequence while a band of colour runs the length of the line beneath them.

   REPLACES THE SHIMMER. That line used to carry a white highlight sweeping
   across the lime, on a 4.5s loop. Two sweeping-colour effects on one sentence
   would be one too many, and the shimmer's perpetual loop was always the
   weaker idea — a page this quiet reads a repeating sweep as a defect. This
   one plays once, when the line is reached, and then stops.

   THE COLOURS ARE THE PAGE'S OWN. The supplied component's demo passes
   #A97CF8 / #F38CB8 / #FDCC92 — purple, pink, peach. All three clear AA here
   (6.45, 8.61, 13.25 on the canvas) so contrast was never the objection; the
   objection is that this page is lime on near-black by an explicit decision,
   and three new hues on its single most important sentence would spend that
   decision for an effect. These three are already on the page: the accent, the
   ink, and the blue the console panel uses for a settled state.

   To use the demo palette instead, change the three stops below. Nothing else
   in this block depends on which colours they are. */
.p-turn__line--dia {
  --dia-1: var(--accent);      /* #c6ff00 · 16.48 on canvas */
  --dia-2: var(--ink);         /* #ffffff · 19.55           */
  --dia-3: var(--panel-ok);    /* #7fc8f5 · 10.69           */
  color: var(--dia-1);
  font-weight: var(--weight-medium);
}

/* Unsupported, and the line is simply the accent — which is what it is here
   before the gradient is applied. `color: transparent` never escapes this
   block, so a browser without background-clip cannot erase the sentence. */
@supports (background-clip: text) or (-webkit-background-clip: text) {
  .p-turn__line--dia {
    /* The lime run at the head of the gradient is wider than the window the
       line sees (100 / 220 = 45.5% of it), so when the sweep finishes at
       position 0% the whole sentence sits inside that run and rests on the
       accent. Without that the animation stopped wherever it stopped and left
       the last two words blue — a colour that had swept through reading as
       the colour the line simply is. The band travels; the resting state is
       lime. */
    background-image: linear-gradient(
      100deg,
      var(--dia-1) 0%,
      var(--dia-1) 48%,
      var(--dia-2) 58%,
      var(--dia-3) 70%,
      var(--dia-2) 82%,
      var(--dia-1) 94%,
      var(--dia-1) 100%
    );
    background-size: 220% 100%;
    background-position: 100% 0;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
  }

  /* One pass, on arrival, and then it rests on the accent. The band is wide
     and the travel is long, so what crosses a given glyph is a slow change of
     hue rather than a stripe going past it. */
  .js .p-reveal.is-revealed .p-turn__line--dia {
    animation: p-dia-sweep 2600ms var(--ease-out) 120ms 1 both;
  }
}

@keyframes p-dia-sweep {
  from { background-position: 100% 0; }
  to { background-position: 0% 0; }
}

/* The characters carry only their timing. Colour comes from the gradient
   above, so a glyph's hue follows its position in the line rather than the
   order it appeared in — the line reads as one moving thing. */
.p-dia__w {
  display: inline-block;
  white-space: nowrap;
}

.p-dia__c {
  display: inline-block;
  opacity: 0;
  transform: translate3d(0, 0.32em, 0);
  transition:
    opacity 520ms var(--ease-out) var(--dia-delay, 0ms),
    transform 520ms var(--ease-out) var(--dia-delay, 0ms);
  --dia-delay: calc(var(--n, 0) * 26ms);
}

.p-reveal.is-revealed .p-dia__c {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .p-dia__c { transform: none; transition: opacity var(--fast) linear; }
  .js .p-reveal.is-revealed .p-turn__line--dia { animation: none; }
}

/* ---------------------------------------------------------------------------
   THE CLOSE — dot field behind the statement and the diagram
   ---------------------------------------------------------------------------
   One ground under both, so the sentence and the picture that argues it read
   as a single surface rather than as prose followed by an illustration.

   THE FIELD IS OPTIONAL IN EVERY DIRECTION. The canvas has no size of its own
   and paints nothing without WebGL, so with scripts blocked, on a machine
   without a context, or under a CSP that drops the script, this is a plain
   band on the page's own ground.

   THE EDGES ARE MASKED. A rectangle of texture with hard edges reads as a
   pasted-in panel. The mask dissolves the field into the page on all four
   sides, which is only possible because the canvas is transparent rather than
   painting its own black.

   ITS BRIGHTNESS IS A CONTRAST BUDGET, NOT A TASTE CALL. White dots behind
   muted text is the one way this effect can break the page, so the opacity
   here is the value the a11y check measures against with the lens parked
   directly behind the statement. Raising it needs that check re-run, not an
   opinion.
   ------------------------------------------------------------------------- */

/* THE SEPARATION FROM THE BAND ABOVE IS MARGIN, NOT PADDING, AND THAT IS THE
   WHOLE POSITIONING OF THE EFFECT. The canvas is `inset: 0` on this element,
   so it covers whatever this element's border box covers. With the space above
   the statement set as padding, the field ran 192px higher than the statement
   did — nearly a third of it was empty page — and since the lens drifts
   vertically, its bright cluster spent much of its time sitting in that gap
   rather than behind anything. As margin, the space is outside the border box:
   the field now starts at the statement and ends at the diagram, which is what
   it is the background OF. */
.p-close {
  position: relative;
  isolation: isolate;
  /* The statement's own 128px top margin, plus the band's 64px, hoisted out
     here as one number. Split between the two it sat INSIDE the field's box
     and pushed the field 152px above the text it is meant to sit behind. The
     sum is deliberate: the statement lands in exactly the same place it did,
     and only the field's box changed. */
  margin-block: calc(var(--s-32) + var(--s-16)) var(--s-8);
}

.p-close .p-statement {
  margin-top: 0;
}

.p-close__field {
  position: absolute;
  inset: 0;
  z-index: -1;
  display: block;
  width: 100%;
  height: 100%;
  opacity: 0.22;
  /* Wider than the box, so the falloff happens at the very edges rather than
     shaping the field into an oval. It should read as this section's ground,
     not as a lit shape sitting on it. */
  -webkit-mask-image: radial-gradient(128% 118% at 50% 50%, #000 46%, transparent 88%);
  mask-image: radial-gradient(128% 118% at 50% 50%, #000 46%, transparent 88%);
  pointer-events: none;
}

/* Just enough that the field carries a little past the text at top and bottom
   instead of stopping on its baseline. */
.p-close__body {
  padding-block: var(--s-6);
}

@media (prefers-reduced-motion: reduce) {
  /* The field is static under reduced motion rather than absent — it is
     texture, not movement — but it drops further back so a parked bright
     cluster cannot sit behind the text at full strength. */
  .p-close__field { opacity: 0.15; }
}

/* ---------------------------------------------------------------------------
   FLOW GRAPH — separate processes resolving into one platform
   ---------------------------------------------------------------------------
   Closes the thesis band, directly under "Every function in one platform."

   Layout is a CSS grid; the wires are one stretched SVG behind it. The grid's
   proportions and the SVG's percentage coordinates are the same numbers, so
   the curves land on the nodes without either knowing the other's pixel size.

   Motion sits on a finished base, like everything else here: scripts blocked,
   reduced motion, or the observer never firing all leave the diagram complete
   and readable. `enter.js` only says when to start.
   ------------------------------------------------------------------------- */

.p-flow {
  position: relative;
  margin: var(--s-24) 0 0;
  display: grid;
  /* Below 48em this is a stack: the processes, a funnel, then the platform.
     ROW GAP IS ZERO ON PURPOSE. The funnel is the separation now, and it only
     reads as a connection if it touches what it connects — a gap above and
     below would make it a line drawn near the stack rather than a line out of
     it. The processes keep their own spacing inside the list. */
  row-gap: 0;
  grid-template-columns: minmax(0, 1fr);
}

.p-flow__sources {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--s-4);
}

.p-flow__node,
.p-flow__platform {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-4) var(--s-6);
  border: 1px solid var(--hairline);
  border-radius: var(--radius-md);
  background: var(--surface-sunken);
  min-width: 0;
}

.p-flow__dot {
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--ink-tertiary);
}

.p-flow__label,
.p-flow__platform-label {
  font-family: var(--font-mono);
  font-size: var(--text-label);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--ink-secondary);
}

/* The one lit element. The four processes are ink; the thing they resolve
   into is the accent, which is the same rationing the rest of the page uses. */
.p-flow__platform {
  justify-content: center;
  border-color: color-mix(in srgb, var(--accent) 42%, transparent);
  background: var(--accent-tint);
}

.p-flow__platform-label {
  color: var(--accent);
}

.p-flow__wires {
  display: none;
}

/* --- Wide: two columns, wires drawn ------------------------------------- */
@media (min-width: 48em) {
  /* Three tracks, and the numbers are the SVG's. Sources end at 36%, the
     wires cross the gutter and converge at 49%, the merged line runs to 58.5%
     and the platform starts at 58%. Change one and the other has to move. */
  .p-flow {
    grid-template-columns: 36% 22% 1fr;
    gap: 0;
    align-items: center;
    /* A row per node, so adding one grows the figure instead of squeezing the
       rhythm. The component publishes the count as `--flow-n`. */
    min-height: calc(var(--flow-n, 4) * 66px);
  }

  /* EQUAL ROWS, NO GAP. This is what makes the wire endpoints exact rather
     than approximate. With a row gap, node i's centre is
     `i * (row + gap) + row / 2`, which is not `(i + 0.5) / N` for any gap
     above zero — at five nodes that put the top and bottom wires about five
     pixels off their node's centre. With equal gapless rows and each node
     centred inside its own row, the centre IS `(i + 0.5) / N`, which is the
     number the component draws to. The visual separation is the row being
     taller than the node, not a gap between them. */
  .p-flow__sources {
    align-self: stretch;
    gap: 0;
    grid-auto-rows: 1fr;
    align-items: center;
  }

  /* Fills its track rather than sitting at a fixed width. A capped box left a
     growing margin to its right — 190px at 1440 — which read as the diagram
     being cut off rather than as composition, and put the figure's right edge
     somewhere no other block on the page ends. Filling makes it the widest
     element in the picture, which is the correct weight for the thing the
     other four resolve into. */
  .p-flow__platform {
    grid-column: 3;
    padding-block: var(--s-8);
  }

  .p-flow__wires {
    display: block;
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
    pointer-events: none;
  }

  .p-flow__wire,
  .p-flow__spark {
    fill: none;
    stroke-linecap: round;
    /* The SVG is stretched, so a scaled stroke would be thicker across than
       down. This keeps every wire the same weight whatever the box does. */
    vector-effect: non-scaling-stroke;
  }

  .p-flow__wire {
    stroke: url(#p-flow-grad);
    stroke-width: 1.25;
  }

  /* HEAVIER THAN THE BUNDLE IT LEAVES, WHICH IT WAS NOT.

     At 1.6 non-scaling pixels this line antialiased across two pixel rows and
     the brightest row measured #97c304 — about 60% of the accent — while the
     point where five wires overlap measured the full #c6ff00. So the picture
     read as a bright knot that stopped, followed by a faint hairline, and the
     connection into the platform looked like a gap even though the geometry
     had it overlapping the panel by five pixels.

     The fix is weight, not length. At 3 it covers whole pixel rows, so it
     renders at the accent it is set to and is unambiguously the strongest
     stroke in the diagram, which is what "the merged run is the lit one" was
     always supposed to mean. */
  .p-flow__wire--merged {
    stroke: var(--accent);
    stroke-width: 3;
  }

  /* The bright copy the moving window exposes.

     NEARLY WHITE, ON PURPOSE, AND IT TOOK TWO GOES TO GET THERE. Stroked in
     the accent it was invisible, because the wires are already lime where they
     converge. At a 55% mix with white it was still not reading: measured on
     the merged run, the brightest pixel went from #c6ff00 at rest to #dfff6f
     under the light, which is a shift in saturation rather than an arrival of
     light. At this mix the same pixel goes to near white, which is legible
     against both the dim ink at the left of each wire and the full accent of
     the merged run at the right.

     It stays inside the palette: white is the page's ink, and this is a light
     passing over a lime line rather than a third colour introduced. */
  .p-flow__spark {
    stroke: color-mix(in srgb, var(--accent) 22%, #fff);
    stroke-width: 2.6;
  }

  .p-flow__spark--merged {
    stroke-width: 3.6;
  }
}

/* --- The mobile funnel -------------------------------------------------- */

/* Its own height, because the SVG is stretched to whatever box it is given and
   a percentage height would collapse to nothing in an auto-height grid row.
   Tall enough that the strands read as curves rather than as a crease. */
.p-flow__funnel {
  display: block;
  width: 100%;
  height: 92px;
}

@media (min-width: 48em) {
  .p-flow__funnel { display: none; }
}

.p-flow__strand,
.p-flow__funnel-spark {
  fill: none;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}

.p-flow__strand {
  stroke: url(#p-flow-funnel-grad);
  stroke-width: 1.25;
}

/* The one arriving line, flat accent and heavier — the same rationing the wide
   layout uses, and flat for the same reason: it is vertical, so its box is
   zero WIDE, and an objectBoundingBox gradient would not render it at all. */
.p-flow__strand--merged {
  stroke: var(--accent);
  stroke-width: 3;
}

.p-flow__funnel-spark {
  stroke: color-mix(in srgb, var(--accent) 22%, #fff);
  stroke-width: 2.6;
}

.p-flow__funnel-spark--merged { stroke-width: 3.6; }

.p-flow__funnel-sparks { opacity: 0; }

.js .p-flow.is-in .p-flow__funnel-sparks {
  opacity: 1;
  transition: opacity var(--base) var(--ease-out) 900ms;
}

/* Down rather than across, and over the whole box: unlike the wide layout,
   the strands here occupy the full height, so there is no empty stretch for
   the sweep to waste time crossing. */
.js .p-flow.is-in .p-flow__funnel-window {
  animation: p-flow-funnel-spark 2800ms linear 900ms infinite;
}

@keyframes p-flow-funnel-spark {
  0%, 6% { transform: translateY(0); }
  58%, 100% { transform: translateY(118px); }
}

@media (prefers-reduced-motion: reduce) {
  .js .p-flow.is-in .p-flow__funnel-sparks { opacity: 0; transition: none; }
  .js .p-flow.is-in .p-flow__funnel-window { animation: none; }
}

/* --- Entry, once, when the band is reached ------------------------------ */

/* THE WIRES ARE REVEALED BY A CLIP SWEEP, NOT BY A DASH.

   The obvious way to draw a line in is `stroke-dashoffset`, and it does not
   work here. These strokes carry `vector-effect: non-scaling-stroke`, which is
   needed because the SVG is stretched about four times more across than down
   and an ordinary stroke would come out thick on the vertical parts and thin
   on the horizontal ones. But non-scaling-stroke also moves dash lengths into
   SCREEN pixels, so any dasharray is a different fraction of the path at every
   container width: the merged line rendered as two dashes with a gap, and
   `pathLength` normalisation did not help because the same rule applies after
   it.

   A clip sweeping left to right needs no path length at all. It also reads
   better for this diagram, since everything here flows one way: the four wires
   emerge in the order they are reached and the merged lime run is last because
   it is furthest right, which is the sequence the picture is arguing for.

   THE SWEEP IS LINEAR, WHICH IS THE ONE PLACE ON THIS PAGE THAT IS. Every
   other motion here eases out, because things arriving should decelerate. A
   sweep is not a thing arriving, it is a pen moving, and a pen that slows down
   halfway across draws an uneven line. It also mattered mechanically: the
   wires occupy only the left 58.5% of the box, so under `--ease-out` the
   visible drawing was finished by about 600ms and the remaining 550ms swept
   empty space, leaving a dead half-second before the platform appeared.
   Linear puts the edge at 58.5% at 933ms, so the platform's 1080ms arrival
   lands just behind the line reaching it. */
@media (min-width: 48em) {
  .js .p-flow .p-flow__wires {
    clip-path: inset(0 100% 0 0);
  }

  .js .p-flow.is-in .p-flow__wires {
    clip-path: inset(0 0 0 0);
    transition: clip-path 1150ms linear 260ms;
  }

  /* THE TRAVELLING LIGHT.

     One stripe, moving. Everything about the convergence is geometry rather
     than choreography: because the stripe is a single column in the SVG's own
     coordinates, all five wires light at the same x at the same instant, so
     the lights merge where the wires merge and one light continues to the
     platform. Nothing is timed to meet anything.

     It is a CSS transform on the mask's rect, which is a compositor property,
     so this costs no frame loop and no script.

     THE TRAVEL IS SCOPED TO THE CONTENT, WHICH IS NOT THE SAME AS THE BOX. The
     first build swept the stripe across the whole viewBox, 0 to 100, and it
     looked broken: the wires occupy only x 36 to 58.5, so four fifths of every
     cycle lit nothing at all and the light read as an occasional flicker
     rather than as a run. The keyframes now start with the stripe's leading
     edge at the source column and end with its trailing edge past the
     platform, and the tail of the cycle is a deliberate rest so the run has a
     beat between passes instead of being a conveyor.

     The 1500ms delay clears the draw-in — the wires finish arriving at about
     960ms and the platform lands at 1080ms — so the light travels a diagram
     that is already complete rather than racing its own construction. */
  .js .p-flow.is-in .p-flow__spark-window {
    animation: p-flow-spark 2800ms linear 1500ms infinite;
  }

  .p-flow__sparks {
    opacity: 0;
  }

  .js .p-flow.is-in .p-flow__sparks {
    opacity: 1;
    transition: opacity var(--base) var(--ease-out) 1500ms;
  }
}

/* User units. The stripe is 14 wide and sits at x=-14, so `translateX(36px)`
   puts its leading edge exactly on the source column and `translateX(72.5px)`
   puts its trailing edge exactly past the platform. Travel occupies 6% to 58%
   of the cycle; the rest is the beat between passes. */
@keyframes p-flow-spark {
  0%, 6% { transform: translateX(36px); }
  58%, 100% { transform: translateX(72.5px); }
}

.js .p-flow .p-flow__node,
.js .p-flow .p-flow__platform {
  opacity: 0;
  transform: translate3d(0, 10px, 0);
}

.js .p-flow.is-in .p-flow__node {
  opacity: 1;
  transform: none;
  transition:
    opacity var(--base) var(--ease-out),
    transform var(--base) var(--ease-out);
  transition-delay: calc(var(--n, 0) * 110ms);
}

/* Arrives last, once the sweep has reached it. */
.js .p-flow.is-in .p-flow__platform {
  opacity: 1;
  transform: none;
  transition:
    opacity var(--slow) var(--ease-out),
    transform var(--slow) var(--ease-out);
  transition-delay: 1080ms;
}

@media (prefers-reduced-motion: reduce) {
  .js .p-flow .p-flow__node,
  .js .p-flow .p-flow__platform { opacity: 1; transform: none; }
  .js .p-flow .p-flow__wires,
  .js .p-flow.is-in .p-flow__wires { clip-path: none; transition: none; }
  /* The travelling light is withdrawn rather than parked. A stripe frozen
     mid-wire is a bright artefact sitting on the diagram forever, which is a
     worse outcome than not having the effect. */
  .js .p-flow.is-in .p-flow__sparks { opacity: 0; transition: none; }
  .js .p-flow.is-in .p-flow__spark-window { animation: none; }
}

/* ---------------------------------------------------------------------------
   MOBILE · THE AI SCENES ARE CAPPED
   ---------------------------------------------------------------------------
   `.p-ai__grid` is a single column below 44em, so each scene panel takes the
   full measure and its 120x96 viewBox turns that into about 250px of height.
   Four of them stacked put roughly a thousand pixels of decoration into a
   section whose argument is the four sentences underneath, and the reader
   scrolls past illustration to reach it.

   THE PANEL IS CAPPED, NOT THE GRAPHIC INSIDE IT. Capping the height alone was
   the first attempt and it left the scene floating: the SVG is 1.25:1, so a
   shorter one is also a narrower one, and it sat as a small drawing marooned
   in a full-measure frame. The panel has to come down with it. Capping the
   panel's WIDTH lets the intrinsic ratio set the height, so the frame stays
   proportional to what it holds at any cap.
   ------------------------------------------------------------------------ */
@media (max-width: 43.999em) {
  .p-ai__stage {
    max-width: 13rem;
    margin-inline: auto;
  }

  .p-ai__grid { gap: var(--s-8); }
  .p-ai__card { gap: var(--s-4); }
}

/* ---------------------------------------------------------------------------
   MOBILE · THE FOOTER
   ---------------------------------------------------------------------------
   Two changes, both about the vertical run. Three link lists stacked one per
   row made the footer taller than the section above it, and the mark and its
   byline were a row-gap apart when they are a lockup and should read as one
   thing.

   The lists go two across. The mark and the byline keep the grid's row gap
   between them, which is now small, and the separation before the lists comes
   from the lists themselves — so the pair sits tight and the block below it
   still has air. Padding on every column rather than a margin on the first
   also spaces the two rows of lists from each other, which is the same
   distance and should be.
   ------------------------------------------------------------------------ */
@media (max-width: 33.999em) {
  .p-footer__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: var(--s-6);
    row-gap: var(--s-3);
  }

  .p-footer__mark,
  .p-footer__byline { grid-column: 1 / -1; }

  .p-footer__byline { margin-top: 0; }

  .p-footer__col { padding-top: var(--s-8); }

  .p-footer__list { gap: var(--s-3); }
}

/* ==========================================================================
   FAQ — native disclosure, no script.

   <details>/<summary> rather than a scripted accordion, and not as a
   preference: the page has to work with JavaScript off, and check-a11y tabs
   through `a[href], button:not([disabled]), summary` demanding a painted
   focus ring on each. Native summary is focusable and Enter/Space-toggleable
   for free; a div-and-script version would mean rebuilding all of that by
   hand and would still fail the scripts-blocked run.

   NOTHING HERE ANIMATES ITS HEIGHT, deliberately. A <details> cannot, in
   plain CSS: the browser toggles its content between rendered and
   `display: none`, so there is no height to interpolate. Every workaround
   has a catch — `grid-template-rows: 0fr → 1fr` animates open but not closed
   because the content is already gone by then; `interpolate-size` and
   `::details-content` are not universally supported; anything that works
   everywhere needs a script to hold the element open through the closing
   frame, which costs a file and reopens the no-JS question. The mark
   rotation below carries the interaction instead, and it is a transform,
   which is the cheapest thing a browser animates.
   ======================================================================= */
.p-faq__heading {
  margin-block-end: var(--space-lg);
  text-align: center;
}

/* THE TYPE IS THE AI CARDS', NOT A SCALE OF ITS OWN.

   Those cards set the page's reading size: `.p-ai__copy` is --text-body at
   --leading-body, and its lead is the SAME size, separated only by weight and
   ink. So the question here is not a heading size; it is the card lead, and
   the answer is the card body. Set once on the list and inherited, rather
   than restated on each part, so the two cannot drift apart later.

   --text-body is a fixed 1.0625rem with no clamp, so desktop and phone get
   the same size — which is what the cards already do on both.

   Centred as a block, with the text inside still ranged left. The section
   head follows `.p-ai__head`, which is centred; the rows are not, because an
   accordion with centred questions has no common left edge to scan down. */
.p-faq__list {
  max-width: var(--measure);
  margin-inline: auto;
  font-size: var(--text-body);
  line-height: var(--leading-body);
}

.p-faq__item { border-block-start: 1px solid var(--hairline); }

.p-faq__item:last-child { border-block-end: 1px solid var(--hairline); }

/* The native marker is drawn by two mechanisms and needs killing in both, or
   Safari keeps its triangle. Setting `display` away from `list-item` is the
   third belt: harmless where the other two already worked. */
.p-faq__q {
  list-style: none;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--space-md);
  align-items: start;
  padding-block: var(--space-md);
  cursor: pointer;
}

.p-faq__q::-webkit-details-marker { display: none; }

/* Size and leading inherited from the list above, so the question is the card
   lead: same measure as its answer, distinguished by weight and ink alone. */
.p-faq__qt {
  margin: 0;
  font-size: inherit;
  line-height: inherit;
  font-weight: var(--weight-medium);
  color: var(--ink);
  transition: color var(--duration-base) var(--ease-out);
}

.p-faq__q:hover .p-faq__qt { color: var(--accent); }

/* A plus rotated 45 degrees IS a cross, so one shape covers both states. No
   icon asset, no second element to keep in sync, nothing to swap on toggle. */
/* The box is one line tall, DERIVED rather than nudged: the bars sit at its
   50%, which is therefore the optical centre of the question's first line at
   any size. A hand-tuned top margin would be right for one type scale and
   quietly wrong after the next change to it. */
.p-faq__mark {
  position: relative;
  inline-size: 0.875rem;
  block-size: calc(var(--text-body) * var(--leading-body));
  transition: transform var(--duration-base) var(--ease-out);
}

.p-faq__mark::before,
.p-faq__mark::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  inset-block-start: calc(50% - 1px);
  block-size: 2px;
  background: var(--ink-tertiary);
  transition: background var(--duration-base) var(--ease-out);
}

.p-faq__mark::after { transform: rotate(90deg); }

.p-faq__item[open] .p-faq__mark { transform: rotate(45deg); }

/* The mark is aria-hidden and decorative, so accent on hover is a colour
   change no reader depends on. */
.p-faq__q:hover .p-faq__mark::before,
.p-faq__q:hover .p-faq__mark::after {
  background: var(--accent);
}

.p-faq__a { padding-block-end: var(--space-lg); }

/* Size, leading and measure all come from the list; only the ink differs,
   exactly as `.p-ai__copy` differs from the strong inside it. */
.p-faq__a p {
  margin: 0;
  color: var(--ink-secondary);
}

@media (prefers-reduced-motion: reduce) {
  .p-faq__mark,
  .p-faq__mark::before,
  .p-faq__mark::after,
  .p-faq__qt {
    transition: none;
  }
}
