/* =========================================================================
   Troutwood design system
   Tokens, primitives, the field, nav and footer, plus the homepage's own
   sections. Loaded by index.html and platform.html; page-specific styles sit
   alongside it (see tw-platform.css).

   Dark forest, one continuous scroll. Organised by section, in page order.

   The whole page sits on a single fixed background layer (gradient + canvas
   + grain). Every section is transparent by default so the particle field
   reads as one environment the content travels through, rather than a strip
   of animation with pages stacked after it.

   That is the homepage. Interior pages carry too much argument to spend the
   field on all of it: they window the particles down to the close and section
   the rest by tone instead. See section 19, Bands.
   ========================================================================= */

/* -------------------------------------------------------------------------
   01. Font
   Inter ships as a variable font on the latin subset: one 48KB file covers
   400/500/700/800 instead of four static files.
   ------------------------------------------------------------------------- */

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('/fonts/inter-latin-var.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

/* -------------------------------------------------------------------------
   02. Tokens
   The greens are sampled from the reference art: a near-black forest base,
   a warmer forest mid, and a teal-green glow that lifts the centre of the
   frame. The blue haze is the one cool note and it stays a haze, never a
   surface colour.
   ------------------------------------------------------------------------- */

:root {
  /* Forest ramp, darkest first. --forest-800 is the brand forest and is only
     ever a foreground here: it is the label colour on the sage CTA. */
  --forest-950: #071209;
  --forest-900: #0c1f14;
  --forest-800: #112a18;

  /* Accents. --sage is the brand green and stays decorative or large; every
     accent at body size or below uses --sage-bright. See the note below. */
  --sage: #67bc7c;
  --sage-bright: #8fd9a3;
  --sky: #5aa7f8;

  /* Text on the field.
     The measurement that matters here is not the darkest surface, it is the
     brightest: the glow's core, #29522D, which text scrolls straight through.
     Every value below is quoted there, so each one is a floor:
                       glow core   darkest
       --text-primary     8.8:1     18.0:1
       --text-secondary   5.6:1     10.5:1
       --text-tertiary    4.6:1      8.1:1
       --sage-bright      5.3:1     10.8:1
       --sage             3.8:1      7.7:1   large text and decoration only
       --sky              3.5:1      7.1:1   decoration only, never text
     Which is why the glow tops out at 0.44 alpha in .field__glow and why
     every small accent on the page uses --sage-bright rather than --sage.
     Reverse pairing: --forest-800 on --sage is 6.6:1, which is what makes
     the sage CTA legal with a dark label instead of a white one. */
  --text-primary: #ffffff;
  --text-secondary: rgba(226, 240, 230, 0.82);
  --text-tertiary: rgba(214, 231, 219, 0.74);
  --rule: rgba(255, 255, 255, 0.12);
  --rule-strong: rgba(255, 255, 255, 0.22);
  --surface: rgba(255, 255, 255, 0.035);
  --surface-hi: rgba(255, 255, 255, 0.06);

  /* The halo under small sage decoration. A token rather than a literal so a
     band can switch it off in one place: a green bloom is a dark-field device
     and on paper it reads as a rendering fault. See section 19. */
  --accent-glow: rgba(103, 188, 124, 0.6);

  /* Band tones, darkest first. The steps between neighbours are 1.21, 1.27 and
     1.53 end to end: small numbers, because contrast ratio is a poor model of
     what the eye does this close to black, and a step that measures 1.2 here
     is plainly visible where the same ratio at mid-grey would not be. They
     were widened from a first pass that measured 1.15/1.11 and read as one
     tone with a seam through it.

     Every band is darker than the glow's core (#29522D), which is the tone
     section 02 quotes all its text floors against. So a band can only ever
     improve the contrast of the text on it, and none of those floors has to
     be re-checked per band. See section 19. */
  --band-sunk: #040c07;
  --band-forest: #0c2416;
  --band-canopy: #153822;
  --band-paper: #f2f6f0;
  --band-seam: rgba(255, 255, 255, 0.07);

  /* Type */
  --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
    Helvetica, Arial, sans-serif;

  --step-eyebrow: 0.6875rem;
  --step-fine: 0.75rem;
  --step-small: 0.875rem;
  --step-body: 1.0625rem;
  --step-lead: clamp(1.125rem, 0.98rem + 0.6vw, 1.4375rem);
  --step-h3: clamp(1.125rem, 1.02rem + 0.45vw, 1.3125rem);
  --step-h2: clamp(2rem, 1.35rem + 2.9vw, 3.5rem);
  /* Cut for hero lines that are a phrase each. The line this cap was
     originally quoted against, "It just doesn't travel or scale.", is gone;
     of what is left, the longest is advisors' "Present for the other 361
     days." at 13.8em, which at the cap is 1160px against a 1136px measure
     and so already turns just inside --max. Everything else clears it:
     "One connected platform." is 11.3em, 952px. Raising the cap or the slope
     turns more of them, and from roughly 1024px up rather than only at the
     widest viewports.

     The homepage headline is two whole sentences and measures nearly twice
     any of these. It cannot be carried here at any size that still reads as
     a headline, so it takes its own; see .hero__title--statement. */
  --step-display: clamp(2.625rem, 0.6rem + 5.4vw, 5.25rem);
  --step-statement: clamp(1.625rem, 1.05rem + 2.6vw, 3rem);

  /* Space */
  --gutter: clamp(1.25rem, 0.9rem + 1.6vw, 2.5rem);
  --max: 76rem;
  --max-prose: 42rem;
  --section-y: clamp(5.5rem, 3rem + 9vw, 11rem);

  /* Misc */
  /* One turbulence tile, shared by the field and by every band, so an opaque
     band never reads smoother than the page it interrupts. */
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='0.55'/%3E%3C/svg%3E");
  --radius: 14px;
  /* Buttons sit one step tighter than the panels they sit on, so a button
     inside a card never reads as rounder than the card holding it. */
  --radius-btn: 12px;
  --focus: var(--sage-bright);
  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  --ease-out: cubic-bezier(0.16, 0.84, 0.28, 1);
  --nav-h: 72px;
}

/* -------------------------------------------------------------------------
   03. Reset and base
   ------------------------------------------------------------------------- */

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* The nav is fixed, so an anchor that scrolls a section to the top of the
     viewport parks its heading underneath the bar. One declaration here fixes
     every in-page link on the site rather than a scroll-margin per target. */
  scroll-padding-top: calc(var(--nav-h) + 1.5rem);
  background: var(--forest-950);
}

body,
h1,
h2,
h3,
p,
ul,
ol,
figure,
blockquote {
  margin: 0;
}

ul,
ol {
  padding: 0;
  list-style: none;
}

body {
  background: transparent;
  color: var(--text-primary);
  font-family: var(--font-sans);
  font-size: var(--step-body);
  font-weight: 400;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* 320px support: nothing is allowed to push the page sideways. `clip`
     rather than `hidden` where it is supported, because `hidden` makes the
     body a scroll container, which is a documented way to break the
     position:sticky the act depends on. */
  overflow-x: hidden;
  overflow-x: clip;
}

img,
svg,
canvas {
  display: block;
  max-width: 100%;
}

a {
  color: var(--sage-bright);
  text-decoration-thickness: 1px;
  text-underline-offset: 0.18em;
}

/* Never outline:none without a replacement. The 3px offset is load-bearing:
   it puts the ring on the page background rather than against a button fill,
   where sage-bright on sage would be 1.4:1. On the field it is 5.3:1 at the
   glow's brightest and 10.8:1 everywhere else. */
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
  border-radius: 3px;
}

::selection {
  background: rgba(103, 188, 124, 0.3);
  color: #fff;
}

/* -------------------------------------------------------------------------
   04. Background field
   One fixed layer for the whole document: gradient, canvas, grain, vignette.
   Nothing here ever scrolls, so the page moves through it instead of past
   it. pointer-events:none throughout, so it can never eat a click.
   ------------------------------------------------------------------------- */

.field {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  /* Own layer, so scrolling content never repaints the gradient. */
  transform: translateZ(0);
}

/* The base tone, under everything. It has to match the colour the particle
   canvas washes itself with each frame, because that canvas is opaque and
   fading it out reveals exactly this. */
.field__base {
  position: absolute;
  inset: 0;
  /* The wash colour the canvas paints itself with each frame sits inside this
     ramp, so fading the canvas out never shows a seam against it. */
  background: linear-gradient(168deg, var(--forest-900) 0%, var(--forest-950) 100%);
}

/* The photographic scene, on the pages that carry one.

   Sits between the base tone and the particles: the water is behind the flow,
   never over it. Its opacity is written from tw-field.js as the page scrolls
   through the hero, so the layer is inert — and absent, at `--field-scene: 0`
   — on every page that does not set one.

   The grade is split. The build script ships the picture already darkened and
   desaturated, because those are bytes; everything below is the part worth
   being able to retune without a rebuild, so it is all custom properties. */
.field__scene {
  position: absolute;
  inset: 0;
  display: block;
  /* Defaults to present, not to absent. This picture is the hero's largest
     paint and it is fetched at high priority; starting it at zero and waiting
     for tw-field.js — which boots on an idle callback after load, deliberately
     late — would hold the whole scene back behind the script and then fade it
     in over a hero the reader is already looking at. The script takes the
     property over on its first frame. The cost is that with scripting off the
     scene never fades, which is a fixed photograph behind the sections below
     the hero; that is the milder of the two failures by a distance. */
  opacity: var(--field-scene, 1);
  /* Where the water sits vertically once `cover` has trimmed the crop. Read
     back by tw-field.js through getComputedStyle to place the emitter, so this
     property is the single source of truth for the framing: move it and the
     particles move with it. */
  --scene-y: 50%;
  /* How far the whole frame is pulled toward the page's own ground.

     Low, and the scrim below does the work instead. A flat pull applies
     equally to the sun, the mist and the shadow, so raising it to protect the
     copy costs the picture the warm half of its range everywhere — including
     the two-thirds of the frame that has no copy over it and is the reason for
     using this photograph. All this needs to do is seat the frame on the
     page's own tone; the scrim handles legibility where legibility is
     actually at stake. */
  --scene-tone: 0.16;
}

/* The <picture> is a grouping element, not a box: dropping it out of the
   layout leaves the <img> as a direct child of the scene, which is what the
   100% below is measured against. */
.field__scene picture {
  display: contents;
}

.field__scene img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% var(--scene-y);
  /* The media reset caps every image at max-width:100%, which is right for
     content and wrong for a layer that has to fill the viewport exactly. */
  max-width: none;
  filter: saturate(var(--scene-sat, 0.95)) brightness(var(--scene-lift, 1));
}

/* Two overlays, kept apart because they answer different questions.

   ::before is tone: a light, flat pull that seats the frame on the page's own
   ground so it reads as part of the page rather than as a picture pasted onto
   it.

   ::after is legibility: a scrim under the copy, and a fade at the foot of the
   viewport so the scene hands off to the dark rather than ending at an edge.
   The direction of the first gradient is the layout — horizontal while the
   copy is in a left-hand column, vertical once it goes full width.

   The scrim is heavy where the copy is and gone by 84%, which is the shape
   that lets the tone above stay low: the sun, the mist on the far wall and the
   falls themselves are all past the point where it has any effect. Measured
   against the copy column rather than guessed — the worst white-on-scene
   contrast anywhere in the headline band is 6.1:1, against a 4.5:1 bar, and
   the pixel that sets it is the lit mist at the column's right edge rather
   than anything on the left. */
.field__scene::before,
.field__scene::after {
  content: '';
  position: absolute;
  inset: 0;
}

.field__scene::before {
  background: var(--forest-950);
  opacity: var(--scene-tone);
}

.field__scene::after {
  background:
    linear-gradient(to bottom, transparent 62%, rgba(7, 18, 9, 0.66) 88%, var(--forest-950) 100%),
    linear-gradient(
      96deg,
      rgba(7, 18, 9, 0.86) 0%,
      rgba(7, 18, 9, 0.74) 30%,
      rgba(7, 18, 9, 0.2) 62%,
      transparent 84%
    );
}

/* Portrait puts the copy above the water rather than beside it, so the scrim
   turns with it. Keyed to orientation and nothing else, because that is what
   the `<source>` in the markup switches crops on and what the hero measure
   opens up at: a scrim on a width breakpoint would sooner or later run
   horizontally across a vertical photograph. */
@media (orientation: portrait) {
  .field__scene::after {
    background:
      linear-gradient(to bottom, transparent 66%, rgba(7, 18, 9, 0.68) 90%, var(--forest-950) 100%),
      linear-gradient(
        to bottom,
        rgba(7, 18, 9, 0.88) 0%,
        rgba(7, 18, 9, 0.7) 26%,
        rgba(7, 18, 9, 0.2) 54%,
        transparent 72%
      );
  }
}

/* The flow itself. Pages that only want it in places mark those sections
   `data-field-window`, and the script writes a vertical gradient here that
   fades the canvas out everywhere else; with no windows the property is unset
   and this resolves to `none`, which is every page that wants the full field.
   Masking the canvas rather than the whole field leaves the gradient, glow,
   grain and vignette alone.

   Screened rather than stacked, which is what lets a photograph sit under it.
   The canvas is opaque by construction — trails are made by washing the whole
   buffer every frame, and the transparent alternative does not survive 8-bit
   alpha; see the note on TRAIL_FADE in tw-field.js. An opaque layer over the
   scene would simply be the scene gone. So the wash is pure black instead of
   the page's ground tone, and the canvas is screened: black screens to
   nothing, so where there are no particles this layer is not there at all, and
   where there are, they arrive as light added to whatever is beneath them.
   That is also the right model off the hero — the base gradient now shows
   through the flow instead of being covered by a flat tone the same colour. */
.field__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: var(--field-alpha, 1);
  mix-blend-mode: screen;
  -webkit-mask-image: var(--field-window, none);
  mask-image: var(--field-window, none);
}

/* Two offset radial glows, screened over the field rather than sitting under
   it. This is the reference art's structure: a warm green core lifted off a
   near-black frame, with one cool pocket toward an edge so the green never
   reads flat. Screening it on top also means the particle trails are lit by
   the glow instead of being covered by it. */
.field__glow {
  position: absolute;
  /* Vertical bleed only. A horizontal one overflows the document, and mobile
     Chrome answers that by widening the layout viewport to fit it, which
     scales the entire page down and pushes the nav's CTA off screen. The
     drift below is vertical, so the bleed only ever needed to be. */
  inset: -8% 0;
  background:
    radial-gradient(
      58% 44% at 62% 30%,
      rgba(70, 134, 102, 0.44) 0%,
      rgba(44, 106, 85, 0.22) 38%,
      transparent 72%
    ),
    radial-gradient(52% 42% at 18% 72%, rgba(37, 52, 63, 0.45) 0%, transparent 68%);
  mix-blend-mode: screen;
  /* Damped from JS while a photographic scene is up. This glow is what gives a
     bare gradient its depth, and over a photograph it is a second light source
     the photograph did not have: at full strength it lays a flat green haze
     across the water. It comes back as the scene fades. */
  opacity: var(--field-glow-alpha, 1);
  /* Drifts a little as the page scrolls, driven from JS. Kept to a few dozen
     pixels: enough to feel alive, not enough to read as parallax. */
  transform: translate3d(0, var(--field-drift, 0px), 0) scale(1.04);
  will-change: transform;
}

/* Filmic grain. One fixed SVG turbulence tile at very low opacity, which is
   most of what stops a large dark gradient from banding. */
.field__grain {
  position: absolute;
  inset: 0;
  opacity: 0.05;
  mix-blend-mode: overlay;
  background-image: var(--grain);
}

/* Darkens the frame so headline text always lands on the deepest part of
   the gradient, whatever the glow is doing behind it. */
.field__vignette {
  position: absolute;
  inset: 0;
  background: radial-gradient(
    120% 90% at 50% 45%,
    transparent 38%,
    rgba(7, 18, 9, 0.55) 82%,
    rgba(7, 18, 9, 0.85) 100%
  );
}

/* Everything real sits above the field. */
.nav,
main,
.footer {
  position: relative;
  z-index: 1;
}

/* -------------------------------------------------------------------------
   05. Primitives
   ------------------------------------------------------------------------- */

.container {
  width: 100%;
  max-width: var(--max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.section {
  padding-block: var(--section-y);
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  top: 0;
  left: var(--gutter);
  z-index: 100;
  transform: translateY(-120%);
  padding: 0.75rem 1rem;
  background: var(--forest-800);
  color: var(--sage-bright);
  font-weight: 600;
  border: 1px solid var(--rule-strong);
  border-radius: 0 0 var(--radius) var(--radius);
}

.skip-link:focus-visible {
  transform: translateY(0);
}

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 0.625rem;
  font-size: var(--step-eyebrow);
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--sage-bright);
}

.eyebrow::before {
  content: '';
  width: 1.75rem;
  height: 1px;
  background: currentColor;
  opacity: 0.6;
}

h1,
h2,
h3 {
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.03em;
  text-wrap: balance;
}

h2 {
  font-size: var(--step-h2);
  font-weight: 700;
  line-height: 1.06;
  letter-spacing: -0.025em;
}

h3 {
  font-size: var(--step-h3);
  font-weight: 700;
  line-height: 1.25;
  letter-spacing: -0.015em;
}

/* Every section leads with an eyebrow, so the gap under it is a token rather
   than a per-section override. */
.section__title,
.problem__title {
  margin-top: 1.25rem;
}

/* The turn in a section title, carried in the brand green rather than in
   italics — the same move .thesis__statement em and .close__statement em make.
   Safe on --sage rather than --sage-bright because section titles are display
   scale, which the note at the top of this file allows. */
.section__title em {
  font-style: normal;
  color: var(--sage);
}

.prose {
  max-width: var(--max-prose);
  text-wrap: pretty;
}

/* Buttons -----------------------------------------------------------------
   min-height 44px is the mobile touch target floor, enforced here rather
   than in a media query so it can never regress on one breakpoint. */

.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  min-height: 46px;
  padding: 0.8125rem 1.5rem;
  border: 1px solid transparent;
  border-radius: var(--radius-btn);
  font-family: inherit;
  font-size: var(--step-small);
  font-weight: 600;
  letter-spacing: -0.005em;
  line-height: 1.2;
  text-decoration: none;
  cursor: pointer;
  transition: background-color 200ms var(--ease), border-color 200ms var(--ease),
    color 200ms var(--ease), transform 200ms var(--ease);
}

.btn--primary {
  background: var(--sage);
  color: var(--forest-800);
}

.btn--primary:hover {
  background: var(--sage-bright);
  transform: translateY(-1px);
}

.btn--ghost {
  background: rgba(255, 255, 255, 0.04);
  color: var(--text-primary);
  border-color: var(--rule-strong);
}

.btn--ghost:hover {
  background: rgba(255, 255, 255, 0.1);
  border-color: rgba(255, 255, 255, 0.42);
  transform: translateY(-1px);
}

.btn-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 2.25rem;
}

.microcopy {
  margin-top: 1.25rem;
  font-size: var(--step-small);
  color: var(--text-tertiary);
}

/* -------------------------------------------------------------------------
   06. Scroll reveal
   Gated behind .reveal-ready, which the script sets on <html> before first
   paint of the observer. Without JS the attribute does nothing and every
   section is simply visible, which is the correct no-JS page.
   ------------------------------------------------------------------------- */

.reveal-ready [data-reveal] {
  opacity: 0;
  transform: translate3d(0, 1.75rem, 0);
  transition: opacity 900ms var(--ease-out), transform 900ms var(--ease-out);
  transition-delay: calc(var(--d, 0) * 80ms);
  will-change: opacity, transform;
}

.reveal-ready [data-reveal='mask'] {
  display: block;
  transform: translate3d(0, 100%, 0);
  opacity: 1;
  transition: transform 1100ms var(--ease-out);
}

.reveal-ready [data-reveal='fade'] {
  transform: none;
}

.reveal-ready [data-reveal].is-in {
  opacity: 1;
  transform: none;
  will-change: auto;
}

/* The mask itself. Two elements so the clip is on the parent and the travel
   is on the child, which is what keeps the letters from being cropped mid
   animation on a descender. */
.mask {
  display: block;
  overflow: hidden;
  padding-block: 0.06em;
  margin-block: -0.06em;
}

/* -------------------------------------------------------------------------
   07. Nav
   Fixed, transparent over the hero, and only earns a surface once the page
   has moved. The field behind it is fixed too, so a solid bar would cut a
   visible seam across the animation.
   ------------------------------------------------------------------------- */

.nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  transition: background-color 300ms var(--ease), border-color 300ms var(--ease),
    backdrop-filter 300ms var(--ease);
  border-bottom: 1px solid transparent;
}

/* An open panel gets the bar its surface too, even at the top of the page —
   otherwise the panel hangs off a transparent bar with a seam between them. */
.nav.is-stuck,
.nav.is-open {
  background: rgba(9, 22, 14, 0.72);
  backdrop-filter: blur(14px) saturate(140%);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
  border-bottom-color: var(--rule);
}

.nav__inner {
  display: flex;
  align-items: center;
  gap: 1.5rem;
  min-height: var(--nav-h);
}

.nav__brand {
  display: inline-flex;
  align-items: center;
  color: var(--text-primary);
  text-decoration: none;
  padding-block: 0.5rem;
}

/* The wordmark ships with a hard-coded white fill. Repainting the paths with
   currentColor keeps it on the same token as the rest of the nav, so a hover
   or a future light surface only has to move one colour. */
.nav__logo {
  display: block;
  height: 15px;
  width: auto;
}

.nav__logo path {
  fill: currentColor;
}

/* Above the collapse breakpoint the panel is not a box at all — `contents`
   drops it out of the layout so the link list and the actions are direct flex
   children of .nav__inner, exactly as they were before the wrapper existed.
   Below the breakpoint it becomes the dropdown. */
.nav__panel {
  display: contents;
}

/* Left aligned: the list follows the wordmark on the .nav__inner gap and the
   actions take the auto margin, so the bar reads brand → nav → CTAs. */
.nav__links {
  display: flex;
  align-items: center;
  gap: 0.25rem;
}

.nav__link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 0.75rem;
  font-size: var(--step-small);
  font-weight: 500;
  color: var(--text-secondary);
  text-decoration: none;
  border-radius: 8px;
  transition: color 160ms var(--ease), background-color 160ms var(--ease);
}

.nav__link:hover {
  color: var(--text-primary);
  background: rgba(255, 255, 255, 0.06);
}

/* Current page. Weight and colour rather than an underline, because the bar
   sits over the field and a rule under one item reads as a stray edge. */
.nav__link[aria-current='page'] {
  color: var(--text-primary);
  font-weight: 600;
}

.nav__actions {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  margin-left: auto;
}

/* Hamburger. Hidden until the link set stops fitting; two bars that cross into
   an X when the panel is open. The bars are the only thing that moves, so the
   44px target never shifts under a thumb mid-tap. */
.nav__toggle {
  display: none;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin-left: auto;
  padding: 0;
  background: none;
  border: 0;
  border-radius: 8px;
  color: var(--text-primary);
  cursor: pointer;
}

.nav__toggle:hover {
  background: rgba(255, 255, 255, 0.06);
}

.nav__toggle-box {
  position: relative;
  display: block;
  width: 20px;
  height: 12px;
}

.nav__toggle-bar {
  position: absolute;
  left: 0;
  width: 100%;
  height: 1.5px;
  background: currentColor;
  border-radius: 2px;
  transition: transform 220ms var(--ease), opacity 160ms var(--ease);
}

.nav__toggle-bar:nth-child(1) {
  top: 0;
}

.nav__toggle-bar:nth-child(2) {
  bottom: 0;
}

.nav.is-open .nav__toggle-bar:nth-child(1) {
  transform: translateY(5.25px) rotate(45deg);
}

.nav.is-open .nav__toggle-bar:nth-child(2) {
  transform: translateY(-5.25px) rotate(-45deg);
}

/* Disclosure menu. Uses <details> so it works with zero JS and is keyboard
   operable by default. */
.nav__menu {
  position: relative;
}

.nav__menu > summary {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  min-height: 44px;
  padding: 0 0.75rem;
  font-size: var(--step-small);
  font-weight: 500;
  color: var(--text-secondary);
  border-radius: 8px;
  cursor: pointer;
  list-style: none;
}

.nav__menu > summary::-webkit-details-marker {
  display: none;
}

.nav__menu > summary:hover {
  color: var(--text-primary);
  background: rgba(255, 255, 255, 0.06);
}

.nav__caret {
  width: 9px;
  height: 9px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  transition: transform 200ms var(--ease);
}

.nav__menu[open] .nav__caret {
  transform: rotate(180deg);
}

.nav__menu-list {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  min-width: 15rem;
  padding: 0.375rem;
  background: rgba(11, 26, 17, 0.94);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);
}

.nav__menu-list a {
  display: block;
  min-height: 44px;
  padding: 0.6875rem 0.75rem;
  font-size: var(--step-small);
  color: var(--text-secondary);
  text-decoration: none;
  border-radius: 8px;
}

.nav__menu-list a:hover {
  color: var(--text-primary);
  background: rgba(255, 255, 255, 0.07);
}

/* Login popout. Same disclosure mechanics as the audience menu — a <details>,
   so it opens with no JS and is keyboard operable by default — but the trigger
   is the ghost button the bar already carried and the panel routes to two
   apps rather than listing pages. */
.nav__login {
  position: relative;
}

.nav__login > summary {
  cursor: pointer;
  list-style: none;
}

.nav__login > summary::-webkit-details-marker {
  display: none;
}

.nav__login[open] .nav__caret {
  transform: rotate(180deg);
}

.login-menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  /* Wide enough to hold the longer of the two descriptions on one line: the
     pair reads as two rows, and a wrap under one of them breaks that. */
  width: 24.5rem;
  max-width: calc(100vw - 2 * var(--gutter));
  padding: 0.75rem;
  background: rgba(11, 26, 17, 0.94);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);
}

.login-menu__eyebrow {
  padding: 0.375rem 0.5rem 0.6875rem;
  font-size: var(--step-eyebrow);
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-tertiary);
}

.login-card {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 0.875rem;
  padding: 0.75rem;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 12px;
  text-decoration: none;
  transition: background-color 200ms var(--ease), border-color 200ms var(--ease);
}

.login-card + .login-card {
  margin-top: 0.5rem;
}

.login-card:hover {
  background: var(--surface-hi);
  border-color: rgba(103, 188, 124, 0.55);
}

.login-card__icon {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 11px;
}

/* One tile per destination, on the two accents the system already owns: the
   brand green for the person carrying the app, the cool note for the desk that
   runs it. Both are icon only, which is the only place --sky is allowed. */
.login-card__icon--individual {
  color: var(--sage-bright);
  background: rgba(103, 188, 124, 0.16);
  border: 1px solid rgba(103, 188, 124, 0.28);
}

.login-card__icon--org {
  color: var(--sky);
  background: rgba(90, 167, 248, 0.16);
  border: 1px solid rgba(90, 167, 248, 0.3);
}

/* min-width:0 so a long description wraps inside the grid track instead of
   pushing the chevron off the panel. */
.login-card__text {
  display: grid;
  gap: 0.1875rem;
  min-width: 0;
}

.login-card__title {
  font-size: var(--step-body);
  font-weight: 600;
  line-height: 1.2;
  color: var(--text-primary);
}

.login-card__desc {
  font-size: var(--step-small);
  line-height: 1.35;
  color: var(--text-tertiary);
}

.login-card__chevron {
  color: var(--text-tertiary);
  transition: transform 200ms var(--ease), color 200ms var(--ease);
}

.login-card:hover .login-card__chevron {
  color: var(--text-primary);
  transform: translateX(2px);
}

.login-menu__foot {
  margin-top: 0.625rem;
  padding: 0.8125rem 0.5rem 0.25rem;
  border-top: 1px solid var(--rule);
  font-size: var(--step-small);
  color: var(--text-tertiary);
}

.login-menu__foot a {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  font-weight: 600;
  color: var(--sage-bright);
  text-decoration: none;
}

.login-menu__foot a:hover {
  color: #b7ecc6;
}

.login-menu__foot a svg {
  transition: transform 220ms var(--ease);
}

.login-menu__foot a:hover svg {
  transform: translateX(3px);
}

@media (max-width: 900px) {
  /* Below the desktop breakpoint the full link set does not fit without
     shrinking touch targets, so the whole thing — links, audience menu, both
     CTAs — collapses behind the hamburger. Nothing is dropped: the panel
     carries the same markup the bar was carrying. */
  .nav__toggle {
    display: inline-flex;
  }

  .nav__panel {
    display: block;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    max-height: calc(100svh - var(--nav-h));
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 0.5rem var(--gutter) 1.25rem;
    background: rgba(11, 26, 17, 0.97);
    backdrop-filter: blur(18px);
    -webkit-backdrop-filter: blur(18px);
    border-bottom: 1px solid var(--rule);
    box-shadow: 0 22px 44px rgba(0, 0, 0, 0.42);

    /* Closed by default. Visibility rather than display so the open and close
       both animate, and pointer-events so a closed panel cannot be tapped
       through the hero underneath it. */
    visibility: hidden;
    opacity: 0;
    pointer-events: none;
    transform: translateY(-8px);
    transition: opacity 200ms var(--ease), transform 240ms var(--ease),
      visibility 0s linear 240ms;
  }

  .nav.is-open .nav__panel {
    visibility: visible;
    opacity: 1;
    pointer-events: auto;
    transform: none;
    transition-delay: 0s;
  }

  /* Stacked, full-bleed rows. Every item keeps its 44px target and the hit
     area runs the width of the panel rather than hugging the label. */
  .nav__links {
    flex-direction: column;
    align-items: stretch;
    gap: 0.125rem;
  }

  .nav__link,
  .nav__menu > summary {
    justify-content: flex-start;
    min-height: 48px;
    padding: 0 0.75rem;
    font-size: var(--step-body);
    color: var(--text-primary);
  }

  .nav__menu {
    position: static;
  }

  .nav__menu > summary {
    justify-content: space-between;
  }

  /* Inline expansion, not an overlay: a floating card inside a panel that is
     itself a card reads as two surfaces stacked on the same plane. */
  .nav__menu-list {
    position: static;
    min-width: 0;
    padding: 0.125rem 0 0.375rem 0.75rem;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border: 0;
    border-left: 1px solid var(--rule);
    border-radius: 0;
    margin-left: 0.75rem;
    box-shadow: none;
  }

  .nav__menu-list a {
    display: flex;
    align-items: center;
  }

  .nav__actions {
    flex-direction: column;
    align-items: stretch;
    gap: 0.5rem;
    margin-left: 0;
    margin-top: 0.875rem;
    padding-top: 1rem;
    border-top: 1px solid var(--rule);
  }

  .nav__actions .btn {
    width: 100%;
  }

  /* The hover lift is a pointer affordance; on a full-width stacked button it
     just makes the row twitch under a thumb. */
  .nav__actions .btn:hover {
    transform: none;
  }

  /* The panel scrolls, and an overflow container clips anything absolutely
     positioned inside it, so the login popout expands in place here the way
     the audience menu does rather than floating over the panel it lives in. */
  .nav__login {
    position: static;
    align-self: stretch;
  }

  .login-menu {
    position: static;
    width: auto;
    max-width: none;
    margin-top: 0.5rem;
    padding: 0;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border: 0;
    border-radius: 0;
    box-shadow: none;
  }

  .login-menu__eyebrow {
    padding-inline: 0.25rem;
  }

  .login-menu__foot {
    padding-inline: 0.25rem;
  }
}

/* Tablet width panel. A full-bleed button is 700px wide at 820px, so the
   buttons drop back to their natural width, left aligned under the links
   they follow. They stay stacked rather than becoming a row: the login trigger
   expands a panel underneath itself, which a row would have to reflow around. */
@media (min-width: 561px) and (max-width: 900px) {
  .nav__actions {
    align-items: flex-start;
  }

  .nav__actions .btn {
    width: auto;
  }

  /* Two cards stretched across 760px are a lot of empty tile; capped, they
     stay the same object they are everywhere else. */
  .login-menu {
    max-width: 26rem;
  }
}

@media (max-width: 560px) {
  .nav__inner {
    gap: 0.75rem;
    min-height: 62px;
  }

  /* The wordmark is ten times wider than it is tall, so at 320px it has to
     come down a little to leave the toggle its full 44px target. */
  .nav__logo {
    height: 13px;
  }

  .nav__panel {
    max-height: calc(100svh - 62px);
  }
}

/* -------------------------------------------------------------------------
   08. Hero
   One viewport, centred, no artwork of its own: the field behind the page is
   the artwork. The headline is the LCP element and nothing animates its
   opacity, only its transform, so it paints immediately.
   ------------------------------------------------------------------------- */

.hero {
  position: relative;
  min-height: 100svh;
  display: grid;
  align-items: center;
  padding-top: calc(var(--nav-h) + 2rem);
  padding-bottom: 7rem;
}

/* Left-aligned, and held to a measure that stops short of the water.

   The wide frame puts the fall's left edge at 59% of the viewport once `cover`
   has trimmed it, and the lit mist on the near wall reaches in to about 52%.
   The mist is the real constraint, not the water: it is the brightest thing
   the copy could land on and it is what sets the worst contrast in the
   headline band. Hence 52vw. Two things follow, and both are easy to get
   wrong.

   It has to be measured in vw. A percentage on these blocks resolves against
   the container's content box, which at 1440 is 1136px inset by 152 — so a
   `52%` measure would end at 743px, against a viewport where 52vw is 749 and
   the two only agree by accident.

   And the container's own inset has to come out of it, because the copy starts
   at that inset rather than at the edge of the screen. Hence 52vw minus the
   inset: the block ends short of the mist at every width, with a couple of per
   cent of slack for the difference between what `vw` measures and what a fixed
   layer actually fills when there is a scrollbar.

   The condition is orientation rather than a width, because that is exactly
   the condition the `<source>` in the markup switches crops on. Keying the
   layout to a breakpoint and the picture to an orientation is how you end up
   with a horizontal scrim under a vertical photograph. In portrait the copy
   sits above the fall rather than beside it and the measure opens back up. */
.hero {
  /* Viewport edge to the container's content box. */
  --hero-inset: max(var(--gutter), calc(50vw - var(--max) / 2 + var(--gutter)));
}

.hero__inner {
  text-align: left;
  --hero-measure: 100%;
}

@media (orientation: landscape) {
  .hero__inner {
    --hero-measure: calc(52vw - var(--hero-inset));
  }
}

/* The lead and the sub are not here: they carry their own cap further down,
   because a paragraph wants the tighter of this column and its own reading
   measure, and a declaration in both places would only be settled by source
   order. */
.hero__title,
.hero .btn-row {
  max-width: var(--hero-measure);
  margin-inline: 0;
}

.hero__title {
  margin-top: 1.25rem;
  font-size: var(--step-display);
  font-weight: 800;
  line-height: 0.98;
  letter-spacing: -0.045em;
}

/* A headline that is two full sentences rather than two phrases, now set in a
   column rather than across the page. The pair measures 15.5em and 21.5em at
   this weight and tracking, so it was always going to wrap; what changed with
   the scene behind it is how much room it has to wrap in.

   The cap is cut against the measure the copy column actually gets, which
   above 1280 is the 40rem it is held to rather than the container. At 3.5rem
   the first statement takes two lines and the second takes two, which is the
   four-line block the layout is balanced for. The old 4.5rem cap was chosen
   for a centred headline sitting on the full 1136px measure; dropped into a
   640px column it sets the first statement in three lines and the second in
   four, and a seven-line headline stops being a headline.

   The floor holds a phone to two lines per statement against the full-width
   measure the portrait layout hands back. */
.hero__title--statement {
  font-size: clamp(2.25rem, 1.05rem + 2.7vw, 3.5rem);
}

.hero__title .mask + .mask {
  margin-top: 0.04em;
}

/* The second line carries the turn in the sentence, so it gets the accent
   colour rather than a weight change. */
.hero__title-accent {
  color: var(--sage);
}

/* The lead-in sits a step above the sub and takes the primary text colour, so
   the eye lands on it first and is handed down to the paragraph below. */
.hero__lead {
  /* Whichever is tighter: the column the scene leaves, or the measure this
     size wants to be read at. In portrait the first is the full container, so
     without the second a paragraph on a tablet sets to 43rem. */
  max-width: min(var(--hero-measure), 42rem);
  margin: 1.75rem 0 0;
  font-size: clamp(1.3125rem, 1.06rem + 1.05vw, 1.75rem);
  font-weight: 600;
  line-height: 1.35;
  letter-spacing: -0.015em;
  color: var(--text-primary);
  text-wrap: pretty;
}

.hero__lead + .hero__sub {
  margin-top: 1rem;
}

.hero__sub {
  max-width: min(var(--hero-measure), 40rem);
  margin: 1.75rem 0 0;
  font-size: var(--step-lead);
  line-height: 1.5;
  color: var(--text-secondary);
  text-wrap: pretty;
}

/* One word of the sub can be lifted out of the secondary tone to carry the
   claim the headline just made. Weight and full-strength text colour only, so
   it reads as emphasis inside the sentence rather than a second accent. */
.hero__sub strong {
  font-weight: 700;
  color: var(--text-primary);
}

.hero .btn-row {
  justify-content: flex-start;
}

/* An interior hero, with no photograph of its own to steer around.

   The left alignment above exists for one reason: on the homepage and the
   company page a picture fills the viewport behind the copy, and the copy has
   to stop short of it. A page with nothing behind it but the field has no such
   edge to respect, so the hero goes back to what it was before the scenes
   arrived — centred, on the container's full measure. */
.hero--centered .hero__inner {
  text-align: center;
  --hero-measure: 100%;
}

.hero--centered .hero__title,
.hero--centered .hero__lead,
.hero--centered .hero__sub {
  margin-inline: auto;
}

.hero--centered .btn-row {
  justify-content: center;
}

/* -------------------------------------------------------------------------
   09. Thesis
   ------------------------------------------------------------------------- */

.thesis {
  text-align: center;
}

.thesis__statement {
  max-width: 52rem;
  margin-inline: auto;
  font-size: var(--step-statement);
  font-weight: 700;
  line-height: 1.18;
  letter-spacing: -0.03em;
  text-wrap: balance;
}

.thesis__statement em {
  font-style: normal;
  color: var(--sage);
}

/* -------------------------------------------------------------------------
   10. Problem
   ------------------------------------------------------------------------- */

.problem__body {
  margin-top: 1.75rem;
  font-size: var(--step-lead);
  line-height: 1.55;
  color: var(--text-secondary);
}

/* -------------------------------------------------------------------------
   11. The act
   A 280vh track with a pinned stage. This is where the field narrows into a
   neck and opens again; the stage itself holds only type, because every
   moving part of the moment is painted on the fixed canvas behind the page.
   ------------------------------------------------------------------------- */

.act {
  position: relative;
  height: 280vh;
}

.act__stage {
  position: sticky;
  top: 0;
  height: 100svh;
  display: grid;
  place-items: center;
  padding-inline: var(--gutter);
  /* Clips the copy scrim below, which is deliberately wider than the text it
     sits behind. Uncontained, it widens the document and mobile Chrome
     responds by scaling the whole page down to fit. Safe on the sticky
     element itself; it is an *ancestor* with overflow that breaks sticky. */
  overflow: hidden;
}

/* All three beats stack in one grid cell and cross-fade. Stacking rather than
   sequencing means the stage height never changes as the copy swaps, so the
   pinned frame is dead still through the whole act. */
.act__beats {
  position: relative;
  display: grid;
  width: 100%;
  max-width: 44rem;
  text-align: center;
}

/* The stream runs straight through the middle of this copy, so the type gets
   a scrim of its own. A radial one with no hard edge: a panel would read as a
   card sitting in front of the animation rather than as part of it. */
.act__beats::before {
  content: '';
  position: absolute;
  inset: -22% -30%;
  background: radial-gradient(
    58% 52% at 50% 50%,
    rgba(6, 16, 10, 0.86) 0%,
    rgba(6, 16, 10, 0.6) 45%,
    transparent 78%
  );
  pointer-events: none;
}

.act__beat {
  position: relative;
  grid-area: 1 / 1;
  opacity: 0;
  transform: translate3d(0, 1.25rem, 0) scale(0.98);
  transition: opacity 620ms var(--ease-out), transform 620ms var(--ease-out);
  /* Transparent rather than hidden: every beat stays in the accessibility
     tree so the argument reads as four paragraphs in order. Nothing inside a
     beat is interactive, so there is no focus trap to worry about, and
     pointer-events keeps the stacked copy from eating a text selection. */
  pointer-events: none;
}

.act__beat.is-live {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

.act__beat p:not(.eyebrow) {
  margin-top: 1.25rem;
  font-size: var(--step-statement);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.03em;
  text-wrap: balance;
}

/* A supporting line under a beat's statement: body size and secondary colour,
   so the statement above stays the only thing in the beat set at display
   scale. */
.act__beat p.act__support {
  margin-top: 1.25rem;
  font-size: var(--step-body);
  font-weight: 500;
  line-height: 1.6;
  letter-spacing: 0;
  color: var(--text-secondary);
}

/* The pinch statement is the brightest line in the act; its supporting line
   is not, so it stays out of this rule and keeps the secondary colour. */
.act__beat--pinch p:not(.eyebrow):not(.act__support) {
  color: var(--text-primary);
}

.act__eyebrow {
  justify-content: center;
}

/* Centres the eyebrow rule pair on this section only: the default eyebrow
   has one leading rule, which reads off-centre under centred type. */
.act__eyebrow::after {
  content: '';
  width: 1.75rem;
  height: 1px;
  background: currentColor;
  opacity: 0.6;
}

/* -------------------------------------------------------------------------
   12. Two worlds
   Three columns where the centre is a rule carrying the payoff line, not a
   card. On mobile the order is Institutions, centre line, People.
   ------------------------------------------------------------------------- */

.worlds__grid {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  gap: clamp(1.5rem, 4vw, 3.5rem);
  margin-top: clamp(2.5rem, 5vw, 4rem);
  align-items: stretch;
}

.worlds__col > h3 {
  padding-bottom: 0.875rem;
  border-bottom: 1px solid var(--rule);
}

.worlds__list {
  margin-top: 1.25rem;
  display: grid;
  gap: 0.75rem;
}

.worlds__list li {
  position: relative;
  padding-left: 1.5rem;
  color: var(--text-secondary);
}

.worlds__list li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.58em;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--sage);
  box-shadow: 0 0 12px var(--accent-glow);
}

.worlds__col--people .worlds__list li::before {
  background: var(--sky);
  box-shadow: 0 0 12px rgba(90, 167, 248, 0.7);
}

.worlds__centre {
  display: grid;
  grid-template-rows: 1fr auto 1fr;
  justify-items: center;
  gap: 1.25rem;
  min-width: 12rem;
}

.worlds__centre::before,
.worlds__centre::after {
  content: '';
  width: 1px;
  background: linear-gradient(
    to bottom,
    transparent,
    var(--rule-strong) 20%,
    var(--rule-strong) 80%,
    transparent
  );
}

/* The measure is set to the longest half of the sentence, "institutions
   succeed.", so text-wrap: balance has exactly one two-line split available
   and takes the break at the comma. At 11rem it fell to four ragged lines of
   11/8/12/8 characters, which read as lopsided even though the box was
   centred. */
.worlds__payoff {
  max-width: 15.5rem;
  text-align: center;
  font-weight: 700;
  font-size: var(--step-h3);
  line-height: 1.25;
  letter-spacing: -0.015em;
  text-wrap: balance;
}

@media (max-width: 820px) {
  .worlds__grid {
    grid-template-columns: 1fr;
  }

  /* Stacked, the centre becomes a banded divider: rule, payoff, rule. Rules
     flanking the text left and right collapse to stubs at this width, which
     reads as a mistake rather than as the hinge between the two columns. */
  .worlds__centre {
    grid-template-rows: auto auto auto;
    grid-template-columns: minmax(0, 1fr);
    justify-items: stretch;
    gap: 1.125rem;
    width: 100%;
    min-width: 0;
    padding-block: 0.25rem;
  }

  .worlds__centre::before,
  .worlds__centre::after {
    width: auto;
    height: 1px;
    background: var(--rule);
  }

  .worlds__payoff {
    justify-self: center;
    max-width: 20rem;
  }
}

/* -------------------------------------------------------------------------
   13. Category
   Bridge, Guide, Execute in a real left-to-right sequence. The arrows carry
   meaning, so they are drawn, and they rotate to point down when the row
   wraps to a column.
   ------------------------------------------------------------------------- */

.category__body {
  margin-top: 1.5rem;
  font-size: var(--step-lead);
  color: var(--text-secondary);
}

.category__seq {
  --seq-gap: clamp(1.75rem, 4vw, 3.5rem);
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: start;
  gap: var(--seq-gap);
  margin-top: clamp(2.5rem, 5vw, 4rem);
}

.category__step {
  position: relative;
  display: grid;
  gap: 0.75rem;
  align-content: start;
}

.category__num {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 50%;
  border: 1px solid var(--rule-strong);
  background: var(--surface);
  font-size: var(--step-eyebrow);
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--sage-bright);
}

.category__step p {
  color: var(--text-secondary);
}

/* Positioned into the grid gap so the sequence reads as one continuous line
   while the markup stays a plain <ol> of <li>. */
.category__arrow {
  position: absolute;
  top: 0.75rem;
  left: calc(-0.5 * var(--seq-gap) - 1rem);
  width: 2rem;
  height: 1rem;
  fill: none;
  stroke: var(--rule-strong);
  stroke-width: 1.5;
}

@media (max-width: 820px) {
  .category__seq {
    grid-template-columns: 1fr;
    gap: 1.5rem;
  }

  /* Stacked, the arrow points down and leads the item it belongs to. */
  .category__arrow {
    position: static;
    margin-left: 0.75rem;
    transform: rotate(90deg);
  }
}

/* -------------------------------------------------------------------------
   14. Router
   Three equal cards, no default, no hierarchy. Each card is one <a> wrapping
   the whole block, so it is keyboard reachable in DOM order and activates on
   Enter without any script.
   ------------------------------------------------------------------------- */

.router__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  gap: 1rem;
  margin-top: clamp(2rem, 4vw, 3rem);
}

.router__card {
  display: grid;
  grid-template-rows: auto auto 1fr;
  gap: 0.625rem;
  padding: clamp(1.5rem, 3vw, 2.25rem);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  text-decoration: none;
  color: var(--text-primary);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  transition: border-color 220ms var(--ease), transform 220ms var(--ease),
    background-color 220ms var(--ease), box-shadow 220ms var(--ease);
}

.router__card:hover {
  background: var(--surface-hi);
  border-color: rgba(103, 188, 124, 0.55);
  transform: translateY(-3px);
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
}

.router__card p {
  font-size: var(--step-small);
  line-height: 1.55;
  color: var(--text-secondary);
}

/* margin-top:auto rather than a fixed gap: the three cards carry different
   amounts of copy, and a cue that floats at a different height in each one
   reads as three cards rather than as one row. */
.router__cue {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  margin-top: auto;
  padding-top: 0.75rem;
  font-size: var(--step-small);
  font-weight: 600;
  color: var(--sage-bright);
}

.router__card:hover .router__cue {
  color: #b7ecc6;
}

.router__card:hover .router__cue svg {
  transform: translateX(3px);
}

.router__cue svg {
  transition: transform 220ms var(--ease);
}

/* -------------------------------------------------------------------------
   15. Trust
   The one section with its own surface, so the four commitments read as a
   held panel rather than as more scrolling copy.
   ------------------------------------------------------------------------- */

.trust__panel {
  padding: clamp(2.25rem, 5vw, 4rem);
  background: linear-gradient(
    140deg,
    rgba(31, 68, 48, 0.55),
    rgba(11, 26, 17, 0.72)
  );
  border: 1px solid var(--rule);
  border-radius: calc(var(--radius) + 8px);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

/* The homepage used to run this panel with a visually-hidden heading and
   nothing above it, which left a lone surface floating in the middle of the
   field. It now carries a visible eyebrow and title like every other page
   using the panel, and this is the margin that holds it off that title. The
   four page stylesheets that each declared their own copy of this rule can
   drop it; an identical duplicate is harmless, but this is the one that
   should survive. */
.trust__panel--titled {
  margin-top: clamp(2.5rem, 5vw, 3.5rem);
}

/* Lead paragraph between the section title and the panel. Same treatment as
   the other section leads; the panel's own top margin already holds it off
   this copy, so it needs nothing else. */
.trust__body {
  margin-top: 1.5rem;
  font-size: var(--step-lead);
  color: var(--text-secondary);
}

/* Four commitments, four columns. At 14rem the last one wrapped to a row of
   its own inside the panel, which reads as an afterthought rather than as the
   fourth of a set. */
.trust__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
  gap: clamp(1.75rem, 2.4vw, 2rem);
}

/* No two-line floor on the heading. It was here to line the four paragraphs up
   across the row back when two of these headings wrapped and two did not; the
   headings are all short enough now to sit on one line, so the floor only
   reserved an empty second line under each one and opened a gap the router
   cards do not have. Same 0.625rem step as .router__card's gap. */
.trust__item p {
  margin-top: 0.625rem;
  font-size: var(--step-small);
  line-height: 1.55;
  color: var(--text-secondary);
}

/* A Font Awesome glyph held in a tile. The 2px rule it replaces gave each
   column a mark but no shoulder, so the copy started against nothing; at
   2.75rem the tile is a fixed object every column begins from, which is most
   of what stops the panel reading as four floating paragraphs.

   The glyphs are the light family, which is a 300 weight: a hairline next to
   the 700 heading under it. It carries the size the solid weight did not need
   — 1.25rem rather than 1.0625rem — because a light stroke at small sizes
   thins out to nearly nothing against the field. The tile keeps its 2.75rem
   box either way, so the size is a matter of the glyph, not the layout. */
.trust__icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  margin-bottom: 1.25rem;
  border: 1px solid var(--rule);
  border-radius: 12px;
  background: rgba(103, 188, 124, 0.12);
  color: var(--sage-bright);
  font-size: 1.25rem;
  line-height: 1;
}

/* -------------------------------------------------------------------------
   16. For people
   ------------------------------------------------------------------------- */

.people__body {
  margin-top: 1.5rem;
  font-size: var(--step-lead);
  color: var(--text-secondary);
}

/* -------------------------------------------------------------------------
   17. Close
   ------------------------------------------------------------------------- */

.close {
  text-align: center;
}

.close__statement {
  max-width: 48rem;
  margin-inline: auto;
  font-size: var(--step-statement);
  font-weight: 700;
  line-height: 1.18;
  letter-spacing: -0.03em;
  text-wrap: balance;
}

/* The turn in the closing line, carried in the brand green rather than in
   italics. Safe on --sage rather than --sage-bright because this type is
   display scale in every viewport, which is the one place the note at the top
   of this file allows the darker green. */
.close__statement em {
  font-style: normal;
  color: var(--sage);
}

.close .btn-row {
  justify-content: center;
}

/* -------------------------------------------------------------------------
   18. Footer
   ------------------------------------------------------------------------- */

.footer {
  padding-block: clamp(3.5rem, 5vw, 5rem) 2.5rem;
  border-top: 1px solid var(--rule);
  background: rgba(7, 18, 9, 0.72);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

.footer__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
  gap: 2.5rem;
}

.footer__col h2 {
  font-size: var(--step-eyebrow);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-tertiary);
}

.footer__col ul {
  margin-top: 1rem;
  display: grid;
  gap: 0.125rem;
}

.footer__col a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-size: var(--step-small);
  color: var(--text-secondary);
  text-decoration: none;
}

.footer__col a:hover {
  color: var(--sage-bright);
  text-decoration: underline;
}

/* The mark on a link that leaves the site. Dimmer than the label it trails,
   because it is a property of the link rather than part of its name, and
   `margin-left` rather than the list's `gap` so the underline on hover stops
   at the word. */
.footer__ext {
  flex: none;
  margin-left: 0.375rem;
  opacity: 0.55;
}

.footer__col a:hover .footer__ext {
  opacity: 1;
}

.footer__base {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-top: clamp(2.5rem, 5vw, 4rem);
  padding-top: 1.75rem;
  border-top: 1px solid var(--rule);
  font-size: var(--step-small);
  color: var(--text-tertiary);
}

/* The fine print: the loss disclosure and the hCaptcha notice the contact and
   demo forms oblige us to carry. It has to appear on every page and will be
   read by almost nobody, so it trails the sign-off row rather than opening
   it, a step below --step-small.

   Centred on its own measure rather than ranged left: the row above already
   pins the wordmark and the copyright to opposite gutters, and a third
   left-aligned block under them leaves the footer heavy on one side. The rule
   stays on .footer__base, where nothing caps its width — a divider that stops
   short of the gutter reads as a mistake. */
.footer__fine {
  display: grid;
  gap: 0.4rem;
  max-width: 46rem;
  margin-top: 1.5rem;
  margin-inline: auto;
  font-size: var(--step-fine);
  line-height: 1.55;
  text-align: center;
  text-wrap: balance;
  color: var(--text-tertiary);
}

.footer__fine a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 0.2em;
  text-decoration-color: var(--rule-strong);
}

.footer__fine a:hover {
  color: var(--sage-bright);
  text-decoration-color: currentColor;
}

/* Same wordmark as the nav, a shade smaller: down here it is a sign-off next
   to the copyright line rather than the primary brand statement. */
.footer__brand {
  display: block;
  height: 14px;
  width: auto;
  color: var(--text-primary);
}

.footer__brand path {
  fill: currentColor;
}

/* =========================================================================
   19. Bands
   The interior-page counterpart to the field.

   The homepage earns a live particle field the whole way down: it is the
   first thing anyone sees of Troutwood and the atmosphere is the pitch. An
   interior page is a document. Run the same field under nine sections of
   argument and the effect stops being atmosphere and becomes wallpaper, and
   the one moment that genuinely wants it — the last line — has nothing left
   to be louder than.

   So interior pages window the particles down to that single moment (see
   `data-field-window` in section 04) and section everything above it by
   depth instead. The hero included: an interior hero is a headline and two
   lines of argument, and a field moving behind them competes for the read
   with the only thing on the screen worth having. It keeps the gradient,
   the glow and the vignette, and gives up the motion.

   A band is an opaque stratum: it covers the field, kills the glow inside
   its own box, and carries the tone the argument is in at that point. The
   page still moves through one environment; it just descends and climbs
   through it rather than floating in a constant.

   Four tones, and they are a sequence, not a palette:

     --band-sunk     below the base. The problem half of a page.
     --band-forest   the workhorse. Solid ground under an argument.
     --band-canopy   the lift. Where a page is climbing out.
     --band-paper    daylight. One per page, at most, and only for the
                     section a reader has to believe before anything else
                     matters. It is the only place the page inverts, which
                     is the entire reason it lands.

   Sections with no band class are left open: the gradient, glow, grain and
   vignette show through as before, only without particles. That contrast
   between held and open is half of what makes the bands read at all, so a
   page that bands every section has missed the point of them.
   ========================================================================= */

.band {
  position: relative;
  /* Contains the grain's blend. Without it the overlay reaches past the band
     to the fixed field underneath, which is the thing the band is covering. */
  isolation: isolate;
  /* Full-bleed comes free: a section is already a block child of <main>, so
     the background spans the viewport while .container keeps the measure. */
  border-top: 1px solid var(--band-seam);
  /* A band is exactly as wide as the viewport, so this is a viewport edge by
     another name, and it is here for the one thing on these pages that
     deliberately reaches past the measure: the mote field around a lead shot
     (see section 20). Without it, a halo wider than the gutter is scrollable
     overflow and the page grows a horizontal scrollbar at the handful of
     widths where the container is centred but not yet wide enough.

     `clip` rather than `hidden`, and one axis rather than two. `hidden`
     would make every band a scroll container, which is the documented way to
     break a sticky descendant; `clip` creates none, and leaving the block
     axis visible keeps the shadows a band's contents cast into the sections
     above and below it. */
  overflow-x: clip;
}

/* A run of same-tone sections is one stratum, not a stack of them: the seam
   and the edge shading both belong to the run, not to each section in it.
   Dropping only the seam is not enough — two abutting bands still pool a
   shadow and catch a light at the joint, which is a darker line through the
   middle of the stratum and reads as exactly the stack this is avoiding.

   Split across two rules because CSS has no previous-sibling combinator: the
   second of a pair loses its lip by `+`, the first loses its foot by `:has`.
   They are separate custom properties rather than two `background`
   redeclarations so both can land on the middle band of a longer run instead
   of the later rule winning outright.

   A browser without `:has()` drops that rule alone and keeps the foot shade
   at the joint, which is the current behaviour and not a broken one. */
.band--sunk + .band--sunk,
.band--forest + .band--forest,
.band--canopy + .band--canopy,
.band--paper + .band--paper {
  border-top-color: transparent;
  --band-lip: 0;
  --band-wash: 0;
}

.band--sunk:has(+ .band--sunk),
.band--forest:has(+ .band--forest),
.band--canopy:has(+ .band--canopy),
.band--paper:has(+ .band--paper) {
  --band-foot: 0;
}

/* Content clears both decoration layers below. */
.band > * {
  position: relative;
  z-index: 1;
}

/* Edges. Light caught along the top, shadow pooling at the foot: the two
   cues that separate a layer of something from a rectangle of colour. Cheap
   enough to put on every band and the reason a run of them reads as
   sedimentary rather than as stripes. */
.band::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background: linear-gradient(
      to bottom,
      rgba(255, 255, 255, var(--band-lip, 0.05)),
      transparent 6rem
    ),
    linear-gradient(to top, rgba(0, 0, 0, var(--band-foot, 0.25)), transparent 5rem);
}

/* The field's grain, at the field's opacity, over the band's own tone. An
   opaque fill this large bands visibly without it, and next to an open
   section it would read as the smoother of the two, which inverts the
   depth the tone is trying to establish. */
.band::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: 0.05;
  mix-blend-mode: overlay;
  background-image: var(--grain);
}

.band--sunk {
  background: var(--band-sunk);
}

.band--forest {
  background: var(--band-forest);
}

/* The one tone lit from inside: a soft green wash off the top edge, so the
   climb out of the dark reads as light arriving rather than as a paler
   rectangle.

   The wash is a custom property for the same reason the lip and the foot are.
   It is the run's, not the section's: two canopy sections in a row would each
   throw their own light off their own top edge, and the second one lands in
   the middle of the stratum as a glow with nothing above it to be the source.
   The run rule above switches it off, so a run is lit once, at the top, which
   is what being lit from inside means. */
.band--canopy {
  background: radial-gradient(
      90% 55% at 50% 0%,
      rgba(103, 188, 124, var(--band-wash, 0.1)),
      transparent 72%
    ),
    var(--band-canopy);
}

/* ---- Paper ---------------------------------------------------------------
   The inversion. Everything below is a token swap rather than a restyle:
   components inside a paper band are the same components, reading the same
   variables, and they land correctly because the variables moved.

   Contrast, measured on the paper ground (#f2f6f0):
     --text-primary   #071209   17.7:1
     --text-secondary #3b4c40    8.5:1
     --text-tertiary  #55675b    5.6:1
     --sage-bright    #14663a    6.5:1
     --sage           #1a6f42    5.7:1   also passes as body text here
   --sage stays a foreground everywhere except .btn--primary, which is why
   that one button is restored by hand below rather than left to the token. */
.band--paper {
  --text-primary: #071209;
  --text-secondary: #3b4c40;
  --text-tertiary: #55675b;
  --rule: rgba(7, 18, 9, 0.14);
  --rule-strong: rgba(7, 18, 9, 0.28);
  --surface: rgba(255, 255, 255, 0.72);
  --surface-hi: #ffffff;
  --sage: #1a6f42;
  --sage-bright: #14663a;
  --focus: #0b4a29;
  /* A bloom is how a light reads against a dark field. Against paper the
     same shadow reads as a printing fault, so the whole device switches off
     with the ground. */
  --accent-glow: transparent;
  color: var(--text-primary);
  background: var(--band-paper);
  border-top-color: transparent;
  /* Cast down onto whatever follows. Offset past the blur so none of it
     bleeds upward over the band above: the daylight sits on the forest, it
     does not glow into it. */
  box-shadow: 0 40px 60px -30px rgba(2, 8, 4, 0.7);
}

/* Green off the top edge, and only a whisper of shade at the foot. The dark
   band's 0.25 pool reads as depth; the same value on paper reads as a page
   that has not finished loading, so this is a quarter of it over half the
   distance. The cut into the section below does the rest. */
.band--paper::before {
  background: radial-gradient(120% 70% at 50% 0%, rgba(26, 111, 66, 0.11), transparent 72%),
    linear-gradient(to top, rgba(7, 24, 13, 0.06), transparent 3rem);
}

.band--paper::after {
  mix-blend-mode: multiply;
  opacity: 0.035;
}

/* The one place --sage is a fill rather than a mark. Sage on paper is 2.1:1,
   which is under the 3:1 a control's own edge needs before you get to its
   label, so the primary CTA inverts with the ground instead of thinning. */
.band--paper .btn--primary {
  background: var(--forest-800);
  color: #f2f8f4;
}

.band--paper .btn--primary:hover {
  background: #1d4529;
}

.band--paper .btn--ghost {
  background: transparent;
  color: var(--text-primary);
  border-color: var(--rule-strong);
}

.band--paper .btn--ghost:hover {
  background: rgba(7, 18, 9, 0.05);
  border-color: rgba(7, 18, 9, 0.45);
}

/* Held panels invert too: on the field a panel is lighter than its ground
   and leans on a blur, on paper it is the ground's own white lifted off it
   by a shadow. Same job, opposite mechanics. */
.band--paper .trust__panel,
.band--paper .spine__panel,
.band--paper .router__card {
  background: #ffffff;
  border-color: rgba(7, 18, 9, 0.09);
  box-shadow: 0 24px 50px -32px rgba(2, 20, 9, 0.45);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

/* The tile's wash is a lit green on the field. On paper it has to be the ink
   green at low alpha instead, or it reads as a highlighter mark. */
.band--paper .trust__icon,
.band--paper .spine__icon {
  background: rgba(26, 111, 66, 0.09);
}

.band--paper ::selection {
  background: rgba(26, 111, 66, 0.22);
  color: var(--forest-950);
}

/* =========================================================================
   20. Product shots
   How a real capture of the console or the app is mounted on the field.

   The system already had one answer to this and it is section 02 of
   tw-platform.css: the bridge, where both products are rendered artwork,
   suspended, drifting, with a particle stream between them. That is a stage.
   This is the other thing a screenshot has to be able to be — evidence, sitting
   in the argument, read rather than admired.

   The constraint the whole component is shaped around is legibility, and it is
   not symmetric between the two surfaces:

     The console captures are 2880px wide, taken at 2x from a 1440px viewport,
     so their body text is ~14 CSS px. Shown across the full measure (1136px
     here) that text lands at ~11px and reads. Shown three-up in the same
     measure it lands at ~4px and the shot stops being evidence and becomes
     texture — a picture of a dashboard rather than a dashboard.

     The app captures are 750px wide, taken at 2x from a 375pt screen, and
     phone UI is already large-format. The same three-up column that destroys
     a console shot renders a phone at ~0.7 scale, which is legible.

   So the rule the pages follow: a console shot is either full measure or a
   crop tight enough to land near 1:1, and a phone shot is shown whole. Crops
   are cut in the pipeline, not here — see scripts/build-product-images.mjs for
   why, and for where each rectangle lands.

   Everything below is the mount: the frame, the two device treatments, and the
   caption. Nothing in it knows which page it is on.
   ========================================================================= */

/* `align-content: start` is load-bearing, not tidying. A row of these sits in a
   grid whose track height is set by the longest caption in it, so every other
   figure is stretched past its own content. A grid with auto rows answers that
   stretch by growing the rows, and the row being grown is the one holding the
   frame: the image inside keeps its ratio, the frame does not, and the shot
   ends up with a strip of empty space inside its own border and under its own
   shadow. Ten or twelve pixels, on whichever shots happen to have the shorter
   captions, which is exactly the kind of fault that reads as sloppiness
   without ever being obvious enough to find. */
.shot {
  position: relative;
  display: grid;
  align-content: start;
  justify-items: center;
  margin: 0;
}

/* The frame is a box of known ratio with the capture clipped inside it, so the
   layout is stable before a single byte of image has arrived. `aspect-ratio`
   rather than a padding hack because every capture here declares its own
   width and height and the two agree by construction.

   `clip` rather than `hidden`: `hidden` on a rounded box makes it a scroll
   container, which is the documented way to break a sticky ancestor, and this
   component has no reason to risk that on a page that has one. */
.shot__frame {
  position: relative;
  width: 100%;
  overflow: clip;
}

.shot__frame img {
  display: block;
  width: 100%;
  height: auto;
}

/* ---- The console -------------------------------------------------------
   A white application on a near-black page needs an edge or it reads as a
   rectangle of light someone pasted on. Three things give it one: a hairline
   in the page's own rule colour, a long soft shadow that puts it above the
   ground rather than in it, and a corner radius that matches the panels
   around it.

   On top of that, a window bar. A console capture cropped at the viewport
   edge has no chrome of its own, so without one the reader is looking at a
   rectangle of interface with nothing around it that says where it lives.
   The bar is the cheapest possible answer: a strip of the frame's own
   background above the capture, a hairline under it, and three dots at the
   left. Nobody reads it — it is recognised, which is the entire job.

   Deliberately monochrome. Traffic lights in red, amber and green would put
   three saturated colours on the page that belong to somebody else's
   operating system, and would fight the one green this design is built on.
   The dots take --rule-strong, which is the same value the frame's own
   hairline takes, so the whole mount is drawn in one ink and inverts with
   the band underneath it for free. */
.shot--console .shot__frame {
  --chrome: clamp(1.375rem, 0.95rem + 1.05vw, 2rem);
  padding-top: var(--chrome);
  background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.04));
  border: 1px solid var(--rule-strong);
  border-radius: calc(var(--radius) + 4px);
  box-shadow: 0 40px 70px -34px rgba(0, 0, 0, 0.85), 0 6px 18px -10px rgba(0, 0, 0, 0.5);
}

/* The seam between bar and capture. Sits one pixel above the capture rather
   than as a border on the bar itself, so the bar's height stays exactly
   --chrome and the dots below can be centred on that number alone. */
.shot--console .shot__frame::before {
  content: '';
  position: absolute;
  inset: calc(var(--chrome) - 1px) 0 auto;
  height: 1px;
  background: var(--rule);
}

/* One dot drawn, two more cast as shadows of it. Three elements' worth of
   mark for one pseudo-element, and the spacing scales with the bar because
   every value is in rem. */
.shot--console .shot__frame::after {
  content: '';
  position: absolute;
  top: calc(var(--chrome) / 2);
  left: clamp(0.75rem, 1.1vw, 1.125rem);
  width: 0.4375rem;
  height: 0.4375rem;
  margin-top: -0.21875rem;
  border-radius: 50%;
  background: var(--rule-strong);
  box-shadow: 0.75rem 0 var(--rule-strong), 1.5rem 0 var(--rule-strong);
}

/* ---- The app -----------------------------------------------------------
   The corner radius is the interesting part. These are raw screen captures,
   not device renders, and a screen with square corners does not read as a
   phone at any size. The real radius is ~12% of the screen's width, and a
   single percentage on a 375x812 box would draw an ellipse: 12% of the width
   is 45px across but 12% of the height is 97px down.

   So the radius is given as two percentages, `12% / 5.54%`, which is the same
   45px in both directions at the capture's own ratio — and stays circular at
   every width the phone is shown at, which one hard-coded pixel value could
   not. 5.54 is 12 x 375/812.

   Around that screen sits a bezel, which is what turns a capture into an
   object you could pick up. It is drawn, not rendered: a ring of padding
   filled with a raking gradient, lit from the upper left the way the shadows
   under everything else on the page are.

   The bezel is a rem, not a percentage. Percentage padding resolves against
   the width on all four sides, which would be right — but the hairline below
   is an absolutely positioned box inset by the same amount, and there
   percentages resolve per axis, so a phone 2.2x taller than it is wide would
   get a top inset 2.2x its side inset. One length unit for both keeps the
   ring even and keeps the two rules honest about being the same number.

   The outer radius is then the screen's radius plus the bezel, restated as a
   fraction of the outer box: 14.4% across, and 6.9% down once the extra
   height of the ring is in the denominator. */
.shot--app .shot__frame {
  --bezel: 0.5rem;
  padding: var(--bezel);
  border-radius: 14.4% / 6.9%;
  background: linear-gradient(158deg, #33413a 0%, #16211a 34%, #090f0b 100%);
  box-shadow:
    0 0 0 1px rgba(255, 255, 255, 0.14),
    0 30px 46px -22px rgba(0, 0, 0, 0.8),
    0 8px 16px -10px rgba(0, 0, 0, 0.6);
}

.shot--app .shot__frame img {
  border-radius: 12% / 5.54%;
}

/* Where the bezel meets the glass. Without it the capture's own white edge
   runs straight into the lit top of the ring and the two read as one
   surface; a dark hairline is the shadow the bezel would cast on a screen
   set slightly into it. Drawn over the image rather than under it, which is
   what a pseudo-element after the content gives for free. */
.shot--app .shot__frame::after {
  content: '';
  position: absolute;
  inset: var(--bezel);
  border-radius: 12% / 5.54%;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.45);
  pointer-events: none;
}

/* ---- Details -----------------------------------------------------------
   A crop is a smaller thing than a screen and gets a lighter mount: the same
   edge treatment at panel scale rather than window scale.

   No window bar on these, and that is a point about honesty rather than
   restraint. Each one is a crop of a panel inside the console shown two
   sections up; three bars in a row would claim three separate applications.
   What they get instead is the same idea as the handset's bezel at a third
   of the weight — a thin mat of the frame's own background around the crop —
   so the family reads as one treatment held at three scales: window, panel,
   device. */
.shot--detail .shot__frame {
  padding: 0.5rem;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--rule);
  border-radius: calc(var(--radius) + 4px);
  box-shadow: 0 18px 34px -22px rgba(0, 0, 0, 0.7);
}

.shot--detail .shot__frame img {
  border-radius: calc(var(--radius) - 5px);
}

/* ---- Caption -----------------------------------------------------------
   Captions carry the meaning, so they are not optional decoration. A reader
   who has scrolled past four screens of argument is not going to work out
   from a table of names what they were meant to notice; the lead sentence
   tells them, and the shot proves it.

   The gap is generous on purpose. A caption a single line-height below a
   frame reads as a label printed on the object, and these are notes stood
   off it — the shot has to finish before the reading of it starts. It also
   has to clear the shadow, which now falls further than it used to under a
   mount that has real depth. */
.shot__caption {
  max-width: 34rem;
  margin-top: clamp(1.5rem, 0.9rem + 1.1vw, 2.25rem);
  font-size: var(--step-small);
  line-height: 1.55;
  color: var(--text-tertiary);
  text-align: center;
  text-wrap: pretty;
}

.shot__caption strong {
  display: block;
  color: var(--text-primary);
  font-weight: 600;
}

/* Left-aligned wherever a shot is one item in a row rather than the subject of
   a section: a centred caption under a column reads as a title for the column,
   and these are notes on a picture. */
.shot--detail .shot__caption,
.shot--app .shot__caption {
  text-align: left;
  justify-self: stretch;
}

/* On paper the shadow inverts with everything else: the same lift, cast in the
   ink the band is printed in rather than in the black the field is cut from.

   The dots and the seam need no rule here: both are drawn in --rule-strong
   and --rule, which the paper band has already flipped to dark inks. Only
   the two surfaces do, because a white wash on white is nothing. The bezel
   is the exception and stays dark on every band — a handset is a dark object
   whichever page it is standing on, and lightening it would make it a
   drawing of a phone rather than a phone. */
.band--paper .shot--console .shot__frame,
.band--paper .shot--detail .shot__frame {
  border-color: rgba(7, 18, 9, 0.12);
  box-shadow: 0 28px 56px -30px rgba(2, 20, 9, 0.45);
}

.band--paper .shot--console .shot__frame {
  background: linear-gradient(to bottom, rgba(7, 18, 9, 0.07), rgba(7, 18, 9, 0.03));
}

.band--paper .shot--detail .shot__frame {
  background: rgba(7, 18, 9, 0.04);
}

.band--paper .shot--app .shot__frame {
  box-shadow: 0 0 0 1px rgba(7, 18, 9, 0.1), 0 28px 46px -24px rgba(2, 20, 9, 0.5);
}

/* ---- Motes -------------------------------------------------------------
   Air around a shot that stands on its own.

   Only ever on a standalone one. A shot in a row of three has its neighbours
   for company and the eye reads the row; a single console across the full
   measure has nothing beside it, and on a band — which is opaque, and kills
   the page's own field inside its box — it sits in dead air. These put a
   little of that field back, locally, for the one figure that has to carry a
   section by itself.

   The layer is deliberately larger than the frame it sits behind, and the
   capture is opaque, so nothing drifts anywhere the reader can see it except
   the margin around the shot. That is the whole effect: a halo that moves,
   never a pattern crossing the product. The insets are lengths rather than
   percentages so the band stays the same width whatever the shot's ratio,
   and the bottom is the shallowest of the three because that is the side the
   caption is on.

   Nothing here is in the accessibility tree — the markup is aria-hidden and
   these are empty elements — and nothing here is load-bearing, so a browser
   that drops the animation loses decoration and no meaning. */
.shot__motes {
  /* How far past the shot the field reaches on each side, and the unit every
     mote's horizontal position is quoted in. It has to track the space that
     is actually there: a lead shot runs the full measure, so the only room
     beside it is the gutter plus whatever the centred container leaves over,
     and past about 1100px of viewport that is what this curve follows. Where
     it overshoots, the band clips it (see .band) rather than the page
     growing a horizontal scrollbar. */
  --halo: clamp(2.5rem, 3.2vw, 5rem);

  position: absolute;
  inset: -3.5rem calc(var(--halo) * -1) -2rem;
  pointer-events: none;
}

/* The bloom under the whole thing. A shot lit from nowhere looks pasted on;
   this is the light the object would be standing in, and it is only ever
   visible where the capture does not cover it. */
.shot__motes::before {
  content: '';
  position: absolute;
  inset: 8%;
  background: radial-gradient(58% 54% at 50% 46%, rgba(103, 188, 124, 0.18), rgba(103, 188, 124, 0) 72%);
  filter: blur(22px);
}

/* Each mote fades in, crosses a few dozen pixels, and fades out. The travel
   is small on purpose: at this size and opacity, anything fast enough to
   track with the eye stops being atmosphere and becomes a thing moving on
   the page while somebody is trying to read the caption under it. */
.shot__motes span {
  position: absolute;
  width: var(--s, 3px);
  height: var(--s, 3px);
  border-radius: 50%;
  background: var(--sage-bright);
  box-shadow: 0 0 0.5rem rgba(143, 217, 163, 0.5);
  opacity: 0;
  animation: shot-mote var(--dur, 20s) ease-in-out var(--delay, 0s) infinite;
}

@keyframes shot-mote {
  0% {
    opacity: 0;
    transform: translate3d(0, 0, 0) scale(0.55);
  }

  22% {
    opacity: var(--peak, 0.5);
  }

  70% {
    opacity: var(--peak, 0.5);
  }

  100% {
    opacity: 0;
    transform: translate3d(var(--dx, 0.5rem), var(--dy, -2.5rem), 0) scale(1);
  }
}

/* Five down each side, two across the top, and nothing below: the bottom of
   this layer is where the caption starts, and a light crossing a line of
   type is a fault rather than an atmosphere.

   Every one is anchored to the edge it belongs to. Across, the offset is a
   fraction of --halo, so the whole scattering narrows with the space it has
   instead of walking onto the capture when the viewport tightens. Along the
   edge it is a percentage, so it stretches with the shot. The delays are
   spread across the longest cycle here on purpose — twelve motes on twelve
   unrelated clocks never resolve into a pattern, which is the difference
   between a field and a loop.

   Each drifts up and slightly sideways. Anything that wanders over the
   capture simply goes behind it: the frame paints after this layer, so a
   mote leaving the margin reads as one passing behind the device. */
.shot__motes span:nth-child(1) { left: calc(var(--halo) * 0.30); top: 14%; --s: 3px; --dur: 19s; --delay: 0s; --dx: 0.6rem; --dy: -2.2rem; --peak: 0.55; }
.shot__motes span:nth-child(2) { left: calc(var(--halo) * 0.72); top: 33%; --s: 2px; --dur: 24s; --delay: 3.5s; --dx: -0.9rem; --dy: -3rem; --peak: 0.4; }
.shot__motes span:nth-child(3) { left: calc(var(--halo) * 0.10); top: 52%; --s: 4px; --dur: 27s; --delay: 8s; --dx: 1.1rem; --dy: -2.6rem; --peak: 0.35; }
.shot__motes span:nth-child(4) { left: calc(var(--halo) * 0.55); top: 71%; --s: 2px; --dur: 21s; --delay: 12s; --dx: -0.5rem; --dy: -2rem; --peak: 0.5; }
.shot__motes span:nth-child(5) { left: calc(var(--halo) * 0.88); top: 88%; --s: 3px; --dur: 25s; --delay: 5s; --dx: 0.8rem; --dy: -3.4rem; --peak: 0.3; }

.shot__motes span:nth-child(6) { right: calc(var(--halo) * 0.22); top: 11%; --s: 2px; --dur: 23s; --delay: 6.5s; --dx: -0.7rem; --dy: -2.8rem; --peak: 0.45; }
.shot__motes span:nth-child(7) { right: calc(var(--halo) * 0.80); top: 29%; --s: 4px; --dur: 28s; --delay: 1.5s; --dx: 0.9rem; --dy: -2.4rem; --peak: 0.32; }
.shot__motes span:nth-child(8) { right: calc(var(--halo) * 0.08); top: 47%; --s: 3px; --dur: 20s; --delay: 10s; --dx: -1.2rem; --dy: -3.2rem; --peak: 0.5; }
.shot__motes span:nth-child(9) { right: calc(var(--halo) * 0.62); top: 66%; --s: 2px; --dur: 26s; --delay: 4s; --dx: 0.5rem; --dy: -2.1rem; --peak: 0.38; }
.shot__motes span:nth-child(10) { right: calc(var(--halo) * 0.42); top: 84%; --s: 3px; --dur: 22s; --delay: 14s; --dx: -0.6rem; --dy: -2.9rem; --peak: 0.42; }

.shot__motes span:nth-child(11) { left: 27%; top: 0.7rem; --s: 2px; --dur: 29s; --delay: 2.5s; --dx: 1.4rem; --dy: -1.4rem; --peak: 0.34; }
.shot__motes span:nth-child(12) { left: 68%; top: 1.9rem; --s: 3px; --dur: 24s; --delay: 9s; --dx: -1.6rem; --dy: -1.6rem; --peak: 0.3; }

/* Below the measure the shot runs to the gutter and there is no margin left
   for any of this to happen in. The motes would either sit on the capture or
   fall off the viewport, so they go entirely rather than being squeezed. */
@media (max-width: 60rem) {
  .shot__motes {
    display: none;
  }
}

/* -------------------------------------------------------------------------
   21. Debug readout
   Behind ?debug only. Shows the two values the whole page is driven from.
   ------------------------------------------------------------------------- */

.field-debug {
  position: fixed;
  z-index: 50;
  bottom: 1rem;
  left: 1rem;
  display: grid;
  gap: 0.25rem;
  padding: 0.625rem 0.875rem;
  background: rgba(7, 18, 9, 0.9);
  border: 1px solid var(--rule);
  border-radius: 10px;
  font-size: 0.6875rem;
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}

@media print {
  .field-debug {
    display: none;
  }
}

/* -------------------------------------------------------------------------
   22. Reduced motion
   Zero motion anywhere. The act loses its scroll track so there is no dead
   space to scroll through, and every revealed element is simply present.
   ------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
  }

  .reveal-ready [data-reveal],
  .reveal-ready [data-reveal='mask'] {
    opacity: 1;
    transform: none;
  }

  .router__card:hover,
  .btn:hover {
    transform: none;
  }

  /* The blanket rule above ends every animation on its last keyframe, and
     the motes' last keyframe is opacity 0 — so without this the field simply
     vanishes. Naming no animation at all leaves them where they were placed
     and holds them there: the same scattering of light, standing still. */
  .shot__motes span {
    animation: none;
    opacity: 0.34;
    transform: none;
  }

  .act {
    height: auto;
  }

  .act__stage {
    position: static;
    height: auto;
    padding-block: var(--section-y);
  }

  /* Stacked beats need to become a legible column once nothing is driving
     the cross-fade. */
  .act__beats {
    display: grid;
    gap: 3rem;
    max-width: var(--max-prose);
  }

  .act__beat {
    grid-area: auto;
    opacity: 1;
    transform: none;
    pointer-events: auto;
  }

  .field__glow {
    transform: none;
  }
}

/* -------------------------------------------------------------------------
   23. Print
   Buyers will PDF this page into decks: drop the field, the nav, and the
   footer link columns, and print black on white.
   ------------------------------------------------------------------------- */

@media print {
  :root {
    --section-y: 1.5rem;
    --text-primary: #000;
    --text-secondary: #333;
    --text-tertiary: #555;
    --rule: #ccc;
    --rule-strong: #999;
    --surface: transparent;
    --sage: #1f6b35;
    --sage-bright: #1f6b35;
    --accent-glow: transparent;
  }

  html,
  body {
    background: #fff;
  }

  /* Every band prints as paper, so the tone sequence collapses to nothing
     rather than to nine pages of ink. The paper band's own token overrides
     are already the printed values, so it needs no separate handling. */
  .band {
    background: none !important;
    border-top: 0;
    box-shadow: none;
  }

  .band::before,
  .band::after {
    display: none;
  }

  body {
    font-size: 11pt;
    color: #000;
  }

  .field,
  .nav,
  .skip-link,
  .footer__grid,
  .btn-row {
    display: none !important;
  }

  .hero {
    min-height: 0;
    padding-block: 0 1.5rem;
    page-break-after: avoid;
  }

  .hero__title {
    font-size: 26pt;
  }

  /* Shots print, because on these pages they are the evidence and a deck
     without them is a deck of claims. What goes is the mounting: a shadow
     tuned to sit a white window on a near-black field smears across paper,
     and the hairline that gave it an edge there is unnecessary once the page
     around it is white too. */
  .shot__frame {
    box-shadow: none !important;
    border-color: #ccc !important;
    page-break-inside: avoid;
  }

  /* Atmosphere does not print. It is twelve grey dots on white and a blur
     filter no printer resolves, and the shot is on paper to be evidence. */
  .shot__motes {
    display: none;
  }

  .act {
    height: auto;
  }

  .act__stage {
    position: static;
    height: auto;
    padding: 0;
  }

  .act__beats {
    display: grid;
    gap: 1.5rem;
    text-align: left;
  }

  .act__beat {
    grid-area: auto;
    opacity: 1;
    transform: none;
    pointer-events: auto;
  }

  .act__beat p:not(.eyebrow) {
    font-size: 14pt;
  }

  .trust__panel,
  .router__card {
    background: transparent !important;
    border-color: #ccc !important;
  }

  .footer {
    background: transparent;
  }

  .section,
  .router__card {
    page-break-inside: avoid;
  }

  a {
    color: #000;
    text-decoration: underline;
  }
}
