/* THE SPARK DUST hero. Same house tokens as the product-as-proof reference
   (shared/viz/tokens.json): zinc base, accent blue. UNLIKE the reference,
   role names carry NO family colour coding in this option (Fred: "I have
   doubts about the purple and orange... I like the blue... this should come
   more from becoming perhaps brighter sometimes") — every role renders the
   same monochrome blue; data-family attributes stay in the markup for
   future use but drive no colour here. The family legend is removed
   entirely (nothing left for it to describe). Layer architecture (house
   reference research-map-cinematic.html):
   canvas z0 < veil z1 < static-stars z1 < runway content z2 < role names +
   hero copy z3 < header z40. Structure and fallback composition are carried
   over from product-as-proof byte-for-byte except: the monochrome role
   colour, the removed legend, and the new .static-stars CSS backdrop
   (extends the no-3d/reduced-motion static composition with a starfield
   read, since this option's live scene now has a starfield layer of its
   own — the static fallback should not look barer than the live scene).
   #hero-light (emergence, 2026-09-03, normal tier only, JS-inserted — see
   its own rule below) sits at z0 too, positioned in DOM order between
   #dust and #scene so #scene (same z-index, later in source) always
   paints over it; nominally UNDER .static-stars' z1, but that layer's own
   background is a sparse, >99%-transparent dot field, so the light reads
   through it in practice rather than being occluded — see #hero-light's
   own comment for the full reasoning and the FLAG this leaves for the
   integrator (renumbering the shared stack for one new layer was judged
   not worth it for a transient pre-boot glow). */

:root{
  --bg-0:#09090b;
  --bg-1:#101013;
  --bg-2:#18181b;
  --bg-3:#27272a;
  --fg-1:#f4f4f5;
  --fg-2:#a1a1aa;
  --fg-3:#86868f; /* zinc-500 lifted for AA on --bg-0 (5.51:1), carried from the reference */
  --accent:#2563eb;
  --accent-2:#60a5fa;
  --accent-3:#bfdbfe;
  --role-c:#60a5fa;      --role-rgb:96,165,250;
  --glass:rgba(18,18,22,.62);
  --glass-brd:rgba(148,163,255,.15);
  --hairline:rgba(255,255,255,.07);
  --ease-out:cubic-bezier(.16,1,.3,1);
  --ease-in-out:cubic-bezier(.65,0,.35,1);
  --ease-swift:cubic-bezier(.3,.9,.35,1);
  --radius-lg:20px;
  --radius-md:15px;
  --radius-sm:9px;
  --maxw:1180px;
}

/* "Inter Fallback" (2026-09-04): a size-adjusted LOCAL face, not a webfont
   -- it never triggers a network request. Paired with index.html's own
   non-render-blocking Google Fonts <link> (preload + media=print swap):
   once the font stylesheet is no longer render-blocking, first paint
   happens BEFORE Inter is known/loaded, so the browser paints with
   whatever this stack's first available face is. Without this, that face
   is "Segoe UI" at ITS OWN metrics, then a visible reflow when Inter
   swaps in (font-display:swap, confirmed set on all 35 @font-face rules
   Google serves for this family -- fetched and inspected directly,
   2026-09-04). ascent-override/descent-override/line-gap-override/
   size-adjust below are Inter-vs-Arial values, cross-confirmed against
   two independent published sources (the values Vercel's next/font tool
   computes for Inter's real font metrics against Arial -- not guessed):
   ascent-override 90.20%, descent-override 22.48%, line-gap-override
   0.00%, size-adjust 107.40%. Arial (not "Segoe UI") is the base because
   these are the only published, verified numbers; Arial is effectively
   universal (Windows/macOS/Linux all alias or ship it), so this still
   fires before the real webfont everywhere "Segoe UI" would have. */
@font-face{
  font-family:"Inter Fallback";
  src:local("Arial");
  ascent-override:90.20%;
  descent-override:22.48%;
  line-gap-override:0.00%;
  size-adjust:107.40%;
}

*,*::before,*::after{ box-sizing:border-box; }
html{ scroll-behavior:smooth; }
html,body{ overflow-x:hidden; }
body{
  margin:0;
  background:var(--bg-0);
  color:var(--fg-1);
  font-family:Inter,"Inter Fallback","Segoe UI",system-ui,-apple-system,sans-serif;
  -webkit-font-smoothing:antialiased;
  text-rendering:optimizeLegibility;
}
img{ max-width:100%; display:block; }
a{ color:inherit; }
h1,h2,h3{ font-weight:700; letter-spacing:-.025em; margin:0; text-wrap:balance; }
p{ margin:0; }
::selection{ background:var(--accent); color:#fff; }

.skip-link{
  position:absolute; left:12px; top:-60px; z-index:100;
  background:var(--accent); color:#fff; padding:10px 16px; border-radius:var(--radius-sm);
  font-size:14px; font-weight:600; transition:top .18s var(--ease-swift);
}
.skip-link:focus{ top:12px; }
:focus-visible{ outline:2px solid var(--accent-2); outline-offset:3px; border-radius:4px; }

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

/* ---------- Scene layers ---------- */

/* Layer 0: the CINEMA DUST canvas (editorial-cinema's scene, ported verbatim
   into hero.html). Same contract as its origin: fixed, full page, hidden
   until the module boots and sets body.dust-on; if WebGL is absent, the
   module fails, or ?dust=off, the class never lands and .static-stars stays
   as the CSS backdrop.

   #dust and #scene are BOTH position:fixed at z-index 0. Equal z-index means
   source order decides, and #dust is first in the DOM, so the dust paints
   UNDER the hero scene. That is deliberate: it avoids renumbering the whole
   documented stack (canvas z0 < veil z1 < static-stars z1 < content z2).
   The hero renderer runs alpha:true so this layer shows through it. */
#dust{
  position:fixed; inset:0; z-index:0;
  display:none; pointer-events:none;
}
body.dust-on #dust{ display:block; }
/* No-3D is the hero organism's failure state, not the dust's: the dust is an
   independent classic-WebGL context and keeps running, so the page still has
   a live background when WebGPU/TSL is unavailable. */
body.dust-on .static-stars{ opacity:0; }

#scene{
  position:fixed; inset:0; width:100vw; height:100vh; z-index:0;
  display:block; pointer-events:none; opacity:0;
  transition:opacity 1.4s var(--ease-out);
}
body.js-3d #scene{ opacity:1; }
body.no-3d #scene{ display:none; }
body.no-3d #hero-light{ display:none; }

/* ---------- Hero light (emergence, 2026-09-03) ----------
   Fred's plan (2026-09-03, verbatim): "the central spark/light fades in,
   then a few particles, then more and more and more over the whatever
   time everything needs to load, then the names" — explicitly NOT a still
   poster that then switches to the loaded scene (the retired boot-poster
   this replaces was exactly that, and is gone). This element is the FIRST
   thing painted: a soft glow at the Spark's approximate resting screen
   position, installed at tier-selection time on the normal tier only
   (env.flags.emergence, hero.js installHeroLight()), fading in from paint
   and crossfading into the real Spark the instant the core reports its
   own first frame (hero.js handleCoreNotify's 'emergence' case) — one
   continuous light, never a hard cut.
   Position: --spark-x/--spark-y (%) are set inline by hero.js's
   sparkRestPercent(), an EXACT replica (P0(b) fix, 2026-09-04, track 2;
   see projectSparkExact()'s own header comment for the full derivation
   and its three.js cross-check) of hero-core.js's own boot-frame camera
   projection, including camera.lookAt() — position match is Fred's
   explicit requirement ("it's important that the positions of the
   spark/3d version match"), verified to sub-pixel agreement against the
   core's own reported first-frame position (payload.sparkScreen on
   'spark-first-frame') via hero.js's handleLightHandover().
   Appearance (2026-09-04): superseded -- the element is now a div carrying
   a pixel replica of the rendered Spark; see the rule's own comment below
   and hero.js paintSparkReplica(). The earlier two-stop radial-gradient
   approximation (and the vmax sizes it was measured to) is gone. */
/* 2026-09-04 (Fred: "the spark/light should look the same as the one it
   blends into"): #hero-light is now a div carrying a pixel REPLICA
   of the rendered Spark (hero.js paintSparkReplica(): the core's three
   additive sprites, same colours/scales/falloff/blend/sRGB output,
   calibrated against a ?sparkonly=1 reference capture to within 4% of
   full scale at every radius). The two radial-gradient sizes and the
   screen blend this rule used to carry are gone with the approximation:
   the div carries the radially symmetric breath+halo sum as a sampled
   CSS radial-gradient (premultiplied-equivalent stops, so it composites
   as C + bg*(1-A) -- exactly what the WebGPU canvas's additive sprites
   do over the same page ground) and a small software-canvas child adds
   the lobed core with plus-lighter. It is sized by hero.js to the
   breath sprite's full quad at the Spark's projected depth and centred
   on --spark-x/--spark-y (the same exact projection as before;
   handleLightHandover()'s position correction still drives those two
   properties). Fade-in: --hero-light-fade-ms (1600ms) from
   first paint; the handover out is a JS tween that mirrors the core's
   own Spark intensity ramp (hero.js crossfadeLightIntoSpark()). */
#hero-light{
  position:fixed; z-index:0; pointer-events:none;
  left:var(--spark-x,61%); top:var(--spark-y,49%);
  transform:translate(-50%,-50%);
  opacity:0;
  transition:opacity var(--hero-light-fade-ms, 1600ms) var(--ease-out);
  will-change:opacity;
  /* background: the breath+halo radial-gradient, set inline by hero.js
     paintSparkReplica() (128 sampled stops, CSS px); the lobed core is
     the canvas child below. */
}
#hero-light.is-visible{ opacity:1; }
#hero-light .hero-light-core{
  position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
  display:block; pointer-events:none;
  mix-blend-mode:screen; /* fallback */
}
@supports (mix-blend-mode:plus-lighter){
  #hero-light .hero-light-core{ mix-blend-mode:plus-lighter; }
}
@media (prefers-reduced-motion:reduce){
  #hero-light{ transition:none; }
}

/* R6 (Fred, 2026-08-30): dim once scrolled past the hero -- one
   reversible `hero-past` state controls both fixed particle canvases and
   restores full brightness on the way back into #stage. R7 (Fred, same
   day): "dim another 20%" on top of R6's 30%, so the target is 0.50.
   PAIRED VALUE: hero.js's syncHeroViewportState() reports this same number
   as HERO.scrollState.brightness and the behavior contract's R41 row
   asserts both agree -- change them together. This lives in the VERBATIM hero floor, not v2.css: the
   ss26 scoper (scripts/webflow_ss26_build.py, scope_css_source) rewrites a
   v2.css `body.hero-past` selector to `[data-ss26-site].hero-past`, which
   never matches, because hero.js toggles `hero-past` on the real <body>
   (the identical trap v2.js's R5-1 comment documents for `body.js-3d`). */
#scene, #dust{ transition:opacity .48s var(--ease-out); }
body.hero-past #scene, body.hero-past #dust{ opacity:.50; }
@media (prefers-reduced-motion:reduce){
  #scene, #dust{ transition:none; }
}

/* Veil: keeps type AA over the brightest particles, from EITHER dust layer.
   Left column (desktop) darkens under the copy; --vs (0..1, set by JS from
   damped scroll, capped lower now -- see R1 below) deepens the field
   slightly as the page condenses. R1 fix: the OLD ::after multiplier (.82)
   compounded with an uncapped --vs (maxing at 1 after just 2 viewport
   heights of scroll) meant this layer sat at a near-opaque black overlay
   for the ENTIRE rest of a document many more viewport heights long --
   exactly the "must NOT erase the stars further down the page" failure.
   Multiplier cut .82 -> .22 and the JS-side --vs ceiling lowered (see
   hero.js frame()) so the worst-case additional darkening is a light
   ~0.12 opacity wash, not a near-black scrim.

   Repair (2026-08-11, [bg-stars] re-verify FAIL): root cause was THIS rule,
   not the starfield formula. A real-pixel probe (white backdrop inserted
   between canvas z0 and this layer, then read back) measured combined
   linear+radial opacity across the failing 600x400 above-fold-left crop at
   0.46-0.92 -- even the theoretical BRIGHTEST possible star (a rare-big
   sprite centre at full twinkle and closest depth, ~204-208/255 pre-veil,
   the same ceiling bg-hierarchy's own ~205-230 measured peak already sits
   at) composites to at most ~110/255 there, never the required 140. No
   amount of star-brightness/size tuning alone could pass this crop while
   the ORIGINAL stops stood; the veil had to ease. Both gradients' stop
   POSITIONS/radii were compressed so the same overall shape (strong left,
   clear middle-right, light rise at the far edge) resolves within the
   viewport's left ~40% instead of past it; stop VALUES were tuned down
   only as far as measurement required, converged empirically (real
   screenshots + connected-component count) against two guardrails each
   re-checked live after every step: text contrast behind the actual
   rendered h1 (huge headroom -- 16.7:1 measured vs the 4.5:1 AA floor, see
   README/DESIGN) and bg-hierarchy's "no star brighter than the Spark"
   bound (checked after every iteration; an earlier, more aggressive pass
   that ALSO lowered the radial's own stop VALUES flipped that bound --
   reverted, the radial's ORIGINAL stop values are unchanged here, only its
   size/reach). See hero.js's bgRareBig comment (below, R9 section) for the
   second, safer half of this fix -- more rare-big CANDIDATES rather than a
   brighter ceiling. */
/* 2026-08-16 veil re-tune. The stops above were set in R9 against the OLD
   TSL starfield, which was a brighter, harder, discrete-point field; the
   veil had to fight it to keep type AA. Layer 0 is now editorial-cinema's
   dust, which is softer and dimmer by construction, and the R9 vignette was
   over-damping it: measured on the same right-side crop, this page read
   mean-luminance 10.98 against the reference page's 14.72 (75%), i.e. the
   veil, not the port, was why the background still read as "not noticeable".
   ONLY the right-side / outer-vignette stops are eased (linear 100%
   .30 -> .14, radial .34/.60 -> .18/.34). The LEFT column stops (.78/.18/.0
   across 0-16%) are untouched: that is the band that protects the h1 and
   lede, and it is where the type-contrast measurement lives. */
#veil{
  position:fixed; inset:0; z-index:1; pointer-events:none;
  background:
    linear-gradient(100deg, rgba(9,9,11,.78) 0%, rgba(9,9,11,.18) 7%, rgba(9,9,11,.0) 16%, rgba(9,9,11,.0) 40%, rgba(9,9,11,.14) 100%),
    radial-gradient(360% 190% at 62% 46%, rgba(9,9,11,0) 0%, rgba(9,9,11,.18) 78%, rgba(9,9,11,.34) 100%);
}
#veil::after{
  content:""; position:absolute; inset:0;
  background:var(--bg-0);
  opacity:calc(var(--vs, 0) * .22);
}

/* ---------- Static starfield backdrop (new in this option).
     A pure-CSS field of small blue dots behind the no-3d / reduced-motion
     static composition, so that composition reads as "a sky", matching the
     live scene's own ambient starfield layer. Multiple radial-gradients at
     fixed positions/sizes (deterministic, no JS, zero layout cost); a
     repeating pattern underneath adds fine texture. Crossfades away once
     the live scene boots, same idiom as .fallback-lines/.fallback-spark.
     R9 (2026-08-11): roughly doubled the star count (14 -> 30) and lifted
     size/opacity so the no-3d and reduced-motion fallbacks read at a
     density comparable to the live scene's own R9 pass (BG_COUNT/size/
     brightness all raised there against the flow-refined benchmark) --
     the static composition should not look barer than the scene it stands
     in for. A handful of larger, brighter dots (last four entries) echo
     the live scene's own "occasional bright near mote" accents. */
.static-stars{
  position:fixed; inset:0; z-index:1; pointer-events:none;
  opacity:1; transition:opacity 1.2s var(--ease-out);
  background-image:
    radial-gradient(1.8px 1.8px at 8% 22%, rgba(191,213,255,.92), transparent),
    radial-gradient(1.5px 1.5px at 18% 64%, rgba(148,180,255,.8), transparent),
    radial-gradient(2.2px 2.2px at 27% 12%, rgba(239,246,255,.95), transparent),
    radial-gradient(1.4px 1.4px at 36% 48%, rgba(120,155,240,.7), transparent),
    radial-gradient(1.7px 1.7px at 47% 78%, rgba(191,213,255,.85), transparent),
    radial-gradient(2px 2px at 58% 30%, rgba(239,246,255,.92), transparent),
    radial-gradient(1.4px 1.4px at 68% 58%, rgba(120,155,240,.7), transparent),
    radial-gradient(1.6px 1.6px at 74% 15%, rgba(148,180,255,.8), transparent),
    radial-gradient(1.8px 1.8px at 83% 68%, rgba(191,213,255,.88), transparent),
    radial-gradient(1.4px 1.4px at 91% 40%, rgba(120,155,240,.65), transparent),
    radial-gradient(1.5px 1.5px at 12% 88%, rgba(148,180,255,.7), transparent),
    radial-gradient(1.7px 1.7px at 63% 90%, rgba(191,213,255,.8), transparent),
    radial-gradient(1.2px 1.2px at 40% 6%, rgba(120,155,240,.65), transparent),
    radial-gradient(1.2px 1.2px at 95% 82%, rgba(148,180,255,.65), transparent),
    radial-gradient(1.3px 1.3px at 3% 45%, rgba(148,180,255,.68), transparent),
    radial-gradient(1.6px 1.6px at 22% 34%, rgba(239,246,255,.8), transparent),
    radial-gradient(1.1px 1.1px at 31% 92%, rgba(120,155,240,.6), transparent),
    radial-gradient(1.4px 1.4px at 44% 20%, rgba(191,213,255,.75), transparent),
    radial-gradient(1.2px 1.2px at 52% 55%, rgba(120,155,240,.62), transparent),
    radial-gradient(1.5px 1.5px at 61% 8%, rgba(148,180,255,.75), transparent),
    radial-gradient(1.3px 1.3px at 70% 78%, rgba(191,213,255,.72), transparent),
    radial-gradient(1.1px 1.1px at 79% 4%, rgba(120,155,240,.58), transparent),
    radial-gradient(1.4px 1.4px at 87% 55%, rgba(148,180,255,.72), transparent),
    radial-gradient(1.2px 1.2px at 96% 18%, rgba(191,213,255,.65), transparent),
    radial-gradient(1.3px 1.3px at 15% 6%, rgba(120,155,240,.6), transparent),
    radial-gradient(1.1px 1.1px at 55% 96%, rgba(148,180,255,.6), transparent),
    radial-gradient(3px 3px at 78% 42%, rgba(239,246,255,1), transparent),
    radial-gradient(2.6px 2.6px at 5% 70%, rgba(239,246,255,.95), transparent),
    radial-gradient(2.8px 2.8px at 34% 82%, rgba(224,236,255,.9), transparent),
    radial-gradient(2.5px 2.5px at 89% 90%, rgba(224,236,255,.88), transparent);
  background-repeat:no-repeat;
  background-size:100% 100%;
}
body.js-3d .static-stars{ opacity:0; }

/* ---------- The sixteen role names: ONE list, scene geometry from first
     paint. The pre-scene names are deliberately hidden (see R36 below), so
     reserving their final fixed viewport does not reveal a placeholder. It
     prevents the traced #roles expansion when body.js-3d arrives; each name
     is then placed every frame from its projected 3D anchor plus its own
     slow idle wander. Interactive scene semantics remain role="button",
     tabindex and aria-pressed. */

#roles{
  list-style:none; margin:0; padding:0;
  position:fixed; inset:0; z-index:3;
  overflow:hidden;
  pointer-events:none;
}
.role-name{
  position:absolute;
  font-size:clamp(11px, .95vw + 6px, 17px);
  font-weight:600; letter-spacing:.01em; white-space:nowrap;
  color:var(--role-c);
  transform:translate(-50%,-50%);
  pointer-events:auto; cursor:pointer;
  text-shadow:
    0 0 14px rgba(var(--role-rgb), calc(.28 + var(--mg,0) * .6)),
    0 1px 8px rgba(9,9,11,.9);
  transition:none;
}
.role-name:focus-visible{ outline-offset:6px; border-radius:6px; }
/* data-family selectors kept (future use) — all three resolve to the same
   blue token today; no visual family distinction in this option. */
.role-name[data-family="craft"],
.role-name[data-family="production"],
.role-name[data-family="market"]{ --role-c:#60a5fa; --role-rgb:96,165,250; }

/* Fallback constellation placement (matches the hard-coded SVG lines).
   R36 (2026-08-23, Fred, staged-preview read): the static hub-and-spoke
   placeholder read as broken mock content, and his call is that a
   placeholder shows a counter or NOTHING -- nothing chosen. Outside scene
   mode (pre-boot, no-3d, no-JS) the names are fully hidden; visibility
   also removes their tab stops and pointer targets, so no invisible
   role="button" stops remain. The --fx/--fy geometry is kept as the
   record matching the (also retired) SVG lines. Scene mode is untouched:
   js-3d flips before the first projectNames() frame, whose per-frame
   inline opacity takes over the moment the rule stops matching. */
body:not(.js-3d) .role-name{ left:var(--fx); top:var(--fy); opacity:0; visibility:hidden; }

/* Scene mode: fixed-viewport, JS transforms; depth buckets.
   overflow:hidden clips names whose centre has rotated past the edge-fade
   zone so their off-screen bounding box never inflates document.body.scrollWidth
   (inherited fix: position:fixed descendants are excluded from
   documentElement's scrollWidth but still counted toward body's in
   Chromium). */
body.js-3d #roles{ position:fixed; inset:0; overflow:hidden; }
/* Repair (containing-block bug, carried over): a transformed ancestor
   becomes the containing block for position:fixed descendants, so
   .net-box's own centering transform would otherwise hijack #roles's fixed
   positioning in scene mode. .net-box has zero visual role in scene mode
   (fallback layers fade above), so its footprint is collapsed to a single
   point. */
body.js-3d .net-box{
  transform:none;
  top:0; left:0; right:auto; bottom:auto;
  width:1px; height:1px;
}
body.js-3d .role-name{
  left:0; top:0; will-change:transform,opacity;
  color:color-mix(in srgb, var(--role-c) calc(100% - var(--mg,0) * 55%), #ffffff calc(var(--mg,0) * 55%));
}
body.js-3d .role-name.l2{ text-shadow:0 0 10px rgba(var(--role-rgb),.22), 0 1px 8px rgba(9,9,11,.9); }
body.js-3d .role-name.l3{ filter:blur(1.2px); }
body.js-3d .role-name.is-near{ filter:none; }
body.js-3d .role-name.is-selected{
  filter:none;
  text-shadow:0 0 22px rgba(var(--role-rgb), .9), 0 1px 8px rgba(9,9,11,.95);
}

/* R30/R32: rotating lesser-known titles. R32 (Fred: once visible "they
   should not behave differently from the other names") retires the quieter
   decorative styling: the two slot elements now carry BOTH classes
   (.role-name .role-extra) so typography, colour token, --mg proximity
   mix, depth buckets (l1/l2/l3), cursor, focus ring and is-selected all
   come from the .role-name rules above, byte-identical to the canon
   names. .role-extra remains as a marker + the dead-slot floor below.
   Interactivity while dead is gated inline by JS (pointer-events:none,
   tabindex=-1, aria-hidden between rotations). Scene mode only (created
   by JS after boot); they never exist in the no-3d fallback. */
.role-extra{
  opacity:0; /* boots invisible until the first spawn's fade-in */
}

/* ---------- Header ---------- */

.site-header{
  position:fixed; top:0; left:0; right:0; z-index:40;
  display:flex; align-items:center; justify-content:space-between;
  padding:18px clamp(20px,4vw,44px);
  background:linear-gradient(rgba(9,9,11,.72), rgba(9,9,11,0));
}
.brand img{ width:118px; height:auto; }
.header-actions{ display:flex; gap:10px; align-items:center; }

.btn{
  display:inline-flex; align-items:center; justify-content:center; gap:8px;
  padding:11px 20px; border-radius:999px;
  font-size:14.5px; font-weight:600; text-decoration:none;
  transition:transform .18s var(--ease-swift), background .18s var(--ease-swift),
             border-color .18s var(--ease-swift), box-shadow .18s var(--ease-swift);
}
.btn-primary{
  background:var(--accent); color:#fff;
  box-shadow:0 4px 24px rgba(37,99,235,.35), inset 0 1px 0 rgba(255,255,255,.16);
}
.btn-primary:hover{ background:#3b82f6; transform:translateY(-1px); }
.btn-secondary{
  background:var(--glass); color:var(--fg-1);
  border:1px solid var(--glass-brd);
  backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
}
.btn-secondary:hover{ border-color:rgba(148,163,255,.35); transform:translateY(-1px); }
.btn-ghost{ background:transparent; color:var(--fg-2); }
.btn-ghost:hover{ color:var(--fg-1); }

/* ---------- Hero stage ---------- */

.stage{
  position:relative; min-height:100svh;
  display:flex; align-items:center;
}
.shell{ width:100%; max-width:var(--maxw); margin:0 auto; padding:0 clamp(20px,5vw,48px); }

.hero-copy{
  position:relative; z-index:3; max-width:560px;
  padding-top:8vh;
  will-change:transform,opacity;
  /* pointer-events:none + re-enabled on .btn only: a dimmed role-name that
     idle-wanders (or, in scene mode, simply projects) into this text
     block's bounding box must stay genuinely clickable -- decorative copy
     text must never win hit-testing over an interactive control it happens
     to sit in front of on screen. Caught live via a real click(): a click
     at a dimmed name's own rendered centre was landing on this block's H1/
     P.lede instead of the name (2026-08-06 self-test). */
  pointer-events:none;
}
.hero-copy .btn{ pointer-events:auto; }
.eyebrow{
  font-size:13px; font-weight:600; letter-spacing:.16em; text-transform:uppercase;
  color:var(--fg-3); margin-bottom:18px;
}
.hero-copy h1{
  font-size:clamp(2.6rem, 5.6vw + .6rem, 5.1rem);
  line-height:1.03; letter-spacing:-.032em;
}
.accent-text{
  background:linear-gradient(92deg, var(--accent-2), var(--accent-3));
  -webkit-background-clip:text; background-clip:text; color:transparent;
}
.hero-copy .lede{
  margin-top:22px; max-width:46ch;
  font-size:clamp(15.5px, .5vw + 12px, 18px); line-height:1.65; color:var(--fg-2);
  text-shadow:0 1px 10px rgba(9,9,11,.85);
}
.hero-cta{ margin-top:34px; display:flex; gap:13px; flex-wrap:wrap; }
.hero-cta .btn{ padding:15px 28px; font-size:15.5px; }
.hero-trust{
  margin-top:26px; display:flex; align-items:center; gap:10px;
  font-size:13.5px; color:var(--fg-3);
}
.trust-dot{
  width:7px; height:7px; border-radius:50%; background:#22c55e;
  box-shadow:0 0 10px rgba(34,197,94,.7); flex:none;
}

/* R38 (Fred, 2026-08-30): the cue rail is now native Webflow DOM -- its
   position, z-index, flex layout, gaps, type register, color, and visual
   line geometry/background live in Designer styles (Fred-owned). This file
   retains the body-class gates, media wraps, <=760px hide, pointer-events,
   and ::after runners with their structural containment/clipping; the R37
   hit-testing contract keeps this decoration from ever winning a click. */
.cue-rail{ pointer-events:none; }
.scroll-cue{ pointer-events:none; }
/* R7d (Fred, 2026-08-30): the scroll cue retires the moment the page first
   scrolls (hero.js sets body.hero-scroll-done once, every mode) -- same
   retire vocabulary as body.hero-drag-done below. */
body.hero-scroll-done .scroll-cue{
  opacity:0; visibility:hidden;
  transition:opacity .6s var(--ease-out), visibility 0s .6s;
}
/* Cue hold (2026-09-04): on the normal tier the Drag/Scroll cues stay
   parked, lines paused, from tier selection until the Spark's own first
   frame (hero.js sets/clears body.hero-cues-hold). Two reasons: the cues
   advertise interactions that do not exist until the scene is live, and
   the GPU process's first compile freezes every browser frame for up to
   ~0.9s -- with nothing moving on screen that freeze is invisible. */
/* (placed AFTER the base .cue-rail rule above on purpose: the native
   build's test reads the FIRST `.cue-rail{` block as the Designer-migrated
   base rule and requires it to carry pointer-events:none only.) */
body .cue-rail{ transition:opacity .6s var(--ease-out); }
body.hero-cues-hold .cue-rail{ opacity:0; }
body.hero-cues-hold .ss26-scroll-line::after,
body.hero-cues-hold .ss26-drag-line::after{ animation-play-state:paused; }
/* Structural safety: contain each absolute runner instead of resolving it
   against .stage; visual geometry remains Designer-owned. */
.ss26-scroll-line,
.ss26-drag-line{ position:relative; overflow:hidden; }
.ss26-scroll-line::after{
  content:""; position:absolute; left:0; top:-30%;
  width:100%; height:30%; background:var(--accent-2);
  animation:cue-drop 2.8s var(--ease-in-out) infinite;
}
@keyframes cue-drop{ 0%{ top:-30%; } 60%,100%{ top:110%; } }

/* R37: the drag cue -- the scroll cue's sibling for the R35 drag-orbit.
   Same 1px-line vocabulary rotated to the horizontal: scroll's segment
   drops (the page continues down), drag's segment tugs right then left
   (the organism turns under a lateral pull). Visible ONLY while the
   interaction it advertises exists:
     - base display:none; the (pointer: fine) wrap mirrors hero.js's own
       pointerFine gate (line ~118) -- R35 is mouse/pen only, touch keeps
       native scroll, so a coarse-primary device never sees the cue;
     - opacity gated on body.js-3d, which the LIVE boot branch sets and
       applyNo3D() removes -- pre-boot, ?no3d, probeGL fail, the 5s
       watchdog, module fail() and ladder rung 4 all lack the class;
     - the reduced-motion block at the end of this file removes it where
       R35 deliberately renders a fixed still (no drag exists there);
     - body.hero-drag-done (set by hero.js on the first real drag past the
       existing 6px slop) retires it for the page life. No storage. */
.drag-cue{ display:none; }
@media (pointer: fine){
  .drag-cue{
    display:flex;
    pointer-events:none;
    opacity:0; transition:opacity .8s var(--ease-out);
  }
  body.js-3d .drag-cue{ opacity:1; }
  /* R8 (Fred, 2026-08-30): the Designer canvas never runs hero.js, so
     body.js-3d never sets and the drag cue rendered invisible on the
     canvas -- Fred could not see the element he now owns there.
     html.wf-design-mode exists ONLY on the Webflow Designer canvas, so
     this is inert on every published and prototype surface. */
  html.wf-design-mode .drag-cue{ opacity:1; }
  /* R8d (Fred, 2026-08-30, "click drag also to stop showing after first
     scrolling down from hero section"): the drag cue now retires on the
     SAME first-scroll signal as the scroll cue, not only on the first
     drag. Placed AFTER body.js-3d .drag-cue{opacity:1} on purpose --
     equal specificity, later rule wins the tie. */
  body.hero-scroll-done .drag-cue{
    opacity:0; visibility:hidden;
    transition:opacity .6s var(--ease-out), visibility 0s .6s;
  }
  body.js-3d.hero-drag-done .drag-cue{
    opacity:0; visibility:hidden;
    transition:opacity .6s var(--ease-out), visibility 0s .6s;
  }
  .ss26-drag-line::after{
    content:""; position:absolute; top:0; left:35%;
    width:30%; height:100%; background:var(--accent-2);
    animation:cue-tug 4.6s var(--ease-in-out) infinite;
  }
}
@keyframes cue-tug{
  0%,100%{ left:35%; }
  22%{ left:70%; }
  46%{ left:35%; }
  68%{ left:0%; }
}

/* R9f (Fred, 2026-08-30, Designer canvas at Mobile (L): '?' over a screen of
   dead sky between the hero CTAs and Privacy): the stage reserves a full
   viewport (min-height:100svh; min(100svh,190vw)/min(100svh,120vw) on the
   narrow breakpoints) for the 3D scene, and the Designer canvas never runs
   hero.js, so while EDITING that box renders as empty sky on every
   breakpoint and Fred scrolls through it. Collapse the stage to its copy on
   the CANVAS ONLY, and neutralise the runway's hero-overlap negative
   margins, which exist solely to tuck the runway under the live scene's
   tail. html.wf-design-mode exists only on the Designer canvas, so both
   rules are inert on every published and prototype surface; they sit
   OUTSIDE the (pointer:fine) wrap above because they gate on the canvas,
   not the pointer. The min-height:0 half also outweighs the media-query
   stage caps below by specificity (0,1,1 vs 0,0,1). */
html.wf-design-mode .stage{ min-height:0; padding:96px 0 48px; }
html.wf-design-mode .runway{ margin-top:0; padding-top:0; }

/* ---------- Runway ---------- */

/* padding-top gives the scroll hand-off room to breathe: the hero copy and
   names are still fading through the second half of .stage's own scroll
   distance, so the first runway heading needs to land AFTER that fade
   completes rather than right at the .stage/.runway seam. */
.runway{ position:relative; z-index:2; padding-top:10vh; } /* R7c (Fred, 2026-08-30, "too much space"): 28vh -> 16vh; R8 (same day, "space ... still with some bug", desktop no longer exempt): 16vh -> 10vh */
/* R8d (Fred, 2026-08-30, fourth desktop round: "measure the blank space
   visually don't assume. fix it"): pixel-scan of the published page measured
   493 blank rows between the CTA and the privacy eyebrow at 1920x1080 --
   ~295px of that is the stage's own 100svh tail below the centred copy,
   which no padding cut can reach (the R7d iPad lesson, desktop edition).
   Padding cuts are exhausted; the mechanism change is OVERLAP: pull the
   runway up into the stage tail so the privacy eyebrow lands just below
   the fold (~2vh + section padding). Desktop only -- <=1000px seams were
   tuned separately and .runway's z-index:2 already paints runway content
   above the stage box; drag-orbit listens on window with a target gate,
   so the overlapped strip cannot block it (hero.js ~3837). */
@media (min-width:1001px){
  .runway{ margin-top:-14vh; padding-top:8vh; }
}
.runway-section{ padding:14vh 0; }
.runway-section .shell{ max-width:980px; }
.eyebrow--num{ display:flex; align-items:center; gap:12px; }
.eyebrow-num{
  font-size:12px; color:var(--accent-2); border:1px solid rgba(96,165,250,.3);
  border-radius:999px; padding:3px 9px;
}
.runway-section h2{ font-size:clamp(1.8rem, 2.6vw + .6rem, 2.9rem); margin-top:6px; }
.runway-lede{
  margin-top:16px; max-width:58ch; color:var(--fg-2); line-height:1.7;
  font-size:clamp(15px,.4vw + 12px,17px);
}
.runway-grid{
  margin-top:38px; display:grid; gap:18px;
  grid-template-columns:repeat(3, 1fr);
}
.runway-card{
  background:var(--glass); border:1px solid var(--glass-brd);
  border-radius:var(--radius-lg); padding:26px 24px;
  backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);
  transition:transform .3s var(--ease-out), border-color .3s var(--ease-out);
}
.runway-card:hover{ transform:translateY(-3px); border-color:rgba(148,163,255,.32); }
.runway-card h3{ font-size:16.5px; margin-bottom:10px; }
.runway-card p{ color:var(--fg-2); font-size:14.5px; line-height:1.65; }

.runway-steps{
  list-style:none; margin:38px 0 0; padding:0;
  display:grid; gap:14px;
}
.runway-steps li{
  display:flex; gap:20px; align-items:flex-start;
  background:var(--glass); border:1px solid var(--glass-brd);
  border-radius:var(--radius-md); padding:22px 24px;
  backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);
}
.step-num{
  font-size:13px; font-weight:600; color:var(--accent-2);
  border:1px solid rgba(96,165,250,.3); border-radius:999px;
  padding:5px 11px; flex:none; margin-top:2px;
}
.runway-steps h3{ font-size:16px; margin-bottom:6px; }
.runway-steps p{ color:var(--fg-2); font-size:14.5px; line-height:1.6; }

.final-cta{ padding:16vh 0 12vh; text-align:center; }
.final-cta h2{ font-size:clamp(1.9rem, 3vw + .6rem, 3.1rem); }
.final-cta > .shell > p{
  margin:18px auto 0; max-width:52ch; color:var(--fg-2); line-height:1.7;
}
.final-cta-actions{ margin-top:34px; display:flex; gap:13px; justify-content:center; flex-wrap:wrap; }
.final-cta-actions .btn{ padding:15px 28px; font-size:15.5px; }

.site-footer{
  border-top:1px solid var(--hairline);
  padding:34px 0 44px; text-align:center;
  color:var(--fg-3); font-size:13px; line-height:1.8;
}

/* ---------- Responsive composition ---------- */

@media (max-width:1000px){
  .net-box{ width:min(72vw,600px); right:0; }
  /* R6b (Fred, 2026-08-30): the hero-to-runway seam ran to ~629px of blank
     space on iPad portrait (820x1180) -- most of it this 28vh desktop
     hand-off gap, sized for the wide viewport's slower name-fade travel
     distance. Narrower viewports don't need as much room. */
  .runway{ padding-top:10vh; margin-top:-14vh; } /* R7c: 18vh -> 10vh; R8e
     (Fred, 2026-08-30, "still bad on ipad"): published-pixel band CTA ->
     privacy eyebrow measured 523px; same overlap mechanism as desktop.
     The stage is already width-capped below (984px at 820), so the eyebrow
     peeking into the first screen fills dead sky, it does not fight a
     full-viewport hero. */
  /* R7d (Fred, 2026-08-30, third "too much space" round): the padding cuts
     alone could not close it because the bulk on iPad PORTRAIT is the
     stage's own 100svh tail -- the centred copy leaves ~half a 1180px-tall
     stage empty below it before any runway padding starts. Cap the stage
     against WIDTH so tall-narrow viewports stop inheriting a full-height
     hero; landscape tablets (svh < 120vw) are untouched. */
  .stage{ min-height:min(100svh, 120vw); }
  /* R8e: the runway overlap above lets the privacy eyebrow peek into the
     first screen on capped-stage viewports, where it lands over UNDIMMED
     scene names (seamDim only fires on scroll). Mute that zone with the
     house veil mechanism -- the base gradients plus one added bottom rise;
     the <=760px block below still replaces the whole background. */
  #veil{
    background:
      linear-gradient(100deg, rgba(9,9,11,.78) 0%, rgba(9,9,11,.18) 7%, rgba(9,9,11,.0) 16%, rgba(9,9,11,.0) 40%, rgba(9,9,11,.14) 100%),
      linear-gradient(rgba(9,9,11,0) 0%, rgba(9,9,11,0) 72%, rgba(9,9,11,.55) 92%, rgba(9,9,11,.66) 100%),
      radial-gradient(360% 190% at 62% 46%, rgba(9,9,11,0) 0%, rgba(9,9,11,.18) 78%, rgba(9,9,11,.34) 100%);
  }
}
@media (max-width:760px){
  /* R7d: the <=1000px 120vw width-cap must NOT reach phones -- 120vw of a
     390px phone (468px) would crush the copy-top/constellation-below
     composition. R8e (Fred, 2026-08-30, "still bad ... mobile versions
     specially"): published-pixel band CTA -> privacy eyebrow measured
     561px, most of it the full-height stage's sky between the copy and
     the constellation. A GENTLER width cap (190vw = 741px at 390) keeps
     the mobile composition breathing while pulling privacy up; paired
     with a small runway overlap below. */
  .stage{ align-items:flex-start; min-height:min(100svh, 190vw); }
  .hero-copy{ padding-top:16vh; max-width:100%; }
  /* Mobile is a first-class composition: constellation moves BELOW the copy,
     centred; the scene's group offset mirrors this (portrait branch in hero.js). */
  .net-box{
    width:min(96vw,480px); right:auto; left:50%; top:auto; bottom:-4vh;
    transform:translateX(-50%); aspect-ratio:1/1.05;
  }
  .cue-rail{ display:none; }
  .runway-grid{ grid-template-columns:1fr; }
  .runway-section{ padding:11vh 0; }
  /* R6b continued: mobile measured ~444px of blank seam; tighten further. */
  .runway{ padding-top:3vh; margin-top:-6vh; } /* R7c 10->7vh; R7d 7->5vh;
     R8e: 5->3vh + a small overlap, paired with the 190vw stage cap above */
  #veil{
    background:
      linear-gradient(rgba(9,9,11,.9) 0%, rgba(9,9,11,.55) 34%, rgba(9,9,11,.12) 55%, rgba(9,9,11,.38) 100%),
      radial-gradient(130% 70% at 50% 72%, rgba(9,9,11,0) 0%, rgba(9,9,11,.5) 82%, rgba(9,9,11,.78) 100%);
  }
}
/* R9g (Fred, 2026-08-30, "don't reserve so much when in mobile landscape"):
   the stage's viewport-height reservation is sized for surfaces where the
   scene fills it. Two surfaces where that reservation is oversized:
   (1) Webflow's Mobile Landscape breakpoint band (479-767px) as seen in the
   Designer PREVIEW, whose canvas iframe is ~900px tall, so 100svh reserves a
   whole tall canvas of sky (registered scripts are inert in preview, so the
   scene never draws there). A hard px ceiling caps the reservation at the
   hero copy's own height plus breathing room; real devices in that band are
   landscape phones whose svh (320-430) sits under the ceiling anyway.
   (2) Real short-landscape phones at any width: trim the reservation below
   the full screen so the page starts moving sooner. min-height only -- copy
   taller than the cap still grows the stage. Placed AFTER the 760px block so
   source order wins the overlap band; desktop, iPad and portrait phones
   match neither query. */
@media (min-width:479px) and (max-width:767px){
  /* R9h (Fred, 2026-08-30, "now there's not enough distance"): the 480px
     ceiling put the privacy eyebrow ~27px under the reserved box in preview
     (the runway's hero-overlap negative margin, sized for tucking under the
     live scene's tail, was still firing against a capped stage). Ceiling up
     to 560px, and the band gets its own hand-off spacing: no overlap, a
     modest padding instead. */
  .stage{ min-height:min(100svh, 560px); }
  .runway{ margin-top:0; padding-top:6vh; }
}
@media (max-height:500px) and (orientation:landscape){
  .stage{ min-height:min(94svh, 560px); } /* R9h: 88svh -> 94svh */
}
@media (max-width:480px){
  .brand img{ width:96px; }
  .header-actions .btn-ghost{ display:none; }
}

/* CLS R18 (2026-08-31): a cold trace attributed a 0.17078 layout-shift
   entry to #net-box, collapsing it from 760x644 to this active-scene 1x1
   geometry when body.js-3d arrived. The fallback children and names are
   already hidden before scene mode, so start the inert wrapper in the exact
   scene geometry instead of reserving then collapsing an invisible box. This
   also leaves no transformed ancestor to change #roles's fixed containing
   block. */
.net-box{
  position:absolute; z-index:2;
  top:0; left:0; right:auto; bottom:auto;
  width:1px; height:1px;
  transform:none;
  pointer-events:none;
}
/* R36: retired with the static role constellation (see the role-name
   fallback block) -- the lines-to-orb mock must never paint in any
   pre-scene state, so display:none rather than the old crossfade, and the
   js-3d opacity rule it made redundant is gone. The detail rules below
   stay as the record of the shape. */
.fallback-lines, .fallback-spark{
  display:none;
}
.fallback-lines{ width:100%; height:100%; }
.fallback-lines line{ stroke:rgba(96,165,250,.16); stroke-width:.35; }
.fallback-spark::before{
  content:""; position:absolute; left:50%; top:47%;
  width:34%; aspect-ratio:1; transform:translate(-50%,-50%);
  border-radius:50%;
  background:
    radial-gradient(circle, rgba(239,246,255,.95) 0%, rgba(96,165,250,.55) 14%, rgba(37,99,235,.30) 34%, rgba(30,58,138,.14) 58%, transparent 72%);
  animation:spark-breathe 9.5s var(--ease-in-out) infinite;
}
@keyframes spark-breathe{ 0%,100%{ transform:translate(-50%,-50%) scale(1); } 50%{ transform:translate(-50%,-50%) scale(1.07); } }

/* ---------- R35: drag-orbit active state. Idle cursor deliberately
     unchanged; the standing affordance is the R37 drag cue, not a grab
     cursor. During a live drag the whole page reads grabbing and text
     selection is suppressed so a rotation sweep across the copy cannot
     start a highlight. ---------- */
body.hero-dragging, body.hero-dragging *{ cursor:grabbing !important; }
body.hero-dragging{ user-select:none; -webkit-user-select:none; }

/* ---------- no-3d: static composition is the whole scene ---------- */
body.no-3d #veil{ background:radial-gradient(120% 100% at 60% 40%, rgba(16,16,19,0), rgba(9,9,11,.6) 90%); }
body.no-3d #veil::after{ opacity:0; }

/* ---------- Reduced motion: a designed static composition, not a broken
     page. The scene renders exactly one composed frame (hero.js), every CSS
     loop stops, smooth scroll is native. The static starfield stays put
     (it is already static, nothing to stop). ---------- */
@media (prefers-reduced-motion: reduce){
  html{ scroll-behavior:auto; }
  .fallback-spark::before{ animation:none; }
  .ss26-scroll-line::after{ animation:none; top:30%; }
  .btn, .runway-card{ transition:none; }
  #scene{ transition:none; }
  /* R37: under reduced motion the scene is a fixed designed still and the
     R35 drag-orbit does not exist (hero.js reduced-motion branch: "no
     drag, no user offsets"), so the drag cue's static composed form is
     ABSENCE -- a cue for a dead interaction is a lie. The scroll cue
     differs: scrolling still works, so it stays, parked. .cue-rail
     scoping outranks the pointer-media rule regardless of file order. */
  .cue-rail .drag-cue{ display:none; }
}
