/* ==========================================================================
   ALLIN — fx.css
   Motion layer: scroll reveals, cursor, grain, marquee, and the hero
   ecosystem graph. Loaded after sections.css so it can lean on the tokens.

   Everything here is gated on `html.js`. Without the boot script the page
   renders fully visible and static — a failed script never blanks content.
   ========================================================================== */

/* ---------- Scroll reveal ------------------------------------------------- */
html.js [data-reveal]{
  opacity:0; transform:translateY(24px);
  transition:opacity .85s var(--ease-out) var(--reveal-delay,0ms),
             transform .85s var(--ease-out) var(--reveal-delay,0ms);
  will-change:opacity,transform;
}
html.js [data-reveal].is-revealed{opacity:1;transform:none;will-change:auto}
html.js [data-reveal-group] > *{
  opacity:0; transform:translateY(20px);
  transition:opacity .8s var(--ease-out) var(--reveal-delay,0ms),
             transform .8s var(--ease-out) var(--reveal-delay,0ms);
}
html.js [data-reveal-group].is-revealed > *{opacity:1;transform:none}

/* Character reveal. The chars only become inline-block once the split has
   run, so an un-split heading keeps normal text flow and normal wrapping. */
.split-word{display:inline-block;white-space:nowrap}
.split-char{display:inline-block;will-change:transform,opacity}
html.js [data-split].split-ready .split-char{
  opacity:0; transform:translateY(.55em) rotate(2deg);
  transition:opacity .7s var(--ease-out),transform .7s var(--ease-out);
}
html.js [data-split].split-ready.is-revealed .split-char{opacity:1;transform:none}

html.reduced-motion [data-reveal],
html.reduced-motion [data-reveal-group] > *,
html.reduced-motion [data-split].split-ready .split-char{
  opacity:1 !important;transform:none !important;transition:none !important;
}

/* ---------- Film grain ---------------------------------------------------- */
.grain{
  position:fixed; inset:0; width:100%; height:100%;
  pointer-events:none; z-index:9998;
  opacity:.035; mix-blend-mode:overlay;
}
[data-theme="light"] .grain{opacity:.02}

/* ---------- Cursor -------------------------------------------------------- */
/* Only mounted on fine pointers, and the native cursor is never hidden — a
   custom cursor that disappears over an iframe or a native control is worse
   than no custom cursor at all. */
.cursor{position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:0;transition:opacity .3s}
.cursor.is-active{opacity:1}
.cursor__ring,.cursor__dot{position:absolute;top:0;left:0;border-radius:50%}
.cursor__ring{
  width:34px;height:34px;border:1px solid var(--gold);
  transition:width .28s var(--ease-out),height .28s var(--ease-out),
             background .28s var(--ease-out),border-color .28s;
}
.cursor__dot{width:5px;height:5px;background:var(--gold);transition:opacity .25s}
.cursor.is-hovering .cursor__ring{
  width:52px;height:52px;background:rgba(123,92,255,.12);border-color:var(--gold-soft);
}

/* Over a [data-cursor="view"] panel the ring becomes a labelled white disc in
   mix-blend-mode: difference, which inverts its backdrop and so stays legible
   over any photograph without a contrast check.

   The blend sits on .cursor itself rather than on the ring. .cursor is fixed at
   z-index 9999 and therefore its own stacking context, so a blend declared on a
   child would composite against that empty context and come out plain white.
   Only width, height, background and border-color transition: transform belongs
   to the rAF loop, and animating it from CSS at the same time makes the ring lag
   the pointer by a distance no easing explains. */
.cursor.is-view{mix-blend-mode:difference}
.cursor.is-view .cursor__dot{opacity:0}
.cursor.is-view .cursor__ring{
  width:112px;height:112px;background:#fff;border-color:transparent;
}
.cursor__label{
  position:absolute;top:50%;left:50%;
  transform:translate(-50%,-50%) scale(.5);
  color:#000;font-family:var(--font-head);font-weight:700;
  font-size:.95rem;letter-spacing:.02em;white-space:nowrap;
  opacity:0;transition:opacity .3s,transform .35s var(--ease-out);
}
.cursor.is-view .cursor__label{opacity:1;transform:translate(-50%,-50%) scale(1)}

/* ---------- Scroll progress ----------------------------------------------- */
.scroll-progress{
  position:fixed;top:0;left:0;right:0;height:2px;z-index:9997;
  transform:scaleX(0);transform-origin:0 50%;
  background:linear-gradient(90deg,var(--gold-deep),var(--gold),var(--gold-soft));
}

/* ---------- Marquee ------------------------------------------------------- */
.marquee{overflow:hidden;position:relative;
  -webkit-mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent);
          mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent);
}
.marquee__track{display:flex;width:max-content;will-change:transform}

/* ==========================================================================
   Hero ecosystem graph
   One SVG, no JavaScript. Every wire is a static path plus a duplicate that
   carries a travelling dash; every leaf cycles its course names in place.
   ========================================================================== */
.hero--eco{
  position:relative;overflow:hidden;
  /* Tighter than the default hero: the graph is the payload, so the copy
     above it has to leave the fold before the first branch. */
  padding:calc(var(--nav-h) + clamp(1.4rem,3.5vw,2.6rem)) 0 clamp(2.5rem,5vw,4rem);
}
.hero--eco .hero-copy h1{font-size:clamp(2.2rem,1.5rem + 2.8vw,3.5rem);margin:.9rem 0 1rem}
.hero--eco .hero-copy .lead{margin-bottom:1.5rem}
.hero--eco .hero-copy{max-width:880px;margin-inline:auto;text-align:center}
.hero--eco .hero-copy .lead{margin-inline:auto;max-width:62ch}
.hero--eco .hero-cta{justify-content:center}

/* Soft brand light behind the graph, so the wires read as lit rather than drawn. */
.hero-aura{
  position:absolute;left:50%;top:8%;width:min(1100px,120vw);aspect-ratio:1.6;
  transform:translateX(-50%);pointer-events:none;z-index:0;
  background:radial-gradient(50% 50% at 50% 40%,rgba(123,92,255,.22),transparent 70%);
  filter:blur(10px);
}
.hero--eco .container{position:relative;z-index:1}

/* Width is capped below the container on purpose: the SVG keeps a fixed
   aspect ratio, so every extra pixel of width costs half a pixel of height and
   pushes the first branch past the fold. */
.eco{margin:clamp(1rem,2.6vw,1.9rem) auto 0;max-width:960px}
.eco__cap{
  font-family:var(--font-mono);font-size:var(--fs-xs);letter-spacing:.18em;
  text-transform:uppercase;color:var(--text-mute);
  display:flex;align-items:center;justify-content:center;gap:.9rem;margin-bottom:.75rem;
}
/* Hairlines either side of the caption, drawn in the same colour as the graph
   wires so the label reads as part of the diagram rather than a heading. */
.eco__cap::before,.eco__cap::after{
  content:"";height:1px;flex:0 1 110px;
  background:linear-gradient(90deg,transparent,var(--border));
}
.eco__cap::after{background:linear-gradient(90deg,var(--border),transparent)}
/* The graph keeps a readable minimum width and scrolls sideways on small
   screens. Scaling it down to fit a phone would shrink 12px labels to five. */
.eco__frame{overflow-x:auto;overflow-y:hidden;scrollbar-width:none;padding-bottom:.25rem}
.eco__frame::-webkit-scrollbar{display:none}
.eco__svg{display:block;width:100%;min-width:660px;height:auto;overflow:visible}

.eco__edge{stroke:var(--border);stroke-width:1.1}
/* pathLength="1" normalises every curve to the same length, so one dash
   pattern travels at the same visual speed along a short wire and a long one. */
.eco__spark{
  stroke:var(--gold-soft);stroke-width:1.8;stroke-linecap:round;
  stroke-dasharray:.18 1;stroke-dashoffset:.18;
  animation:eco-travel 2.8s linear infinite;
  animation-delay:calc(var(--i) * .18s);
}

.eco__node rect,.eco__root rect{
  fill:var(--surface);stroke:var(--border);stroke-width:1.2;
  transition:fill .3s var(--ease),stroke .3s var(--ease);
}
.eco__root rect{
  stroke:var(--gold);stroke-width:1.5;
  filter:drop-shadow(0 0 22px rgba(123,92,255,.35));
}
.eco__root-name{
  font-family:var(--font-head);font-size:23px;font-weight:700;
  fill:var(--heading);letter-spacing:.16em;
}
.eco__node--div rect{stroke:rgba(123,92,255,.36)}
.eco__label{font-family:var(--font-mono);font-size:13px;fill:var(--text)}
.eco__node--leaf .eco__label{font-size:12px}
.eco__kind{
  font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;
  text-transform:uppercase;fill:var(--text-mute);
}

a.eco__node{cursor:pointer}
a.eco__node:hover rect{fill:var(--surface-2);stroke:var(--gold)}
a.eco__node:hover .eco__label{fill:var(--heading)}
a.eco__node:focus-visible{outline:none}
a.eco__node:focus-visible rect{stroke:var(--gold);stroke-width:2}

/* Course names share one slot per leaf and hand off to each other. Cycle
   lengths differ, but every loop is a multiple of 9s, so the whole board stays
   in step. Five names get an 18s loop so each still holds for 3.6s. */
.eco__swap{
  opacity:0;animation-duration:9s;animation-timing-function:var(--ease-in-out);
  animation-iteration-count:infinite;
}
.eco__swap--3{animation-name:eco-swap-3;animation-delay:calc(var(--i) * 3s)}
.eco__swap--2{animation-name:eco-swap-2;animation-delay:calc(var(--i) * 4.5s)}
.eco__swap--5{animation-name:eco-swap-5;animation-duration:18s;animation-delay:calc(var(--i) * 3.6s)}

@keyframes eco-travel{from{stroke-dashoffset:.18}to{stroke-dashoffset:-1}}
@keyframes eco-swap-3{
  0%{opacity:0;transform:translateY(7px)}
  4%,28%{opacity:1;transform:none}
  33.33%,100%{opacity:0;transform:translateY(-7px)}
}
@keyframes eco-swap-2{
  0%{opacity:0;transform:translateY(7px)}
  5%,44%{opacity:1;transform:none}
  50%,100%{opacity:0;transform:translateY(-7px)}
}
@keyframes eco-swap-5{
  0%{opacity:0;transform:translateY(7px)}
  2.5%,17%{opacity:1;transform:none}
  20%,100%{opacity:0;transform:translateY(-7px)}
}

html.reduced-motion .eco__spark{animation:none;opacity:0}
html.reduced-motion .eco__swap{animation:none;opacity:0}
html.reduced-motion .eco__swap[style*="--i: 0"]{opacity:1}

/* ---------- Hero stats ---------------------------------------------------- */
.hero-stats{
  display:flex;flex-wrap:wrap;justify-content:center;gap:.75rem;
  margin-top:clamp(1.5rem,4vw,2.5rem);
}
.hero-stats .hs{
  display:flex;align-items:center;gap:.7rem;text-align:left;
  padding:.7rem 1.1rem;border:1px solid var(--border);border-radius:999px;
  background:color-mix(in srgb,var(--surface) 80%,transparent);
  backdrop-filter:blur(8px);
}
.hero-stats .ico{display:grid;place-items:center;width:26px;height:26px;color:var(--gold)}
.hero-stats .ico svg{width:20px;height:20px}
.hero-stats .b-num{font-family:var(--font-head);font-weight:700;color:var(--heading);line-height:1.1}
.hero-stats .b-cap{font-size:var(--fs-xs);color:var(--text-mute)}

@media (max-width:640px){
  .eco__cap{margin-bottom:.6rem}
  .hero-stats .hs{padding:.6rem .9rem}
}

/* ==========================================================================
   Premium surface utilities
   ========================================================================== */

/* --- Border beam ----------------------------------------------------------
   A lit arc that travels the card's outline. Built from a conic gradient
   rotated by an @property angle — a plain custom property is a string to the
   animation engine and would jump from 0deg to 360deg instead of sweeping. */
@property --beam-angle{syntax:"<angle>";initial-value:0deg;inherits:false}

.beam,
.cat-card,.why-card,.prog-card,.vcard,.fac-card{position:relative;isolation:isolate}
.beam::after,
.cat-card::after,.why-card::after,.prog-card::after,.vcard::after,.fac-card::after{
  content:"";position:absolute;inset:0;border-radius:inherit;padding:1.4px;
  background:conic-gradient(from var(--beam-angle),
    transparent 0 62%, var(--gold-soft) 76%, var(--gold) 84%, transparent 92%);
  /* Two masks, subtracted: the fill is punched out and only the padding ring
     survives, so the gradient shows as a border rather than a wash. */
  -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);
          mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);
  -webkit-mask-composite:xor;mask-composite:exclude;
  opacity:0;transition:opacity .45s var(--ease-out);pointer-events:none;
}
.beam:hover::after,.beam:focus-within::after,
.cat-card:hover::after,.cat-card:focus-visible::after,
.why-card:hover::after,.prog-card:hover::after,
.vcard:hover::after,.fac-card:hover::after{
  /* The spin only runs under the pointer. Left on permanently it would repaint
     a conic gradient every frame for every card on the page, visible or not. */
  opacity:1;animation:beam-spin 5s linear infinite;
}
@keyframes beam-spin{to{--beam-angle:360deg}}

/* --- Spotlight ------------------------------------------------------------
   Pointer position arrives as --mx/--my from initSpotlight; the highlight
   itself is CSS, so it composes with whatever hover transform the card has. */
[data-spotlight],
.step-card{position:relative;isolation:isolate}
[data-spotlight]::before,
.cat-card::before,.why-card::before,.vcard::before,.fac-card::before,.step-card::before{
  content:"";position:absolute;inset:0;border-radius:inherit;z-index:-1;
  background:radial-gradient(340px circle at var(--mx,50%) var(--my,0%),
    rgba(123,92,255,.16),transparent 62%);
  opacity:0;transition:opacity .4s var(--ease-out);pointer-events:none;
}
[data-spotlight]:hover::before,
.cat-card:hover::before,.why-card:hover::before,
.vcard:hover::before,.fac-card:hover::before,.step-card:hover::before{opacity:1}

/* --- Shiny text -----------------------------------------------------------
   A highlight sweeps the glyphs every few seconds. background-clip:text takes
   over the element's whole background, so this belongs on plain accent text,
   never on something that carries a pill or card fill. The base colour is held
   at partial alpha because opaque glyphs would sit on top of the sweep and
   hide it. */
.shiny{
  --shiny-width:7ch;
  display:inline-block;
  color:color-mix(in srgb,var(--gold) 76%,transparent);
  background:linear-gradient(100deg,transparent calc(50% - var(--shiny-width)/2),
    var(--gold-soft),transparent calc(50% + var(--shiny-width)/2)) no-repeat;
  background-size:calc(100% + var(--shiny-width) * 2) 100%;
  -webkit-background-clip:text;background-clip:text;
  animation:shiny-text 7s var(--ease-in-out) infinite;
}
@keyframes shiny-text{
  0%,86%,100%{background-position:calc(-1 * var(--shiny-width)) 0}
  30%,56%{background-position:calc(100% + var(--shiny-width)) 0}
}

html.reduced-motion .beam::after,
html.reduced-motion .cat-card::after,html.reduced-motion .why-card::after,
html.reduced-motion .prog-card::after,html.reduced-motion .vcard::after,
html.reduced-motion .fac-card::after,
html.reduced-motion .shiny{animation:none}
html.reduced-motion .shiny{background:none;color:var(--gold)}
html.reduced-motion .cat-card{transition:none}

/* --- Category filter swap --------------------------------------------------
   The tab script fades a card out, waits for the transition, then flips
   [hidden] and fades the new set in. Plain instant hidden-toggling read as a
   jump cut; this is the same opacity/translate pattern as scroll reveal, just
   driven by a click instead of the viewport. */
.cat-card.is-hiding{opacity:0;transform:translateY(6px);pointer-events:none}

/* --- Button shine ---------------------------------------------------------
   A single specular pass across the primary CTA on hover. The band is parked
   off the left edge and animates past the right, so it plays once per hover
   rather than looping in the corner of the user's eye. */
.btn-gold{position:relative;overflow:hidden;isolation:isolate}
.btn-gold::after{
  content:"";position:absolute;top:0;left:-140%;width:55%;height:100%;
  background:linear-gradient(100deg,transparent,rgba(255,255,255,.42),transparent);
  transform:skewX(-18deg);pointer-events:none;
}
.btn-gold:hover::after,.btn-gold:focus-visible::after{animation:btn-shine .8s var(--ease-out)}
@keyframes btn-shine{to{left:160%}}
html.reduced-motion .btn-gold::after{animation:none}

/* --- Cross-document page transitions --------------------------------------
   The site is 36 separate documents, so every nav click used to repaint the
   whole window white before the next page drew. This opts the whole site into
   the browser's own transition between documents: same-origin link clicks and
   back/forward now cross-fade instead of flashing.

   Progressive enhancement in the strictest sense — a browser without the API
   ignores the at-rule and navigates exactly as before. Both the outgoing and
   the incoming document must carry this rule, which they do: fx.css is loaded
   on every page.

   Easing is written out rather than referenced through --ease-* because these
   pseudo-elements live in a separate snapshot tree, and a token that fails to
   resolve there silently drops the whole animation. */
@view-transition{navigation:auto}

/* Elements that are identical on every page get their own snapshot so they
   hold still while the page body moves. Without a name they are captured as
   part of the root and cross-fade against themselves, which reads as a
   flicker on exactly the furniture that should feel nailed down. */
.nav{view-transition-name:allin-nav}
.scroll-progress{view-transition-name:allin-progress}

::view-transition-old(root){animation:vt-leave .26s cubic-bezier(.65,0,.35,1) both}
::view-transition-new(root){animation:vt-enter .42s cubic-bezier(.16,1,.3,1) both}
@keyframes vt-leave{to{opacity:0;transform:translateY(-10px)}}
@keyframes vt-enter{from{opacity:0;transform:translateY(14px)}}

/* Reduced motion keeps the transition — a cross-fade is calmer than a white
   flash — but drops the travel. */
@media (prefers-reduced-motion:reduce){
  ::view-transition-old(root){animation:vt-leave-flat .2s linear both}
  ::view-transition-new(root){animation:vt-enter-flat .2s linear both}
  @keyframes vt-leave-flat{to{opacity:0}}
  @keyframes vt-enter-flat{from{opacity:0}}
}

/* ==========================================================================
   Scroll storytelling
   A stage pins while a column of steps scrolls past it. The pin is native
   `position:sticky` — nothing here or in scrollytell.js touches the scroll
   itself. JS only publishes --scrolly-p (0..1) and .is-active/.is-past, and
   everything below is drawn from those two things.
   ========================================================================== */
.scrolly{
  display:grid;
  grid-template-columns:minmax(0,.9fr) minmax(0,1.1fr);
  gap:clamp(1.6rem,4vw,4rem);
  /* Load-bearing: a stretched grid item is exactly as tall as its row, which
     leaves position:sticky with zero distance to travel. `start` lets the
     stage keep its own height and slide inside the row instead. */
  align-items:start;
}

/* ---------- Pinned stage ---------- */
.scrolly__stage{
  position:sticky;
  /* Cleared past the fixed nav, or the panel title parks underneath it. */
  top:calc(var(--nav-h) + clamp(.8rem,3vw,2rem));
  align-self:start;
  border:1px solid var(--border);
  border-radius:var(--radius-lg);
  background:
    radial-gradient(120% 90% at 12% 0%,rgba(123,92,255,.14),transparent 62%),
    var(--surface);
  padding:clamp(1.5rem,3vw,2.4rem);
  box-shadow:var(--shadow);
  overflow:hidden;
}

/* All panels share one grid cell, so the stage is as tall as its tallest
   panel and never resizes as the story advances. Absolute positioning would
   have needed a hard-coded height to do the same job.

   The overlap is scoped to html.js through :where(), which adds no
   specificity of its own. Two things fall out of that: with the boot script
   missing the panels just stack and stay readable, and the stacked fallbacks
   further down win on source order instead of having to out-specify a gate.

   Inactive panels go visibility:hidden rather than opacity:0 alone, so the
   stack cannot swallow pointer events or hand a screen reader four panels at
   once. Nothing is lost by that: the stage is aria-hidden decoration and
   every line in it is a condensed echo of the track beside it. */
.scrolly__panels{display:grid;gap:1rem}
:where(html.js) .scrolly__panels{gap:0}
:where(html.js) .scrolly__panel{
  grid-area:1/1;
  opacity:0;visibility:hidden;transform:translateY(14px);
  /* Delayed visibility on the way out only, so the panel stays paintable for
     the length of its own fade instead of vanishing on frame one. */
  transition:opacity .5s var(--ease-out),transform .5s var(--ease-out),
             visibility 0s linear .5s;
}
:where(html.js) .scrolly__panel.is-active{
  opacity:1;visibility:visible;transform:none;
  transition:opacity .5s var(--ease-out),transform .5s var(--ease-out),
             visibility 0s;
}
.scrolly__num{
  display:block;font-family:var(--font-head);font-weight:700;line-height:1;
  font-size:clamp(3rem,8vw,4.6rem);
  /* The numeral is the thing that ties the stage to the numbered track, so it
     is drawn as a lit outline rather than body text. */
  color:transparent;
  background:linear-gradient(160deg,var(--gold-soft),var(--gold-deep));
  -webkit-background-clip:text;background-clip:text;
}
.scrolly__panel-title{
  font-family:var(--font-head);font-weight:700;color:var(--heading);
  font-size:var(--fs-lg);margin:.7rem 0 .5rem;
}
.scrolly__panel-line{color:var(--text-soft);font-size:var(--fs-base);max-width:34ch}

/* Progress meter. --scrolly-p arrives already damped from the rAF tick, so
   there is deliberately no transition here: a transition on top of per-frame
   values fights the scroll and lags behind the thumb. */
.scrolly__meter{
  margin-top:clamp(1.4rem,3vw,2rem);height:2px;border-radius:2px;
  background:var(--border-soft);overflow:hidden;
}
.scrolly__meter-fill{
  display:block;height:100%;transform-origin:0 50%;
  transform:scaleX(var(--scrolly-p,0));
  background:linear-gradient(90deg,var(--gold-deep),var(--gold),var(--gold-soft));
}

/* ---------- Scrolling track ---------- */
.scrolly__track{
  list-style:none;margin:0;position:relative;
  /* Lead-in. Without it the first step is already centred the moment the pin
     starts and hands over almost immediately, so panel one gets a fraction of
     the pinned time the other three get. Roughly a third of a step slot evens
     the four turns out. */
  padding:18vh 0 0;
}
/* Rail and its fill are two layers of the same line: the unlit groove is a
   static hairline, the lit part is that hairline scaled by progress. Both are
   faded out at the ends — the spine runs the whole column while the first and
   last dots sit a third of a screen inside it, and a hard-cut line reads as a
   stray border rather than as a thread running through the story. */
.scrolly__track::before,
.scrolly__track::after{
  content:"";position:absolute;left:6px;top:0;bottom:0;width:1px;
  -webkit-mask-image:linear-gradient(180deg,transparent,#000 14%,#000 86%,transparent);
          mask-image:linear-gradient(180deg,transparent,#000 14%,#000 86%,transparent);
}
.scrolly__track::before{background:var(--border)}
.scrolly__track::after{
  background:linear-gradient(180deg,var(--gold-deep),var(--gold-soft));
  transform-origin:50% 0;transform:scaleY(var(--scrolly-p,0));
}

.scrolly__step{
  /* The distance between steps IS the pin duration — shorten this and the
     stage un-pins before the last panel has been read. */
  min-height:60vh;
  /* Two columns rather than a dot positioned against the step box: the box is
     most of a screen tall, so an absolutely placed dot would sit at the top of
     an empty slot instead of beside the words it belongs to. */
  display:grid;
  grid-template-columns:13px minmax(0,1fr);
  column-gap:clamp(1rem,2vw,1.5rem);
  /* Centring the rows inside the tall slot is what keeps the active step near
     the middle of the screen for its whole turn instead of sliding up under
     the nav halfway through. */
  align-content:center;
  opacity:.4;transition:opacity .45s var(--ease-out);
}
.scrolly__step.is-past{opacity:.62}
.scrolly__step.is-active{opacity:1}
.scrolly__step-num{
  grid-column:1;grid-row:1;align-self:center;
  width:13px;height:13px;border-radius:50%;
  /* Opaque fill, because the dot sits on top of the rail rather than beside
     it — a transparent dot would show the hairline running straight through. */
  background:var(--bg);border:1px solid var(--border);
  transition:background .35s var(--ease-out),border-color .35s var(--ease-out),
             box-shadow .35s var(--ease-out);
}
.scrolly__step.is-past .scrolly__step-num{background:var(--gold-deep);border-color:var(--gold-deep)}
.scrolly__step.is-active .scrolly__step-num{
  background:var(--gold);border-color:var(--gold-soft);
  box-shadow:0 0 0 5px rgba(123,92,255,.18);
}
.scrolly__step-kicker{
  grid-column:2;grid-row:1;
  font-family:var(--font-mono);font-size:var(--fs-xs);
  letter-spacing:.18em;text-transform:uppercase;color:var(--text-mute);
}
.scrolly__step-title{grid-column:2;grid-row:2;font-size:var(--fs-md);color:var(--heading);margin:.35rem 0 .45rem}
.scrolly__step-text{grid-column:2;grid-row:3;color:var(--text-soft);font-size:var(--fs-base);max-width:46ch}

/* ---------- Stacked fallback ----------
   Two triggers, one outcome. Below 860px there is no room for a stage beside
   a track, and under reduced motion a pin is exactly the kind of motion that
   was asked not to happen — so both drop to a plain document: nothing pinned,
   the decorative stage gone, every step visible in order, no transitions, and a
   rail that reads full because there is no scroll position left to report. */
@media (max-width:860px){
  .scrolly{grid-template-columns:1fr}
  /* The stage is a decorative mirror: aria-hidden, and every line in it is a
     condensation of the step it is pinned beside. Side by side that reads as a
     caption; stacked one above the other it is the same four steps printed
     twice on a screen that has the least room for it. The track carries the
     real headings and the fuller copy, so the stage is what goes. */
  .scrolly__stage{display:none}
  /* The lead-in only exists to pace a pin that no longer happens. */
  .scrolly__track{padding-top:0}
  /* Two classes deep on purpose: this has to outrank .scrolly__step.is-past,
     which is itself two classes deep and would otherwise keep dimming rows
     that no longer have an active row to be behind. */
  .scrolly .scrolly__step{min-height:0;opacity:1;padding-block:.9rem}
  /* JS keeps publishing a real scroll position here, and an inline custom
     property outranks anything the stylesheet says. Neutralise the transform
     instead of arguing over the variable: an unscaled rail reads full, which
     is the honest state for a list with no travel left to report. */
  .scrolly__track::after{transform:none}
}

html.reduced-motion .scrolly{grid-template-columns:1fr;--scrolly-p:1}
/* Same call as the 860px block above, for the same reason: unpinned, the stage
   stops being a caption beside the track and becomes the four steps printed a
   second time. Reduced motion asks for less movement, not for more reading. */
html.reduced-motion .scrolly__stage{display:none}
html.reduced-motion .scrolly__track{padding-top:0}
html.reduced-motion .scrolly__step{
  min-height:0;opacity:1;transition:none;padding-block:.9rem;
}
html.reduced-motion .scrolly__step-num,
html.reduced-motion .scrolly__meter-fill,
html.reduced-motion .scrolly__track::after{transition:none}
html.reduced-motion .scrolly__meter-fill,
html.reduced-motion .scrolly__track::after{transform:none}

/* ── Spec sheet ─────────────────────────────────────────────────────────────
   Lab inventory on the AI & Robotics page. Deliberately reads like a parts
   list rather than a marketing grid: mono labels, hairline chips, no imagery.
   A parent scanning it should be able to price the kit themselves. */
.spec-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(280px,100%),1fr));gap:1.4rem}
.spec{
  background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--radius);
  padding:1.5rem;transition:transform .35s var(--ease),box-shadow .35s,border-color .35s;
}
.spec:hover{transform:translateY(-6px);border-color:var(--border);box-shadow:var(--shadow),var(--ring)}
.spec h3{font-size:var(--fs-base);margin-bottom:.25rem}
.spec p{font-size:var(--fs-sm);color:var(--text-mute);margin-bottom:1rem}
.spec__list{display:flex;flex-wrap:wrap;gap:.45rem;list-style:none;padding:0;margin:0}
.spec__list li{
  font-family:var(--font-mono);font-size:var(--fs-xs);line-height:1;
  color:var(--text-mute);background:var(--surface-2);
  border:1px solid var(--border-soft);border-radius:999px;padding:.42rem .68rem;
  transition:color .25s ease,border-color .25s ease;
}
.spec:hover .spec__list li{color:var(--text);border-color:var(--border)}

/* ── AI & Robotics band (homepage) ──────────────────────────────────────────
   Sits between the three pillars and the course grid. It borrows the hero's
   aura so the new discipline reads as part of the same ecosystem rather than a
   bolted-on announcement, and the kit list is the honest part: real part
   numbers, so a parent can see the hardware is not a stock photo. */
.ai-band{position:relative;overflow:hidden}
.ai-band__aura{
  position:absolute;left:50%;top:-10%;width:min(1200px,130vw);aspect-ratio:1.9;
  transform:translateX(-50%);pointer-events:none;z-index:0;
  background:radial-gradient(50% 50% at 50% 45%,rgba(123,92,255,.16),transparent 70%);
  filter:blur(14px);
}
.ai-band > .container{position:relative;z-index:1}

.ai-band__kit{
  margin:clamp(1.6rem,3vw,2.4rem) auto 0;max-width:860px;
  background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--radius);
  padding:clamp(1.2rem,2.4vw,1.6rem);text-align:center;
  transition:border-color .35s ease,box-shadow .35s ease;
}
.ai-band__kit:hover{border-color:var(--border);box-shadow:var(--shadow),var(--ring)}
.ai-band__kit figcaption{
  font-family:var(--font-mono);font-size:var(--fs-xs);letter-spacing:.18em;
  text-transform:uppercase;color:var(--text-mute);margin-bottom:.9rem;
}
.ai-band__kit .spec__list{justify-content:center}
.ai-band__kit:hover .spec__list li{color:var(--text);border-color:var(--border)}

/* ── Lab kit panel ──────────────────────────────────────────────────────────
   The skills-page version of the hardware list. Same honest part numbers as
   .ai-band__kit, but grouped into four bench categories so it reads as a spec
   sheet a parent or a hiring manager can scan, not a dumped inventory. */
.kit-panel{
  margin:clamp(1.6rem,3vw,2.4rem) 0 0;
  background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--radius);
  padding:clamp(1.4rem,2.8vw,2rem);
  transition:border-color .35s ease,box-shadow .35s ease;
}
.kit-panel:hover{border-color:var(--border);box-shadow:var(--shadow),var(--ring)}
.kit-panel__head{text-align:center;max-width:52ch;margin:0 auto clamp(1.3rem,2.6vw,1.8rem)}
.kit-panel__head h3{font-size:var(--fs-lg);margin:.45rem 0 .4rem}
.kit-panel__head p{font-size:var(--fs-sm);color:var(--text-mute);margin:0}
.kit-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(1rem,2vw,1.6rem)}
.kit-col{
  border-top:1px solid var(--border-soft);padding-top:.9rem;
  transition:border-color .3s ease;
}
.kit-panel:hover .kit-col{border-color:color-mix(in srgb,var(--gold) 45%,transparent)}
.kit-col h4{
  display:flex;align-items:center;gap:.5rem;
  font-size:var(--fs-sm);margin-bottom:.75rem;
}
.kit-col__n{
  font-family:var(--font-mono);font-size:.66rem;letter-spacing:.1em;
  color:var(--gold);opacity:.85;
}
.kit-col .spec__list{gap:.4rem}
.kit-panel:hover .spec__list li{color:var(--text);border-color:var(--border)}
@media (max-width:900px){
  .kit-grid{grid-template-columns:repeat(2,1fr)}
}
@media (max-width:560px){
  .kit-grid{grid-template-columns:1fr}
}

/* ==========================================================================
   Particle field
   One WebGL point cloud for the whole page, plus a hero-local front layer.

   The fixed canvas sits at z-index 0. That is not "behind everything" — a
   positioned element paints after every non-positioned block background in the
   same stacking context, so the field lands above the flat section grounds
   (.bg-1, .bg-2) and below every card, which is exactly the depth we want: the
   cloud drifts across the page and disappears behind the surfaces on it.

   Plain headings and body copy are not positioned either, so the field would
   paint over them. That is what [data-avoid] is for — the shader pushes the
   cloud off headlines and fades it off copy — and it is why every station
   section marks its text.
   ========================================================================== */
.field-scroll{
  position:fixed;inset:0;width:100%;height:100%;
  z-index:0;pointer-events:none;
}
.hero-field{
  position:absolute;inset:0;width:100%;height:100%;
  pointer-events:none;
}
/* Above .hero--eco .container (z-index 1), so half the population sits in front
   of the headline and the type reads as being inside the cloud rather than on
   a backdrop. */
.hero-field--front{z-index:2}

/* The closing band runs its own field in attract mode: the cloud is pulled
   toward the cursor instead of pushed away, which turns the last block on the
   page into something the reader can stir. Specificity beats .cta-band>*, which
   would otherwise make this position:relative and collapse it. */
.cta-band .cta-field{
  position:absolute;inset:0;width:100%;height:100%;
  z-index:0;pointer-events:none;
}

/* The roadmap is the one band that is both positioned and painted: a positioned
   element draws its own background in the same layer as the fixed canvas and,
   coming later in the document, would draw straight over it. Moving the ground
   onto a negative-z pseudo-element puts it back underneath, so the field crosses
   this section like every other one. */
.roadmap.bg-1{background:transparent}
.roadmap::before{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;
  background:var(--bg-1)}

/* --- Dither texture -------------------------------------------------------
   Painted by initDither into any [data-dither] canvas. It is a texture behind
   live copy, not a pattern in its own right, so it runs at low opacity.

   z-index 0 rather than -1: the host panel is position:sticky on desktop but
   static on mobile, and a negative index inside a static ancestor escapes the
   panel entirely and disappears behind the section. Keeping the canvas at 0
   and lifting the panel's own children to 1 works in both cases. */
.dither{position:absolute;inset:0;width:100%;height:100%;z-index:0;
  opacity:.3;pointer-events:none}
.roadmap-visual > :not(.dither){position:relative;z-index:1}

/* --- Gutter for the field --------------------------------------------------
   Three sections are wall-to-wall cards: #courses and #why-allin are auto-fill
   grids, .faculty is a fixed four-column one. Measured over their scroll range
   the card surfaces cover 45-70% of the content column, and because the canvas
   is fixed while the page scrolls through it, there is no vertical placement
   that dodges a grid — only a free column at the side is a stable safe zone.

   At the default --container:1200px and vw:1440 that free column is 120px each
   side, narrower than the shapes these sections hold. Narrowing the container
   widens it symmetrically: (1440 - c) / 2, so 1100px buys 170px. Simulated over
   the same scroll samples, occlusion falls 25% -> 12% (#courses), 32% -> 16%
   (#why-allin) and 19% -> 10% (.faculty).

   1100px is the floor, not a round number. .why-grid is the binding constraint:
   repeat(auto-fit, minmax(260px, 1fr)) with a 1.2rem gap needs 4*260 + 3*19.2 =
   1097.6px to hold four columns, and .cat-grid needs 1044.8px. Anything under
   1097.6 silently drops a column and reflows the section, which is a bigger
   change than the one we are making. Desktop only, since below 1280px the grids
   are already collapsing to two columns and the gutter question is moot. */
@media (min-width:1280px){
  #courses,#why-allin,.faculty{--container:1100px}
}

/* --- Breathing room in the pillars -----------------------------------------
   The particle word holds at a fixed point on screen while the page scrolls
   through it, so the only thing that can clear it is a horizontal band of page
   with nothing in it at that height. A side lane cannot work here: the widest
   word, COLLEGE, measures 312px at scale .62, while a centred 1200px container
   in a 1440px viewport leaves free columns of only 120px. Symmetric narrowing
   does not close that gap either -- a word that wide with margins needs the
   container down at ~800px, which is not a section any more. So the word is
   centred, and the clearance is vertical.

   #future ran the three pillars 51px below the head and 64px apart, against
   rows 394px tall, and the word spent most of the section clipped in half by
   the edge of a pillar photo. Two things fix that together. The photos now
   carry data-avoid="box", so the shader fades the cloud out across them the
   way it already did across the paragraphs -- the word dissolves into a pillar
   instead of being guillotined by it. And the gaps open to 20vw, 288px at
   1440, against a word 57px tall: five times its height, so it crosses each
   gap fully formed and holds there for roughly 230px of scroll rather than
   flashing through.

   Desktop only. Below 1024px the promo collapses to one column, the word has
   the full width to itself anyway, and this much air would just be dead
   scroll. */
@media (min-width:1024px){
  #future .section-head{margin-bottom:clamp(5rem,20vw,18rem)}
  #future .promo + .promo{margin-top:clamp(5rem,20vw,18rem)}
}
