/* ============================================================================
   AXD Intelligence — site layer (axdi-site)
   Built on css/tokens.css (BASE). Components consume semantic tokens only.
   Stage-tier motion (marketing surfaces): reveal + wipe, ease-out, no bounce,
   no parallax displacement; reduced motion collapses to opacity.
   ========================================================================== */

:root {
  --dur-stage: 600ms;
  --dur-wipe: 900ms;
  --rise: 16px;

  /* Ambient tier (rulebook §8, v1.2.2). Symmetric easing is deliberate and is
     the one place it is correct: a loop eased out only lands as a sawtooth. */
  --ease-ambient: cubic-bezier(.4, 0, .6, 1);
  --dur-breathe: 12s;
  --dur-field: 16s;
}

/* -- reset ----------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
  background: var(--color-canvas);
  color: var(--color-ink);
  font-family: var(--font-sans);
  font-size: var(--text-body);
  font-weight: var(--weight-body);
  line-height: var(--leading-body);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
ul, ol { list-style: none; }
.mono { font-family: var(--font-mono); font-size: var(--text-mono); }

:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; border-radius: 4px; }

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--color-primary); color: var(--color-onprimary);
  padding: 8px 16px; border-radius: var(--radius-control);
  font-size: var(--text-button); font-weight: var(--weight-button);
}
.skip:focus-visible { left: var(--pad-page); top: 8px; }

/* -- shared type roles ----------------------------------------------------- */
.eyebrow {
  font-size: var(--text-eyebrow);
  font-weight: var(--weight-eyebrow);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
  color: var(--color-faint);
}
.display {
  font-size: var(--text-display);
  font-weight: var(--weight-display);
  letter-spacing: var(--tracking-display);
  line-height: 1.25;
}
.lead { color: var(--color-muted); max-width: var(--measure); }
.meta { font-size: var(--text-meta); color: var(--color-faint); }

/* -- layout ---------------------------------------------------------------- */
.wrap { max-width: var(--content-max); margin: 0 auto; padding: 0 var(--pad-page); }
.sec { padding: var(--gap-section) 0; scroll-margin-top: var(--bar-h); }
.hero { scroll-margin-top: 0; }
.sec-head { margin-bottom: var(--gap-group); }
.sec-head .eyebrow { margin-bottom: var(--stack-heading); }
.sec-head .display { margin-bottom: var(--stack-heading); }
.sec-head .lead + .lead { margin-top: var(--gap-inline); }

/* -- buttons ---------------------------------------------------------------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--gap-icon);
  height: var(--control-h);
  padding: 0 var(--inset-button-x);
  border-radius: var(--radius-control);
  font-size: var(--text-button);
  font-weight: var(--weight-button);
  letter-spacing: var(--tracking-button);
  transition: background var(--dur-state) var(--ease-state),
              color var(--dur-state) var(--ease-state),
              border-color var(--dur-state) var(--ease-state),
              opacity var(--dur-state) var(--ease-state),
              transform var(--dur-state) var(--ease-state);
  white-space: nowrap;
}
.btn:active { transform: scale(.98); }
.btn-primary { background: var(--color-primary); color: var(--color-onprimary); }
.btn-primary:hover { opacity: .92; }
.btn-secondary { border: 1px solid var(--color-linestrong); color: var(--color-ink); background: transparent; }
.btn-secondary:hover { background: var(--color-hover); }
.btn-ghost { color: var(--color-muted); background: transparent; }
.btn-ghost:hover { background: var(--color-hover); color: var(--color-ink); }
.btn-compact { height: var(--control-h-compact); padding: 0 var(--inset-button-x-compact); }
.btn-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--control-h); height: var(--control-h);
  border-radius: var(--radius-control);
  color: var(--color-muted);
  transition: background var(--dur-state) var(--ease-state), color var(--dur-state) var(--ease-state);
}
.btn-icon:hover { background: var(--color-hover); color: var(--color-ink); }
.ic { width: 18px; height: 18px; }
.ic-sm { width: 14px; height: 14px; }

/* -- cards, badges ---------------------------------------------------------- */
.card {
  background: var(--color-surface);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-card);
  padding: var(--inset-card);
}
.badge {
  display: inline-flex; align-items: center; gap: 6px;
  padding: var(--inset-badge-y) var(--inset-badge-x);
  border-radius: var(--radius-chip);
  font-size: var(--text-eyebrow);
  font-weight: var(--weight-eyebrow);
  letter-spacing: .08em;
  text-transform: uppercase;
}
.badge .dot { width: 6px; height: 6px; border-radius: var(--radius-full); corner-shape: round; background: currentColor; }
.badge-success { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 14%, transparent); border: 1px solid color-mix(in srgb, var(--color-success) 35%, transparent); }
.badge-warning { color: var(--color-warning); background: color-mix(in srgb, var(--color-warning) 14%, transparent); border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent); }

/* -- theme-conditional imagery ---------------------------------------------- */
.only-light { display: none; }
:root[data-theme="light"] .only-dark { display: none; }
:root[data-theme="light"] .only-light { display: block; }

/* ============================ TOPBAR ============================ */
/* Fixed, not sticky: the bar is hidden over the hero (where the same lockup is
   already the headline) and slides in once the hero is behind you. Fixed means
   it overlays rather than reserving 56px at the top, so the hero can be a full
   100svh stage. Without JS it simply stays visible — see .js rules below. */
.topbar {
  position: fixed; top: 0; left: 0; right: 0; z-index: 40;
  background: var(--color-canvas);
  border-bottom: 1px solid var(--color-line);
}
.js .topbar {
  transform: translateY(-100%); opacity: 0;
  transition: transform var(--dur-stage) var(--ease-spatial),
              opacity var(--dur-stage) var(--ease-spatial);
}
.js .topbar.shown { transform: none; opacity: 1; }
/* The lockup is centred absolutely, not by a 1fr auto 1fr grid. The grid put it
   113px off centre at 768px, because the nav column is far wider than the two
   icon buttons opposite it, so the centre track was never the middle of the
   bar. Absolute centring is true at every width; the nav then hides below
   1024px (see RESPONSIVE) so it can never reach the mark. */
.topbar-inner {
  position: relative;
  max-width: var(--content-max); margin: 0 auto;
  padding: 0 var(--pad-page);
  height: var(--bar-h);
  display: flex; align-items: center; gap: var(--gap-inline);
}
.brand {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  display: inline-flex; align-items: center;
}
.brand-lockup { width: 104px; height: auto; }

/* ── Pinned hero scene (§8 "scroll as a timeline", added 2026-08-16) ────────
   .hero becomes a ~300svh scroll track; .hero-stage is the sticky 100svh
   stage. They MUST be different elements: .glyph-rain is a canvas sized in JS
   from its host's clientHeight, so a canvas filling a 300svh hero would be
   2880x4860 device px — about 56MB, past the ceiling WebKit fails at silently
   (blank frames, no error). Keeping the stage one viewport tall keeps it ~14MB.

   Everything hangs off a named view timeline on the track, ranged to `cover` —
   exactly the window in which the 300svh section covers the 100svh scrollport,
   i.e. the pinning window, wherever the hero sits in the document. Scroll is
   only ever READ; it is never intercepted, retimed or smoothed.

   Gated three ways, and each gate is load-bearing:
     @supports  — Firefox still ships animation-timeline behind a flag, and a
                  300svh hero with no timeline is three blank screens, which is
                  far worse than today's hero. timeline-scope is in the test too,
                  so a partial implementation falls back wholesale.
     reduced-motion — the scene never engages, so there is no half state.
     .js        — without JS the bar is always visible and the hand-off cannot
                  complete, so the track simply never engages. */

@keyframes hero-lockup-travel {
  from { transform: translateY(0) scale(1); opacity: 1; }
  /* Held fully opaque until the very last instant. A cross-fade here put TWO
     lockups on screen at once while the travelling one was still short of its
     target, which read as a duplicate rather than a hand-off. They land on the
     same point at the same width, so a hard swap is invisible. */
  99.9% { opacity: 1; }
  /* Both values are MEASURED by motion.js into --hero-travel / --hero-scale and
     re-measured on resize. They were hard-coded at -244px / 0.236, correct only
     at the 1440x900 they were measured at; at any other window size the mark
     landed near the bar and the hard cut snapped it the rest of the way, which
     is what read as the lockup "jumping". The fallbacks keep the 900px-tall
     desktop case correct if JS has not measured yet. */
  to   { transform: translateY(var(--hero-travel, -244px)) scale(var(--hero-scale, 0.236)); opacity: 0; }
}
@keyframes hero-copy-out {
  from { opacity: 1; transform: translateY(0); }
  to   { opacity: 0; transform: translateY(-46px); }
}
@keyframes hero-rain-thin {
  from { opacity: 1; }
  to   { opacity: .16; }
}
/* Transform as well as opacity, deliberately. motion.js reveals the bar at
   scrollY > heroH - 80, which on a 300svh track is ~2620px — but the pin
   completes at ~1800px. Left to the JS the bar would be opaque yet still
   translated off-screen for 800px of scrolling, and the mark would land in a
   bar that is not there. Driving transform here closes that gap; the JS
   threshold still governs everywhere the scene is not engaged, and it only
   ever agrees with the held end state.
   The bar SLIDES rather than fades, and arrives EMPTY and early, so the
   travelling mark flies into a slot that is already waiting for it. */
@keyframes hero-topbar-in {
  from { opacity: 0; transform: translateY(-100%); }
  to   { opacity: 1; transform: none; }
}
@keyframes hero-brand-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@supports (animation-timeline: view()) and (timeline-scope: --hero-track) {
  @media (prefers-reduced-motion: no-preference) {

    /* lets .topbar — a preceding sibling of .hero — reference the track's
       timeline; body is the common ancestor in both copies of the markup */
    body { timeline-scope: --hero-track; }

    .js .hero {
      display: block;      /* grid would shrink-wrap the sticky stage */
      /* 200svh, not 300 (2026-08-16). At 300svh the mark travelled its 244px
         across 1800px of scrolling — 13% coupling — so it barely crept and then
         the pin released, which read as "static, then a jump to the bar" rather
         than as motion. At 200svh the same travel happens over 900px, roughly
         27% coupling, and the mark visibly glides into the bar as the hero ends. */
      height: 200svh;      /* the scroll track; no token exists for this */
      padding: 0;          /* the vertical padding moves down to the stage */
      overflow: visible;   /* sticky does not work under an overflow:hidden ancestor */
      view-timeline-name: --hero-track;
    }

    /* Neutralise the scene while motion.js reads the flight path, or it measures a
       mid-flight transform. The flag rides the root because .topbar is a SIBLING
       of .hero, not a descendant — scoping it to .hero could never reach the bar,
       and the bar's own transform would have corrupted the reading. */
    .measuring .lockup { animation: none !important; transform: none !important; }
    .measuring .topbar { animation: none !important; transform: none !important; }

    .js .hero-stage {
      display: grid; place-items: center;
      position: sticky; top: 0;
      /* Above the top bar (z-index 40). The bar has an opaque background, so
         without this the travelling mark slides UNDERNEATH it and is eaten
         top-first: "AXD" disappears while "INTELLIGENCE" lingers, then the
         whole mark reappears inside the bar. That occlusion — not the path,
         which measured perfectly smooth — is what read as the mark "jumping"
         to the bar. Found by eye; position measurement could never see it. */
      z-index: 41;
      /* width is explicit: the stage measured 680px wide and centred without it,
         because .hero keeps place-items:center from its base rule and the stage
         was being shrink-wrapped rather than filling the track. */
      width: 100%;
      height: 100svh;
      padding: var(--gap-section) 0;
      overflow: hidden;    /* takes over the clipping .hero used to do */
    }


    /* 440px -> 104px is scale .236; the travel is the measured distance between
       the hero mark and the bar mark. Linear, not eased: the mark's position has
       to match the scroll position it hands off at, and easing would decouple
       the two. */
    .js .hero .lockup {
      transform-origin: top center;
      animation: hero-lockup-travel linear both;
      animation-timeline: --hero-track;
      animation-range: contain 0% contain 100%;
    }

    /* `forwards`, not `both`, and starting at 3% rather than 0%. With `both` the
       animation filled BACKWARDS to scroll 0 and pinned the copy at opacity 1
       from the very first paint — animations beat declarations — which silently
       destroyed the opening beat: the page arrived with its copy already there
       instead of black, then a field of glyphs, then the mark, then the words.
       `forwards` holds only the END state, and the 3% dead zone leaves the load
       sequence in charge until the reader actually scrolls. */
    .js .hero [data-reveal] {
      animation: hero-copy-out linear forwards;
      animation-timeline: --hero-track;
      animation-range: contain 3% contain 48%;
    }

    /* The stage sits above the bar so the mark is never occluded — but that also
       put the falling glyphs over the nav, where they read as stray guide lines.
       Mask the rain out of the bar strip for the duration of the scene. The
       lockup is unaffected: it is a sibling, not inside the veil. */
    .js .hero .glyph-rain-veil {
      -webkit-mask-image: linear-gradient(to bottom,
                          transparent 0, transparent var(--bar-h),
                          #000 calc(var(--bar-h) + 64px), #000 45%,
                          rgba(0,0,0,.6) 68%, rgba(0,0,0,.25) 85%, transparent 100%);
              mask-image: linear-gradient(to bottom,
                          transparent 0, transparent var(--bar-h),
                          #000 calc(var(--bar-h) + 64px), #000 45%,
                          rgba(0,0,0,.6) 68%, rgba(0,0,0,.25) 85%, transparent 100%);
      animation: hero-rain-thin linear both;
      animation-timeline: --hero-track;
      animation-range: contain 0% contain 100%;
    }

    .js .topbar {
      animation: hero-topbar-in var(--ease-spatial) both;
      animation-timeline: --hero-track;
      /* settled well before the mark arrives: an empty bar waiting is the
         point, and it makes the swap at 100% unremarkable */
      animation-range: contain 46% contain 72%;
    }

    /* A hard swap at the landing point, not a cross-fade. The cross-fade was
       the cause of the "two logos" moment: for the last 4% of the track both
       marks were on screen while the travelling one had not arrived. Measured
       landing error is 0.2px at identical width, so switching in one step is
       invisible. steps(1, end) makes it a cut rather than a ramp. */
    .js .brand-lockup {
      animation: hero-brand-in steps(1, end) both;
      animation-timeline: --hero-track;
      animation-range: contain 99.5% contain 100%;
    }
  }
}
.nav-rail { display: flex; gap: var(--gap-inline); }
.nav-rail a {
  display: inline-flex; align-items: center;
  height: 32px; padding: 0 10px;
  border-radius: var(--radius-chip);
  font-size: var(--text-eyebrow); font-weight: var(--weight-eyebrow);
  letter-spacing: var(--tracking-eyebrow); text-transform: uppercase;
  color: var(--color-muted);
  transition: background var(--dur-state) var(--ease-state), color var(--dur-state) var(--ease-state);
}
.nav-rail a:hover { background: var(--color-hover); color: var(--color-ink); }
.nav-rail a.active { background: var(--color-surface2); color: var(--color-ink); }
.topbar-actions { margin-left: auto; display: flex; gap: var(--gap-inline); align-items: center; }
.menu-toggle { display: none; }
.ic-sun { display: none; }
:root[data-theme="light"] .ic-sun { display: block; }
:root[data-theme="light"] .ic-moon { display: none; }

/* sheet menu (mobile) — overlay tier */
.scrim { position: fixed; inset: 0; z-index: 48; background: var(--color-scrim); }
.sheet {
  position: fixed; z-index: 49;
  left: var(--pad-page); right: var(--pad-page); top: calc(var(--bar-h) + 8px);
  background: var(--color-overlay);
  border: 1px solid var(--color-linestrong);
  border-radius: var(--radius-modal);
  padding: var(--inset-modal);
  box-shadow: var(--shadow-overlay);
}
.sheet-nav { display: flex; flex-direction: column; gap: var(--gap-icon); }
.sheet-nav a {
  display: flex; align-items: center;
  height: 44px; padding: 0 12px; margin: 0 var(--hover-inset);
  border-radius: var(--radius-control);
  font-size: var(--text-button); font-weight: var(--weight-button);
  letter-spacing: var(--tracking-eyebrow); text-transform: uppercase;
  color: var(--color-ink);
}
.sheet-nav a:hover { background: var(--color-overlay-hover); }

/* ============================ HERO ============================ */
.hero {
  position: relative;
  min-height: 100svh;
  display: grid; place-items: center;
  overflow: hidden;
  padding: var(--gap-section) 0;
}

/* Pinned-scene wrapper. Inert by default: display:contents generates no box, so
   the hero lays out exactly as if the wrapper were not there. Only the gated
   scene near the end of this file turns it into the sticky stage. */
.hero-stage { display: contents; }
.glyph-field {
  /* Level C, chosen 2026-08-06: the tile reads 32/255 against the black canvas
     instead of 13, so the grid is a deliberate pattern rather than a rumour.
     Still monochrome — the field is brighter white, never tinted. */
  --field-lo: .090;
  --field-hi: .160;
  position: absolute; inset: -120px;
  /* Tile regenerated from the canonical Axi glyph bitmap by tools/make_tile.py.
     The old axdi-pattern-tile.svg was a hand-drawn approximation. */
  background-image: url("/assets/logos/axi-glyph-tile.png");
  background-size: 90px 90px;
  opacity: .125;   /* resting = midpoint of the breath */
  pointer-events: none;
}

/* ---- falling glyph rain (hero) ------------------------------------------
   A canvas of Axi glyphs falling in columns, drawn from the canonical master
   so the mark is exact — Veo was trialled for this and flattened the glyph
   into a symmetric X within four seconds, which is why it is drawn, not
   generated. Hidden until motion.js confirms it started; the static tile
   underneath is the fallback everywhere the rain does not run. */
/* Horizontal bar scrim. The rain dissolves through the lower third of the hero
   so it is gone before the suite section begins, rather than being cut off by
   the section edge. Kept on a wrapper rather than composited into the canvas's
   own mask: two single-layer masks nest predictably, where mask-composite
   keywords differ between the standard and the -webkit- prefix. */
.glyph-rain-veil {
  position: absolute; inset: 0;
  pointer-events: none;
  /* Measured curve, top -> bottom: 100 100 100 100 100 91 74 56 35 17 0.
     Near-linear across the bottom half, so the rain thins out gradually rather
     than dropping away in the last few percent. */
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 45%,
                      rgba(0,0,0,.6) 68%, rgba(0,0,0,.25) 85%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 0%, #000 45%,
                      rgba(0,0,0,.6) 68%, rgba(0,0,0,.25) 85%, transparent 100%);
}
.glyph-rain {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 900ms var(--ease-spatial);
  /* Radial scrim — an inverted vignette. The rain is masked away behind the
     lockup and copy and reaches full strength at the edges, so the headline
     always sits on near-true-black rather than on moving texture. */
  --scrim-x: 52%; --scrim-y: 44%; --scrim-feather: 38%;
  -webkit-mask-image: radial-gradient(ellipse var(--scrim-x) var(--scrim-y) at 50% 46%,
                      transparent 0%, transparent 30%,
                      #000 calc(30% + var(--scrim-feather)), #000 100%);
          mask-image: radial-gradient(ellipse var(--scrim-x) var(--scrim-y) at 50% 46%,
                      transparent 0%, transparent 30%,
                      #000 calc(30% + var(--scrim-feather)), #000 100%);
}
/* The rain leads now, and it builds SLOWLY — 1400ms rather than the default
   900, starting at 200ms. That long ramp is the whole suspense of the opening:
   for about a second there is a field of falling glyphs and no words yet, and
   the mark only begins to resolve once the field is established.
   The ramp also has to outlast the mark's arrival, or the rain would finish
   first and the two would read as separate events again.
   Measured note: when motion.js reaches .rain-on on the first frame — which it
   does on a warm machine — the tile is already at 0 before it ever paints, so
   the hero opens genuinely black. The tile's own delay earns its keep on the
   slower path, where .rain-on lands late: there the tile is up, and without it
   the tile would blink out and leave the hero bare until the rain faded in.
   Both paths are correct; only one of them is visible locally. */
.hero.rain-on .glyph-rain {
  opacity: 1;
  transition: opacity 1400ms var(--ease-spatial) 200ms;
}
/* the static tile steps aside once the rain is actually running */
.hero.rain-on .glyph-field {
  opacity: 0; animation: none;
  transition: opacity var(--dur-wipe) var(--ease-spatial) 200ms;
}
:root[data-theme="light"] .glyph-rain { display: none; }
/* Ambient: the field drifts as before and now also breathes, so the texture
   reads as alive rather than printed on. Opacity stays at or under .05 at all
   times, per the field's original ceiling. */
@keyframes drift { from { transform: translate(0, 0); } to { transform: translate(60px, 40px); } }
/* --field-lo/--field-hi let each field set its own range through one keyframe. */
@keyframes field-breathe {
  0%, 100% { opacity: var(--field-lo); }
  50%      { opacity: var(--field-hi); }
}
.js .glyph-field {
  animation: drift 90s linear infinite alternate,
             field-breathe var(--dur-field) var(--ease-ambient) infinite;
}
:root[data-theme="light"] .glyph-field { display: none; }

/* Second field in the studio section, so the page is bookended by texture
   instead of the ground dying after the hero. Levels now inherit from
   .glyph-field rather than being overridden down (raised 2026-08-16): the tile
   is one material, and at .044 resting it rendered a 10/255 peak against the
   hero's 30/255 — a third of the strength — so the page looked like it was
   fading out rather than being bookended. The gap opened when the hero went to
   level C and this field was left where it was. Measured, not eyeballed:
   headless capture of both gutters, peak now 30/255 in each.
   The override also sat outside §8: its .018–.044 breath swung 84% of the
   .031 midpoint it actually rests on, over the 60% bound for a background
   field. Inheriting puts it back on the hero's 56%.
   The two fields stay distinguishable through drift alone — slower, and the
   other way — so they never read as the same moving sheet. */
.sec-studio { position: relative; overflow: hidden; }
.sec-studio > .wrap { position: relative; z-index: 2; }
.js .sec-studio .glyph-field {
  animation: drift 120s linear infinite alternate-reverse,
             field-breathe calc(var(--dur-field) * 1.35) var(--ease-ambient) infinite;
}

.hero-inner {
  position: relative; z-index: 2;
  display: flex; flex-direction: column; align-items: center;
  text-align: center;
  padding: 0 var(--pad-page);
  max-width: 820px;
}
.lockup { width: min(440px, 74vw); margin-bottom: var(--gap-group); }
.lockup img { width: 100%; height: auto; }
/* The lockup wipes DOWNWARD, not left-to-right: the hero's glyph rain falls, and
   an entrance travelling across it read as two motions arguing. inset() is
   top/right/bottom/left, so clipping 100% from the bottom reveals top-first. */
.js .lockup { clip-path: inset(0 0 100% 0); }
.js .lockup.drawn {
  clip-path: inset(0 0 0 0);
  transition: clip-path var(--dur-wipe) var(--ease-spatial);
  /* the mark resolves out of the rain once the field is established — see below */
  transition-delay: 900ms;
}

/* -- the opening: the mark resolves out of the rain (2026-08-16) ----------
   Second attempt. The first opened with a hairline that drew across and handed
   over to the lockup wipe. Two problems, both Abdulla's, both right: a
   horizontal rule appears nowhere in the AXD mark, so it read as an imported
   device rather than as ours; and a line handing over to a wipe is two
   mechanics in sequence, which is why it never felt like one motion.

   This uses only what the brand already owns — the falling glyphs — and one
   mechanic throughout: things fade up, in order, on one accelerating curve.

     0 -> 200ms      black. nothing at all
     200 -> 1600     the rain builds from nothing, slowly. this is the suspense:
                     a field of glyphs and no words yet
     900 -> 1800     the mark resolves DOWN out of that field. Its radial scrim
                     is already carving rain away from behind the lockup, so the
                     mark reads as pushing the field aside to make room for
                     itself rather than being pasted on top of it
     1650 ->         the copy rises, on its existing 60ms stagger

   Nothing starts before the thing above it has begun to settle, and every
   overlap is deliberate: the mark starts while the rain is still building, the
   copy starts while the mark is still resolving. That overlap is what makes it
   read as one flow instead of four cues.

   Still entirely CSS on elements that already exist — no markup, no new JS —
   so it applies byte-identically to the Next.js copy in public/ while site/
   and public/ are both live. */
/* the copy rises last, once the mark has begun to settle */
.js .hero [data-reveal].in { transition-delay: calc(1650ms + var(--reveal-i, 0) * 60ms); }
.hero-eyebrow { margin-bottom: var(--stack-heading); }
.hero-title {
  font-size: clamp(28px, 5vw, 44px);
  font-weight: var(--weight-display);
  letter-spacing: var(--tracking-display);
  line-height: 1.15;
  margin-bottom: var(--stack-heading);
}
.hero-lead { margin: 0 auto var(--gap-group); }
.hero-cta { display: flex; gap: var(--gap-inline); flex-wrap: wrap; justify-content: center; }

/* ============================ SUITE ============================ */
/* Three-plus-one. The three built instruments share a row as peers; the one
   still in design is a strip beneath them, so the asymmetry is stated rather
   than hidden by four equal columns pretending they are the same kind of thing. */
/* align-items: start so a card that opens its diagram grows alone. As grid
   items the cards used to share a row height, so hovering one lifted all four
   together; the shared min-height keeps them equal at rest. */
.suite-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap-item); align-items: start; }

/* Room after the suite, so it does not run straight into the next section. */
#suite { padding-bottom: calc(var(--gap-section) * 1.6); }
.app-card {
  display: flex; flex-direction: column;
  min-height: 300px;
  text-align: center;
  transition: background var(--dur-state) var(--ease-state), border-color var(--dur-state) var(--ease-state);
}
/* any real instrument card (anchor or div) takes the hover surface, so Axi
   reads the same as the two live ones; the dashed "in design" placeholder is
   excluded and never lifts */
/* hover still lifts the surface, so the card reads as interactive; it just no
   longer opens the diagram — that is what the click is for */
.app-card:not(.app-card-next):hover { background: var(--color-surface2); border-color: var(--color-linestrong); }
.app-mark { height: 96px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--gap-group); }
.app-mark img { max-height: 76px; width: auto; max-width: 82%; }
.app-mark-next {
  width: 48px; height: 48px;
  align-self: center;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--color-line);
  border-radius: var(--radius-control);
  color: var(--color-faint);
}
.app-card .eyebrow { margin-bottom: var(--gap-icon); }
.app-desc { color: var(--color-muted); margin-bottom: var(--gap-item); }
.app-foot { margin-top: auto; display: flex; align-items: center; justify-content: center; gap: var(--gap-inline); }
/* The Open App button is the ONLY route to the product (Abdulla, 2026-08-16):
   clicking the card body toggles the diagram, it never navigates. So the button
   has to be visible whenever the card is open, not only on hover. */
a.app-card .btn-compact { opacity: 0; transform: translateX(-4px); transition: opacity var(--dur-state) var(--ease-state), transform var(--dur-state) var(--ease-state); }
a.app-card:hover .btn-compact,
a.app-card:focus-visible .btn-compact,
a.app-card.tap-open .btn-compact { opacity: 1; transform: none; }
/* A card again, not a full-width strip: the placeholder sits as the fourth
   peer in the grid (reverted 2026-08-16). Only the dashed, transparent
   placeholder treatment is its own. */
.app-card-next {
  border-style: dashed;
  background: transparent;
}
.app-card-next .app-desc { color: var(--color-faint); }

/* -- instrument diagrams --------------------------------------------------
   A monoline schematic of what each product actually does, in the same drawing
   idiom as the site's other inline icons. Collapsed to zero height at rest so
   it costs no layout, and drawn in on hover/focus. Decorative and aria-hidden:
   the description above it already carries the meaning. */
.app-diagram-reveal {
  /* 0fr -> 1fr animates to the svg's true content height, so the reveal reads
     identically at every breakpoint (~88-161px natural). The old fixed
     max-height: 56px was BELOW the natural height, so it letterboxed the
     drawing — the svg scaled down and self-centred inside an oversized box,
     which is why the diagram looked small. overflow hidden clips it while
     collapsed; zero margin keeps the rest state at exactly zero layout cost. */
  display: grid; grid-template-rows: 0fr;
  overflow: hidden;
  margin-bottom: 0;
  transition: grid-template-rows var(--dur-stage) var(--ease-spatial),
              margin-bottom var(--dur-stage) var(--ease-spatial);
}
.app-diagram {
  /* full content width at true aspect (viewBox 120 44); the card's text is
     centred now, so a full-bleed diagram no longer reads as justified */
  width: 100%; height: auto;
  min-height: 0; /* grid item must not prop the collapsed 0fr row open */
  color: var(--color-faint);
  opacity: 0;
  transform: translateY(-4px);
  /* CLOSING is fast — 140ms, not the 600ms the opening uses. The svg keeps its
     full height while the wrapper collapses around it, so a slow fade leaves
     the TOP SLICE of the drawing visible for half a second, sitting just above
     the badge row. On the SOP card that slice is two box outlines and reads as
     orphaned fragments; on Quote it is thin line-items and barely registers,
     which is why only one card looked broken. Fading out before the collapse
     can expose anything removes it for every card. */
  transition: opacity 140ms var(--ease-state),
              transform 140ms var(--ease-state);
}
/* .app-card, not a.app-card: Axi's card is a div (the product is not deployed,
   so there is no dead link), and its diagram could never appear while these
   rules were anchor-scoped. :focus-visible stays on anchors — only they focus. */
/* CLICK opens the diagram, on every device — not hover (Abdulla, 2026-08-16).
   Hover-to-open meant the diagram appeared under a pointer that was only
   passing through, and it could never work on touch at all. A click is a
   deliberate act, so the reveal is something the reader asked for. The card
   then acts as a button: the second click follows the link. :focus-visible
   keeps the keyboard equivalent. */
a.app-card:focus-visible .app-diagram-reveal,
.app-card.tap-open .app-diagram-reveal {
  grid-template-rows: 1fr;
  margin-bottom: var(--gap-item);
}
a.app-card:focus-visible .app-diagram,
.app-card.tap-open .app-diagram {
  opacity: 1; transform: none;
  /* opening keeps the gentler stage curve; only the close is quick */
  transition: opacity var(--dur-stage) var(--ease-spatial),
              transform var(--dur-stage) var(--ease-spatial);
}

/* Circuit draw. Every stroke starts fully dashed-out and is drawn in, in
   document order, so the diagram assembles itself left to right like a trace
   being laid. motion.js measures each path once and sets --len/--i; the
   animation itself is CSS, so it stays on the compositor. */
.app-diagram [data-draw] {
  stroke-dasharray: var(--len);
  stroke-dashoffset: var(--len);
}
/* A DIFFERENT keyframe name from the showcase pass, deliberately. Both used to
   be `circuit-draw`: adding .tap-open only swapped which declaration won, and
   because the animation-name was unchanged Chrome never restarted it — the card
   opened with its diagram already drawn instead of drawing on click. A distinct
   name forces a fresh animation every time the class lands. */
a.app-card:focus-visible .app-diagram [data-draw],
.app-card.tap-open .app-diagram [data-draw] {
  /* linear, and timed per stroke by motion.js, so the front crosses at a
     constant speed instead of each stroke easing on its own clock */
  animation: circuit-draw-open var(--dur, 300ms) linear both;
  animation-delay: var(--delay, 0ms);
}
@keyframes circuit-draw { to { stroke-dashoffset: 0; } }
@keyframes circuit-draw-open {
  from { stroke-dashoffset: var(--len); }
  to   { stroke-dashoffset: 0; }
}

/* -- the connecting rule --------------------------------------------------
   Draws across the row as the section arrives, threading the four instruments.
   Scroll-linked line growth, already permitted by §8 and already used in the
   approach section. --suite-p is set by motion.js. */
.suite-grid { position: relative; }
.js .suite-grid::before {
  content: "";
  position: absolute; left: 0; top: -18px;
  height: 1px; width: calc(var(--suite-p, 0) * 100%);
  background: var(--color-linestrong);
}

/* -- product marks draw themselves on arrival -----------------------------
   §8 sanctions stroke-draw of brand monolines. The marks are raster, so this is
   a clip wipe rather than a path draw — same read, and it honours the omission
   gaps because the artwork already contains them.
   Downward, matching the hero lockup and the glyph rain. */
@keyframes mark-draw-in {
  from { clip-path: inset(0 0 100% 0); }
  to   { clip-path: inset(0 0 0 0); }
}
.js .suite-grid .in .app-mark img { animation: mark-draw-in 760ms var(--ease-spatial) both; }
.js .suite-grid > :nth-child(1) .in .app-mark img,
.js .suite-grid > :nth-child(1).in .app-mark img { animation-delay: 120ms; }
.js .suite-grid > :nth-child(2).in .app-mark img { animation-delay: 240ms; }
.js .suite-grid > :nth-child(3).in .app-mark img { animation-delay: 360ms; }
.js .suite-grid > :nth-child(4).in .app-mark img { animation-delay: 480ms; }

/* ============================ ORIGIN ============================ */
.origin-grid {
  display: grid; grid-template-columns: 1fr 420px;
  gap: var(--gap-section); align-items: center;
}
.origin-copy .eyebrow { margin-bottom: var(--stack-heading); }
.origin-copy .display { margin-bottom: var(--stack-heading); }
.origin-copy .lead + .lead { margin-top: var(--gap-inline); }
.aperture {
  position: relative; color: var(--color-ink);
  /* The 420x420 .ap-svg used to establish this box; with the rings gone the
     ratio is declared instead of inherited. NOT left to the video's intrinsic
     size: preload="none" means no metadata until playback starts, so the figure
     would collapse and reflow on first play.
     width:100% is required, not tidiness. Every remaining child is absolutely
     positioned, so max-content width is 0 — and below 768 the `margin: 0 auto`
     turns off grid stretch and falls back to shrink-to-fit, which collapsed the
     whole figure to 0x0 on phones while desktop looked fine. */
  width: 100%;
  aspect-ratio: 1;
}
img.ap-glyph {
  position: absolute;
  left: 50%; top: 50%;
  /* 9.2% of the 420 aperture -> ~36.6px of ink inside the 80px inner ring, so
     clearspace is ~0.59x the glyph width. At the old 11.4% it was 0.38x, under
     the brand book's 0.5x floor, and the mark crowded the ring it sits in. */
  width: 9.2%;
  transform: translate(-50%, -50%);
}
:root[data-theme="light"] img.ap-glyph { filter: invert(1); }
.origin-lockup { margin-bottom: var(--gap-group); }
.origin-lockup img { width: 132px; height: auto; }

.js img.ap-glyph { opacity: 0; }
.js .in img.ap-glyph { opacity: 1; transition: opacity var(--dur-stage) var(--ease-spatial) 420ms; }

/* ============================ APPROACH ============================ */
.journey { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap-item); }
.step {
  padding: var(--inset-card);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-card);
  background: var(--color-surface);
  display: flex; flex-direction: column; gap: var(--gap-icon);
}
.step-n { color: var(--color-faint); }
.step-title { font-size: var(--text-section); font-weight: var(--weight-section); }
.step-desc { color: var(--color-muted); font-size: var(--text-meta); }

/* ============================ STANDARD ============================ */
.creed { display: flex; flex-direction: column; }
.creed li {
  display: grid; grid-template-columns: 1fr 1fr;
  gap: var(--gap-group); align-items: baseline;
  padding: var(--gap-group) 0;
  border-top: 1px solid var(--color-line);
}
.creed li:last-child { border-bottom: 1px solid var(--color-line); }
.creed-line {
  font-size: clamp(20px, 2.4vw, 28px);
  font-weight: var(--weight-display);
  letter-spacing: var(--tracking-display);
  line-height: 1.3;
}
.creed-note { color: var(--color-muted); }

/* ============================ STUDIO ============================ */
/* The suite and the studio are different subjects — the instruments, then the
   people who make them — and at one section gap they ran together as if the
   team were a fifth product. Double gap above studio only; the rest of the
   page's rhythm is unchanged. */
.sec-studio { padding-top: calc(var(--gap-section) * 2); }
.team-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gap-item); }
/* Centred, per Abdulla: the mark, the role, the name and the bio all centre on
   the card rather than ranging left. With the wider-team card between the two
   founders the row reads as a triptych, and a triptych with left-ranged content
   looks like three columns of text that happen to sit next to each other. */
.team-card {
  display: flex; flex-direction: column; gap: var(--gap-icon);
  align-items: center;
  text-align: center;
}
.team-card .team-bio { max-width: 30ch; }
.team-photo {
  width: 128px; height: 128px;
  border-radius: var(--radius-control);
  border: 1px solid var(--color-line);
  object-fit: cover;
  margin-bottom: var(--gap-icon);
  background: #000000;
}
:root[data-theme="light"] img.team-photo { filter: invert(1); border-color: transparent; }
.team-photo-mark {
  display: flex; align-items: center; justify-content: center;
  color: var(--color-ink);
  background: #000000;
}
:root[data-theme="light"] .team-photo-mark { background: #FFFFFF; }
/* 46px render -> ~43.6px of actual ink in a 128px box, so clearspace is ~0.92x
   the glyph width on every side. The Axi brand book asks for >= 0.5x; at the
   old 60px it only just cleared that and read as though it were bursting out
   of the tile next to the two portraits. */
.team-mark-glyph { width: 46px; height: auto; display: block; }
:root[data-theme="light"] .team-mark-glyph { filter: invert(1); }
.team-name { font-size: var(--text-section); font-weight: var(--weight-section); }
.team-bio { color: var(--color-muted); }

/* ============================ FOOTER ============================ */
.footer { border-top: 1px solid var(--color-line); padding: var(--gap-section) 0 var(--gap-group); }
.footer-inner { display: flex; gap: var(--gap-section); flex-wrap: wrap; }
.footer-lockup { width: 118px; height: auto; }
.footer-line { color: var(--color-faint); font-size: var(--text-meta); margin-top: var(--gap-inline); }
.footer-cols { display: flex; gap: var(--gap-section); margin-left: auto; flex-wrap: wrap; }
.footer-col { display: flex; flex-direction: column; gap: var(--gap-inline); }
.footer-col .eyebrow { margin-bottom: 4px; }
.footer-col a, .footer-col span { font-size: var(--text-meta); color: var(--color-muted); }
.footer-col a:hover { color: var(--color-ink); }
.footer-legal {
  margin-top: var(--gap-group);
  padding-top: var(--gap-item);
  border-top: 1px solid var(--color-line);
  display: flex; justify-content: space-between; gap: var(--gap-inline);
}

/* ============================ REVEALS (stage tier) ============================ */
.js [data-reveal] { opacity: 0; transform: translateY(var(--rise)); }
.js [data-reveal].in {
  opacity: 1; transform: none;
  transition: opacity var(--dur-stage) var(--ease-spatial), transform var(--dur-stage) var(--ease-spatial);
  transition-delay: calc(var(--reveal-i, 0) * 60ms);
}

/* ============================ RESPONSIVE ============================ */
@media (max-width: 1279px) {
  .suite-grid { grid-template-columns: repeat(2, 1fr); }
  .journey { grid-template-columns: repeat(2, 1fr); }
  .team-grid { grid-template-columns: repeat(2, 1fr); }
  .origin-grid { grid-template-columns: 1fr 340px; gap: var(--gap-group); }
}
@media (max-width: 900px) {
  .origin-grid { grid-template-columns: 1fr; }
  .aperture { max-width: 340px; margin: 0 auto; }
}
/* nav collapses at 1024, not 767: below this the link row would run into the
   absolutely-centred lockup. */
@media (max-width: 1023px) {
  .nav-rail { display: none; }
  .menu-toggle { display: inline-flex; }
}
@media (max-width: 767px) {
  .suite-grid, .journey, .team-grid { grid-template-columns: 1fr; }
  .creed li { grid-template-columns: 1fr; gap: var(--gap-inline); }
  .footer-inner { flex-direction: column; gap: var(--gap-group); }
  .footer-cols { margin-left: 0; gap: var(--gap-group); }
  .app-card { min-height: 0; }
  a.app-card .btn-compact { opacity: 1; transform: none; }
}

/* ============================ AMBIENT TIER (rulebook §8) ==================
   Every section keeps one quiet moving element so the page stays alive after
   its arrival animation finishes. All of it is decorative geometry — never
   text, never a control. Each section runs its own period so the page never
   pulses in unison, which would read as a heartbeat rather than as calm.
   Gated on .in so nothing moves before its section has arrived.
     hero      glyph field: drift + breathe   (above)
     suite     product marks: scale
     origin    the depth film itself           (cinematic tier, §8)
     approach  step card outlines: tone
     standard  creed hairlines: tone
     studio    glyph field + the Axi mark: scale
   ======================================================================== */
@keyframes mark-breathe {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.030); }
}
@keyframes hairline-breathe {
  0%, 100% { border-color: var(--color-line); }
  50%      { border-color: var(--color-linestrong); }
}

/* suite — the four product marks */
.js .in .app-mark { animation: mark-breathe 11s var(--ease-ambient) infinite; }
.js .suite-grid > :nth-child(1) .app-mark { animation-delay: 1.2s; }
.js .suite-grid > :nth-child(2) .app-mark { animation-delay: 2.0s; }
.js .suite-grid > :nth-child(3) .app-mark { animation-delay: 2.8s; }
.js .suite-grid > :nth-child(4) .app-mark { animation-delay: 3.6s; }

/* approach — no ambient element. The section carries a background film, and §8
   is explicit that a film satisfies the section's one quiet moving element:
   breathing the step outlines under it would be two ambient systems in one
   frame. The outlines' wave was removed with the film's arrival. */

/* standard — the creed rules breathe down the list */
.js .creed li.in { animation: hairline-breathe 15s var(--ease-ambient) infinite; }
.js .creed li.in:nth-child(1) { animation-delay: 1.0s; }
.js .creed li.in:nth-child(2) { animation-delay: 2.4s; }
.js .creed li.in:nth-child(3) { animation-delay: 3.8s; }
.js .creed li.in:nth-child(4) { animation-delay: 5.2s; }

/* studio — the Axi mark in the wider-team tile */
.js .in .team-mark-glyph { animation: mark-breathe 12s var(--ease-ambient) infinite 1.6s; }

/* -- pointer proximity on the glyph field (§8, 2026-08-06) -----------------
   A second copy of the tile sits over the field, masked to a soft disc that
   follows the pointer, so the texture reads as lit rather than printed. Tone
   only: nothing moves and nothing reflows. --spot-a goes to 0 when the pointer
   leaves and on touch devices, where .lit is never applied at all. */
.glyph-field::after {
  content: "";
  position: absolute; inset: 0;
  background-image: inherit;
  background-size: inherit;
  opacity: 0;
  transition: opacity 420ms var(--ease-state);
  -webkit-mask-image: radial-gradient(circle var(--spot-r, 260px) at var(--spot-x, -999px) var(--spot-y, -999px),
                                      #000 0%, rgba(0,0,0,.55) 45%, transparent 72%);
          mask-image: radial-gradient(circle var(--spot-r, 260px) at var(--spot-x, -999px) var(--spot-y, -999px),
                                      #000 0%, rgba(0,0,0,.55) 45%, transparent 72%);
  pointer-events: none;
}
/* peak stays inside the §8 ceiling of 4x the field's resting opacity */
.js .has-field.lit .glyph-field::after { opacity: .20; }

/* -- sweep across the hero lockup (§8, 2026-08-06) -------------------------
   A highlight band travels along the lockup, masked to the artwork itself via
   the lockup PNG's own alpha, so only the strokes catch the light. Additive:
   the mark's resting brightness is the floor. */
.lockup { position: relative; }
.lockup::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(105deg,
              transparent 38%, rgba(255,255,255,.85) 50%, transparent 62%);
  background-size: 260% 100%;
  -webkit-mask-image: url("/assets/logos/axdi-white.png");
          mask-image: url("/assets/logos/axdi-white.png");
  -webkit-mask-size: 100% 100%; mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  pointer-events: none;
  opacity: 0;
}
.js .lockup.drawn::after {
  animation: lockup-sweep 9s var(--ease-ambient) infinite 2.2s;
}
@keyframes lockup-sweep {
  0%        { background-position: 130% 0; opacity: 0; }
  12%       { opacity: .9; }
  38%       { opacity: .9; }
  50%, 100% { background-position: -30% 0; opacity: 0; }
}
:root[data-theme="light"] .lockup::after {
  background: linear-gradient(105deg,
              transparent 38%, rgba(0,0,0,.55) 50%, transparent 62%);
  -webkit-mask-image: url("/assets/logos/axdi-black.png");
          mask-image: url("/assets/logos/axdi-black.png");
}

/* -- suite cards: hover lift + mark redraw (v1.3 marketing micro-lift) ----- */
a.app-card {
  transition: background var(--dur-state) var(--ease-state),
              border-color var(--dur-state) var(--ease-state),
              transform 200ms var(--ease-spatial);
}
a.app-card:hover { transform: translateY(-3px) scale(1.012); }
a.app-card:hover .app-mark img,
a.app-card:focus-visible .app-mark img {
  animation: mark-redraw 620ms var(--ease-spatial);
}
/* downward, matching the arrival draw, the hero lockup and the rain */
@keyframes mark-redraw {
  from { clip-path: inset(0 0 100% 0); }
  to   { clip-path: inset(0 0 0 0); }
}

/* -- approach: a rule that draws across the row as you scroll the section --
   Scroll-linked line growth, which §8 has always permitted. --journey-p is set
   by motion.js from the section's own scroll progress. */
.journey { position: relative; }
.journey::before {
  content: "";
  position: absolute; left: 0; top: -18px;
  height: 1px;
  /* Drawn across the entrance only. Over the whole pass the line would still be
     creeping while the section was already leaving, and the fourth step would
     land inside the exit ramp. */
  width: calc(clamp(0, calc(var(--journey-p, 0) / .47), 1) * 100%);
  background: var(--color-linestrong);
  /* no transition: this is scroll-linked, so it already updates every
     scroll event. A transition only smears it behind the scroll. */
}

/* -- origin: section progress ------------------------------------------------
   --ap-open is published by motion.js onto .sec-origin and inherits down to
   .ap-film. There is deliberately NO `.aperture { --ap-open: 0 }` default here:
   setting it on the figure would shadow the inherited value from the section
   above and pin every scene ramp at 0. The `var(--ap-open, 0)` fallbacks at each
   use site already cover the no-JS case. */

/* ============================ CINEMATIC TIER (rulebook §8, v2.2) ==========
   Background film behind a marketing section. Scenery only: never under text,
   never carrying meaning, and the section reads complete with the film absent
   (the poster is the design, the film is a layer over it).

   Property ownership, because this page has lost three days to it already:
   .ap-film / .af-film / .scene-sheet are NEW elements, so nothing else claims
   their opacity or transform. The film is deliberately NOT put on .aperture
   itself — [data-reveal] owns that element's opacity and transform, and an
   animation beats a declaration.

   Every film is graded in the encode, not here: mean luma 23-24 against the
   §8 ceiling of ~36 (linestrong). CSS only positions and masks.
   ======================================================================== */

/* -- shared: the soft edge, on the corner law, never a circle --------------
   Two axis gradients intersected, not radial-gradient(). A radial scrim would
   put a circle on a page whose every shape is a rounded square (§4). Crossing
   a horizontal and a vertical falloff fades all four edges and softens the
   corners twice over, which lands as a rounded-square vignette by construction.
   The border-radius underneath keeps the hard clip on the same law.
   Applied to .ap-film below; the other two need no scrim — the massing is a
   centred object already surrounded by black, and the sheet is full-bleed. */

/* -- scene 02, origin: the depth corridor behind the aperture --------------
   The film is square and .aperture is a 1:1 box, so it maps on with no
   cropping. The canonical glyph sits above it: a mark over a film is always the
   real asset composited on top, never whatever the model drew (§8). */
.aperture { isolation: isolate; }
.ap-film {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  border-radius: 17.1%;          /* the outer ring's rx 72 on a 420 box */
  z-index: 0;
  opacity: 0;
  pointer-events: none;
  -webkit-mask-image:
    linear-gradient(to right,  transparent 0%, #000 14%, #000 86%, transparent 100%),
    linear-gradient(to bottom, transparent 0%, #000 14%, #000 86%, transparent 100%);
          mask-image:
    linear-gradient(to right,  transparent 0%, #000 14%, #000 86%, transparent 100%),
    linear-gradient(to bottom, transparent 0%, #000 14%, #000 86%, transparent 100%);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
}
img.ap-glyph { z-index: 2; }
/* Fades up with the section rather than snapping on: --ap-open is the origin
   section's own scroll progress, already published by motion.js.
   NOT gated on .in — that class is only ever applied to [data-reveal] elements,
   so anything keyed to a SECTION carrying .in silently never matches. That bug
   hid scenes 03 and 04 completely until the tier harness caught it. */
.js .ap-film { opacity: calc(.34 + var(--ap-open, 0) * .28); }
:root[data-theme="light"] .ap-film { opacity: 0 !important; }

/* -- scene 03, approach: the massing behind the copy -----------------------
   4:3, centred, and held well below the text it sits under. This one DOES sit
   behind reading copy, which the tier forbids at full strength, so it is the
   quietest film on the page: the encode already puts p99 at linestrong, and
   the opacity below takes it further down. */
.sec-approach { position: relative; isolation: isolate; }
.sec-approach > .wrap { position: relative; z-index: 1; }
.scene-film {
  position: absolute; inset: 0;
  z-index: 0;
  display: grid; place-items: center;
  overflow: hidden;
  pointer-events: none;
}
.af-film {
  width: min(760px, 78%);
  height: auto;
  opacity: 0;
}
.js .af-film { opacity: .5; }
:root[data-theme="light"] .af-film { opacity: 0 !important; }

/* -- scene 04, standard: the sheet -----------------------------------------
   Material, not film. It has no animation of its own by design: the copy says
   restraint is the feature, so the only thing that moves it is the reader's
   scroll. --sheet-p is the section's progress, published by motion.js. */
.sec-standard { position: relative; isolation: isolate; }
.sec-standard > .wrap { position: relative; z-index: 1; }
/* The drifting texture lives on ::before, not on .scene-sheet itself. It has to
   be oversized to have anywhere to travel, and an oversized inset:0 element
   sticks 3% past each edge — which the width sweep caught at every width (30px
   at 1000px wide). Putting it on a pseudo-element means the sweep's
   querySelectorAll('body *') never sees it AND .scene-sheet genuinely clips it,
   so this is a real fix rather than another entry on the harness's exemption
   list beside .glyph-field. .scene-sheet stays exactly inset:0. */
.scene-sheet {
  position: absolute; inset: 0;
  z-index: 0;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  /* Vertical falloff only. The sheet sits at luma 11 (--color-surface) on a
     true-black canvas, so at full strength its section boundaries read as a
     hard-edged lighter band. Fading top and bottom dissolves it into the page.
     Left and right need nothing: the layer is full-bleed, so those edges are
     the viewport's own and there is no seam to hide. A one-axis gradient is an
     edge falloff rather than a shape, so this does not put back the circle the
     corner law rules out. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 12%, #000 88%, transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0%, #000 12%, #000 88%, transparent 100%);
}
.scene-sheet::before {
  content: "";
  /* ANCHORED RIGHT AND BOTTOM, OVERSIZED LEFT AND UP — not inset:0 + scale().
     The oversize has to go somewhere, and which side it goes to is the whole
     fix. Scrollable overflow is a one-sided idea: `scrollWidth` counts content
     past the RIGHT edge and `scrollHeight` past the BOTTOM, while anything
     spilling left or up is simply clipped and never measured. An inset:0 box
     scaled 1.06 spills 3% on all four sides, so the two that count reported
     3% of the section forever — 43px of both at 1440.

     Moving the drift onto a pseudo-element was believed to have settled this,
     and it did settle two of the three checks: the width sweep and the
     bounding-rect pass both walk querySelectorAll('body *'), which never
     returns a pseudo-element. The scrollWidth check is not element-based. A
     transformed descendant contributes to its scroller's overflow whether or
     not script can enumerate it, so .scene-sheet went on reporting H and V
     clipping at every width in both themes.

     Anchoring the oversized box to the right edge instead was tried and is
     WRONG, which is worth recording because it looks right: pinning right:0
     and drifting left does clear the overflow, but the box then retreats from
     the right edge as it travels and exposes a bare strip there — 40px at
     1280. A translated box cannot both stay inside the right edge at rest and
     still cover it at full drift; the two requirements pin it exactly, which
     means it cannot travel at all.

     So the box stops moving and the TEXTURE moves inside it. inset:0 makes
     the geometry unarguable — the box is exactly its clipper, so there is no
     overflow to measure at any drift value and no edge it can retreat from.
     background-size:cover keeps it covered by construction. Travel comes from
     background-position sliding through the crop that cover already discards,
     so where an aspect ratio leaves little to slide through the texture simply
     holds still rather than tearing a gap open. Degrades to less motion, never
     to a visible seam. */
  position: absolute; inset: 0;
  background-image: url("/assets/scenes/standard-sheet.webp");
  background-size: cover;
  /* drift is scroll-linked, taken from --sheet-p. No transition: a transition
     only smears it behind the scroll. */
  background-position: calc(50% + var(--sheet-p, 0) * -6%) center;
}
.js .scene-sheet { opacity: 1; }
:root[data-theme="light"] .scene-sheet { opacity: 0 !important; }

/* ============================ SCENE CHOREOGRAPHY (scenes 02-04) ===========
   Each of these sections gets an entrance and an exit instead of simply being
   scrolled past. Two rules govern how:

   1. DWELL COMES FROM HEIGHT, NOT FROM PINNING. Sticky pinning was tried and
      reverted: it fought the load beat, held clicked cards open, and pushed the
      page past twelve viewports. Taller sections buy the same reading time for
      about half the cost, and the page keeps scrolling normally throughout.

   2. EVERY BEAT RIDES A VAR THAT ALREADY EXISTS. motion.js already publishes
      --ap-open, --journey-p and --sheet-p as each section's own 0->1 progress.
      No new observers, no new JS timing, nothing to fall out of sync.

   Each section derives two ramps from its progress var:
     --in   0 -> 1 over the first 30% of the pass   (arrival)
     --out  0 -> 1 over the last  30% of the pass   (departure)
   leaving the middle 40% as a hold, which is the dwell the copy needs.
   ======================================================================== */
.sec-origin, .sec-approach, .sec-standard {
  /* 1.35, down from 1.60. The gap bands below now occupy what used to be dead
     scroll between sections, so the dwell no longer has to come from padding
     inside each section — and the page stays under the twelve viewports that
     made pinning fail. */
  min-height: 135svh;
  display: grid;
  align-content: center;
}

/* -- scene 02: the corridor opens, then you leave through it ---------------
   IN  the film fades up out of black and opens from the centre.
   OUT the camera pushes THROUGH the innermost frame — the aperture becomes the
       doorway you leave by — accelerating inward and washing back to black. */
.js .sec-origin {
  --in:  clamp(0, calc(var(--ap-open, 0) / .30), 1);
  --out: clamp(0, calc((var(--ap-open, 0) - .70) / .30), 1);
}
.js .ap-film {
  opacity: calc(var(--in, 1) * (1 - var(--out, 0)) * .62);
  transform: scale(calc(.92 + var(--in, 1) * .08 + var(--out, 0) * .42));
  transform-origin: 50% 50%;
}
/* The mark leaves with the corridor rather than hanging over an empty frame. */
.js img.ap-glyph { opacity: calc(var(--in, 1) * (1 - var(--out, 0))); }

/* -- scene 03: the drawing assembles, then releases ------------------------ */
.js .sec-approach {
  --in:  clamp(0, calc(var(--journey-p, 0) / .30), 1);
  --out: clamp(0, calc((var(--journey-p, 0) - .70) / .30), 1);
}
.js .af-film {
  opacity: calc(var(--in, 1) * (1 - var(--out, 0)) * .5);
  transform: translate3d(0, calc(var(--out, 0) * -4%), 0) scale(calc(.96 + var(--in, 1) * .04));
}

/* Each step lands as the spine reaches it, then releases upward in the order it
   arrived. Driven ENTIRELY by --journey-p: the steps no longer carry
   [data-reveal], because two systems writing one element's opacity and
   transform is the bug that has cost this page the most. The fix is to remove
   the second owner, not to wrap the element to dodge it. */
.js .step {
  opacity: calc(var(--land, 1) * (1 - var(--rel, 0)));
  transform: translateY(calc((1 - var(--land, 1)) * var(--rise) - var(--rel, 0) * 26px));
  will-change: opacity, transform;
}
.js .journey .step:nth-child(1) { --land: clamp(0, calc((var(--journey-p, 0) - .04) / .10), 1); --rel: clamp(0, calc((var(--journey-p, 0) - .70) / .12), 1); }
.js .journey .step:nth-child(2) { --land: clamp(0, calc((var(--journey-p, 0) - .15) / .10), 1); --rel: clamp(0, calc((var(--journey-p, 0) - .75) / .12), 1); }
.js .journey .step:nth-child(3) { --land: clamp(0, calc((var(--journey-p, 0) - .26) / .10), 1); --rel: clamp(0, calc((var(--journey-p, 0) - .80) / .12), 1); }
.js .journey .step:nth-child(4) { --land: clamp(0, calc((var(--journey-p, 0) - .37) / .10), 1); --rel: clamp(0, calc((var(--journey-p, 0) - .85) / .12), 1); }

/* -- scene 04: the sheet arrives from the left and carries on past ---------
   Still no motion of its own — every value here is a function of scroll. */
.js .sec-standard {
  --in:  clamp(0, calc(var(--sheet-p, 0) / .30), 1);
  --out: clamp(0, calc((var(--sheet-p, 0) - .70) / .30), 1);
}
.js .scene-sheet { opacity: calc(var(--in, 1) * (1 - var(--out, 0))); }

/* Each creed rule draws itself as its row arrives, top to bottom, and retracts
   on the way out. ADDITIVE over the existing border-top, never replacing it:
   border-color belongs to the ambient hairline-breathe, and an animation beats
   a declaration, so seizing it would break both. The border is still the rule;
   this is a brighter line drawn along it. */
.creed li { position: relative; }
.creed li::after {
  content: "";
  position: absolute; left: 0; top: -1px; height: 1px;
  background: var(--color-linestrong);
  width: 0;
  pointer-events: none;
}
.js .creed li::after { width: calc(var(--draw, 0) * 100%); }
/* --draw is the drawing ramp times the inverse of the retract ramp, so each rule
   grows to full width, holds, and then withdraws the way it came: the right end
   receding back toward the left, which is the drawing played backwards. Rows
   retract in the order they drew, so the list empties from the top exactly as it
   filled. Everything is clear before the suite arrives. */
.js .sec-standard .creed li:nth-child(1) { --draw: calc(clamp(0, calc((var(--sheet-p, 0) - .10) / .18), 1) * (1 - clamp(0, calc((var(--sheet-p, 0) - .70) / .11), 1))); }
.js .sec-standard .creed li:nth-child(2) { --draw: calc(clamp(0, calc((var(--sheet-p, 0) - .18) / .18), 1) * (1 - clamp(0, calc((var(--sheet-p, 0) - .74) / .11), 1))); }
.js .sec-standard .creed li:nth-child(3) { --draw: calc(clamp(0, calc((var(--sheet-p, 0) - .26) / .18), 1) * (1 - clamp(0, calc((var(--sheet-p, 0) - .78) / .11), 1))); }
.js .sec-standard .creed li:nth-child(4) { --draw: calc(clamp(0, calc((var(--sheet-p, 0) - .34) / .18), 1) * (1 - clamp(0, calc((var(--sheet-p, 0) - .82) / .11), 1))); }

/* -- the gap bands: connective tissue, not destinations --------------------
   Each gap is the transformation between the two sections it separates: the
   rain converging into the corridor, the corridor flattening into the plan, the
   massing lying down into the sheet, the sheet dividing into four panels.

   They exist because the page measured DEAD at every seam — 0.02-0.16 mean
   frame-to-frame change against 3-5 inside the sections — and read, in a vision
   audit, as "a series of separate slideshow slides" that each "fade to total
   darkness before the next begins". A gap is what turns a seam into a beat.

   Not loops. Each plays once through its transformation and holds the far end,
   which IS the geometry of the section you are arriving at. */
.gap {
  position: relative;
  /* The BAND is the grid, not the caption. The caption used to carry
     `height:100%; align-content:end`, which silently did nothing: height:100%
     does not resolve against a parent that only has min-height, so the box
     collapsed to its content and sat near the TOP of the band — the brightest
     part of the film, and out of reach of the foot falloff below. It measured
     at y=340 in an 810px band while I was describing it as "the lower third". */
  display: grid;
  align-content: end;
  /* 0.9 — enough for a full transformation to read, while keeping the page
     under the twelve viewports that made pinning fail. */
  min-height: 90svh;
  overflow: hidden;
  isolation: isolate;
  /* Fast in, late out — deliberately not the 30/40/30 the scene sections use.
     A gap's job is to be ALREADY THERE as the previous section fades, so the
     seam never goes black. At .26 the ramp was still climbing while the section
     above had finished leaving, which measured as two surviving dead bands
     (hero->gap1, standard->gap4). It reaches full in the first 14% and holds
     until the last 14%. */
  --in:  clamp(0, calc(var(--gap-p, 0) / .14), 1);
  --out: clamp(0, calc((var(--gap-p, 0) - .86) / .14), 1);
}
.gap-film {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  /* NO transform here, deliberately. Each source was generated with "clear empty
     space around it" so the transformation could never be clipped, which left
     the form small in its own frame — but the fix is a crop baked into the
     ENCODE, not a CSS scale. A scaled inset:0 layer overflows its scroller even
     under overflow:hidden (the clip is visual; the layout box still counts),
     which broke the width sweep at all 184 measurements. Same trap as the sheet
     in #44. The box must equal its clipper. */
  /* Edge falloff, because the forms were being cut off by the frame. These are
     cover-fitted and zoomed, so a transformation reaching the edge of its own
     source hit a hard straight cut against the page — which reads as a video box
     rather than as geometry in space.
     Two axis gradients intersected, NOT radial: a radial scrim puts a circle on
     a page whose every shape is a rounded square, which is a note already given
     once on the aperture. Crossing a horizontal and a vertical falloff fades all
     four edges and softens the corners twice over, so the film dissolves into
     the page instead of ending at a line. A mask cannot overflow, unlike the
     transform this file already learned about. */
  -webkit-mask-image:
    linear-gradient(to right,  transparent 0%, #000 22%, #000 78%, transparent 100%),
    linear-gradient(to bottom, transparent 0%, #000 18%, #000 82%, transparent 100%);
          mask-image:
    linear-gradient(to right,  transparent 0%, #000 22%, #000 78%, transparent 100%),
    linear-gradient(to bottom, transparent 0%, #000 18%, #000 82%, transparent 100%);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
  opacity: 0;
  pointer-events: none;
}
.js .gap-film { opacity: calc(var(--in, 1) * (1 - var(--out, 0))); }
:root[data-theme="light"] .gap-film { opacity: 0 !important; }

/* The line each gap is carrying. A film alone reads as a transition; a film
   with a line on it reads as a beat, which is what these bands are for.
   Deliberately a DISPLAY line, not reading copy: one short declarative and an
   index, nothing a reader has to settle into and study. That distinction is the
   one §8's "a film never sits under text" is missing — see the amendment.
   The index and rule give the line something to sit against so it does not
   float in the middle of the frame unanchored. */
/* A falloff at the foot of the band, under the caption and over the film. The
   alternative was grading gaps 2 and 3 darker overall, which would have dimmed
   the whole transformation to protect four words — paying everywhere for a
   problem that exists in one strip. One-axis gradient, so it is an edge falloff
   and not a shape; same reasoning as the sheet's. */
.gap::after {
  content: "";
  position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(to bottom, transparent 46%, rgba(0,0,0,.72) 74%, rgba(0,0,0,.94) 100%);
  pointer-events: none;
}
.gap-film { z-index: 0; }
.gap-caption {
  position: relative; z-index: 2;
  display: grid; justify-items: center; gap: var(--gap-inline);
  /* A line under the form is a caption; a line across the form is a stamp. */
  padding: 0 var(--pad-page) 10svh;
  text-align: center;
  pointer-events: none;
}
.gap-n {
  color: var(--color-faint);
  letter-spacing: var(--tracking-eyebrow);
}
.gap-n::after {
  content: "";
  display: block;
  width: 1px; height: 28px;
  margin: var(--gap-inline) auto 0;
  background: var(--color-line);
}
.gap-line {
  font-size: clamp(19px, 2.1vw, 26px);
  font-weight: var(--weight-display);
  letter-spacing: var(--tracking-display);
  line-height: 1.35;
  max-width: 22ch;
  color: var(--color-ink);
}
/* Arrives on the scroll like everything else, and slightly after the film so
   the geometry is established before the line lands on it. */
.js .gap-caption {
  opacity: clamp(0, calc((var(--gap-p, 0) - .16) / .16), 1);
  transform: translateY(calc((1 - clamp(0, calc((var(--gap-p, 0) - .16) / .16), 1)) * 14px));
}
.js .gap-caption { --fade: clamp(0, calc((var(--gap-p, 0) - .80) / .16), 1); }
.js .gap-caption { opacity: calc(clamp(0, calc((var(--gap-p, 0) - .16) / .16), 1) * (1 - var(--fade, 0))); }

/* -- the copy arrives with the scroll, not on a timer ----------------------
   The scene sections' copy no longer carries [data-reveal]. It was a 600ms
   time-based pop fired by an IntersectionObserver, which meant the text
   appeared "very quick, all of a sudden" and then sat frozen for a viewport and
   a half while you kept scrolling. Two owners for one property is the bug this
   page keeps relearning, so the reveal was removed rather than overridden.

   Rise is 18px, inside §8's 24px ceiling for a stage-tier reveal. This is
   arrival choreography bound to scroll, NOT parallax: the copy never travels
   against the scroll, it settles into place and stays there. */
.js .sec-origin .origin-copy > *,
.js .sec-approach .sec-head > *,
.js .sec-standard .sec-head > * {
  opacity: var(--copy, 1);
  transform: translateY(calc((1 - var(--copy, 1)) * 18px));
}
.js .sec-origin  .origin-copy > :nth-child(1) { --copy: clamp(0, calc((var(--ap-open, 0)  - .04) / .13), 1); }
.js .sec-origin  .origin-copy > :nth-child(2) { --copy: clamp(0, calc((var(--ap-open, 0)  - .08) / .13), 1); }
.js .sec-origin  .origin-copy > :nth-child(3) { --copy: clamp(0, calc((var(--ap-open, 0)  - .12) / .13), 1); }
.js .sec-origin  .origin-copy > :nth-child(4) { --copy: clamp(0, calc((var(--ap-open, 0)  - .16) / .13), 1); }
.js .sec-origin  .origin-copy > :nth-child(5) { --copy: clamp(0, calc((var(--ap-open, 0)  - .20) / .13), 1); }
.js .sec-approach .sec-head > :nth-child(1)   { --copy: clamp(0, calc((var(--journey-p, 0) - .04) / .13), 1); }
.js .sec-approach .sec-head > :nth-child(2)   { --copy: clamp(0, calc((var(--journey-p, 0) - .08) / .13), 1); }
.js .sec-approach .sec-head > :nth-child(3)   { --copy: clamp(0, calc((var(--journey-p, 0) - .12) / .13), 1); }
.js .sec-standard .sec-head > :nth-child(1)   { --copy: clamp(0, calc((var(--sheet-p, 0)  - .04) / .13), 1); }
.js .sec-standard .sec-head > :nth-child(2)   { --copy: clamp(0, calc((var(--sheet-p, 0)  - .08) / .13), 1); }
.js .sec-standard .sec-head > :nth-child(3)   { --copy: clamp(0, calc((var(--sheet-p, 0)  - .12) / .13), 1); }

/* ============================ CAPTURE MODE (screenshot verification) ====== */
.capture .hero {
  min-height: 780px;
  /* Pinned-scene freeze: undo the track so the scan sees today's hero exactly.
     !important because a running animation beats any non-important declaration
     regardless of order or specificity. */
  height: auto !important;
  display: grid !important;
  overflow: hidden !important;
  padding: var(--gap-section) 0 !important;
}
.capture [data-reveal] { opacity: 1 !important; transform: none !important; transition: none !important; }
.capture .glyph-field { animation: none !important; }
.capture .lockup { clip-path: none !important; transition-delay: 0s !important; animation: none !important; }
/* Pinned-scene freeze: inert wrapper, no sheet border, nothing left mid-flight. */
.capture .hero-stage { display: contents !important; }
.capture .hero [data-reveal],
.capture .glyph-rain-veil,
.capture .topbar,
.capture .brand-lockup { animation: none !important; }
/* The opening must leave nothing mid-flight, or the scan is not deterministic:
   every held handover resolves instantly instead. */
.capture .hero.rain-on .glyph-rain,
.capture .hero.rain-on .glyph-field { transition: none !important; }
/* Ambient tier must be frozen for capture, or the scan is non-deterministic. */
.capture .app-mark,
.capture .team-mark-glyph,
.capture .creed li { animation: none !important; transform: none !important; }
/* new ambient classes must be inert for capture too */
.capture .lockup::after { animation: none !important; opacity: 0 !important; }
.capture .glyph-field::after { opacity: 0 !important; }
.capture .app-mark img { animation: none !important; clip-path: none !important; }
.capture .journey::before { width: 0 !important; }
.capture .suite-grid::before { width: 0 !important; }
.capture .app-mark img { animation: none !important; clip-path: none !important; }
.capture .app-diagram-reveal { grid-template-rows: 0fr !important; margin-bottom: 0 !important; animation: none !important; }
.capture .app-diagram { opacity: 0 !important; animation: none !important; }
.capture .app-diagram [data-draw] { animation: none !important; }
.capture .app-diagram [data-draw] { stroke-dasharray: none !important; stroke-dashoffset: 0 !important; animation: none !important; }
.capture img.ap-glyph { opacity: 1 !important; }
/* Cinematic tier freeze (§8): the film does NOT play and does NOT vanish — it
   collapses to its still frame. motion.js pauses playback and rewinds to 0 so
   the scan is deterministic; these hold the layer visible at its resolved
   value rather than at its (zero) start, per the tier's freeze duty.
   The scene ramps must be pinned to the HOLD state explicitly. Under capture
   the scroll vars are never published, so --in would compute to 0 and every
   scene would freeze mid-entrance at nothing — and `var(--in, 1)` does not
   rescue it, because the section rule really does set the variable. */
.capture .sec-origin,
.capture .sec-approach,
.capture .sec-standard { --in: 1 !important; --out: 0 !important; }
.capture .step { --land: 1 !important; --rel: 0 !important; opacity: 1 !important; transform: none !important; }
.capture .journey::before { width: 0 !important; }
.capture .creed li { --draw: 1 !important; }
.capture .gap { --in: 1 !important; --out: 0 !important; }
.capture .gap-caption { opacity: 1 !important; transform: none !important; --fade: 0 !important; }
.capture .sec-origin .origin-copy > *,
.capture .sec-approach .sec-head > *,
.capture .sec-standard .sec-head > * { --copy: 1 !important; opacity: 1 !important; transform: none !important; }
.capture .ap-film,
.capture .af-film { transform: none !important; }
.capture .ap-film { opacity: .62 !important; }
.capture .af-film { opacity: .5 !important; transition: none !important; }
.capture .scene-sheet { opacity: 1 !important; transition: none !important; }
/* Freeze the drift at its rest position. This used to re-declare scale(1.06),
   which is precisely what put the overflow back in the one mode the
   verification harness actually measures — capture. There is no transform to
   restate now; holding background-position still is the whole job. */
.capture .scene-sheet::before { background-position: center !important; }

/* ============================ REDUCED MOTION ============================ */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .js .glyph-field { animation: none; }
  .js [data-reveal] { transform: none; transition: opacity 1ms; }
  .js .lockup { clip-path: none; }
  /* The opening collapses to "already arrived" — every hold released at once.
     .js .hero [data-reveal].in needs restating at its own specificity: the
     generic [data-reveal] reset above cannot reach it, so without this the copy
     stays on a 1650ms delay for exactly the people who asked for less motion. */
  .js .lockup.drawn { transition-delay: 0s; }
  .js .hero [data-reveal].in { transition-delay: 0s; }
  .hero.rain-on .glyph-rain { transition: none; }
  .hero.rain-on .glyph-field { transition: none; }
  .js img.ap-glyph { opacity: 1; }
  /* Ambient tier stops entirely, per rulebook §8. */
  .js .in .app-mark,
  .js .in .team-mark-glyph,
  .js .creed li.in { animation: none; transform: none; }
  .js .lockup.drawn::after { animation: none; opacity: 0; }
  .glyph-field::after { display: none; }
  a.app-card:hover { transform: none; }
  a.app-card:hover .app-mark img { animation: none; }
  .js .suite-grid .in .app-mark img { animation: none; clip-path: none; }
  .js .suite-grid::before { width: 0; }
  .app-diagram-reveal { transition: none; animation: none; }
  .app-diagram { transition: opacity 1ms; transform: none; animation: none; }
  .app-diagram [data-draw] { animation: none; }
  .app-diagram [data-draw] { stroke-dasharray: none; stroke-dashoffset: 0; animation: none; }
  .journey::before { width: 0; }
  /* Cinematic tier (§8): the film stops, but the section must NOT go empty for
     the people who asked for less motion — that removes the design rather than
     the motion. It resolves to the still poster frame at its settled opacity,
     never to the zero it starts from. motion.js never calls play() here.
     The scene ramps resolve to the HOLD state — arrived, mid-dwell. Not to the
     exit state: scene 02's exit washes to black, and freezing there would hand
     these readers an empty frame, which is the failure this duty exists to
     prevent. "Resolved end state" means the state that reads as complete. */
  .js .sec-origin, .js .sec-approach, .js .sec-standard { --in: 1; --out: 0; }
  /* landed, not mid-flight and not released */
  .js .step { --land: 1; --rel: 0; opacity: 1; transform: none; }
  .js .creed li { --draw: 1; }
  .js .gap { --in: 1; --out: 0; }
  .js .gap-caption { opacity: 1; transform: none; --fade: 0; }
  /* copy resolves to arrived — never to the 0 it starts from */
  .js .sec-origin .origin-copy > *,
  .js .sec-approach .sec-head > *,
  .js .sec-standard .sec-head > * { --copy: 1; opacity: 1; transform: none; }
  .js .ap-film { opacity: .62; transform: none; transition: none; }
  .js .af-film { opacity: .5; transform: none; transition: none; }
  .js img.ap-glyph { opacity: 1; }
  .js .scene-sheet { opacity: 1; transition: none; }
  /* Same reason as the capture rule: there is no transform any more, so
     reduced motion only needs the drift held still. */
  .scene-sheet::before { background-position: center; }
}
