/* ============================================================
   GRAPHICXO — "The Creative Universe" (Phase 3.5, homepage only)
   Layer A  · the persistent WebGL atmosphere
   Layer C  · the seven scene panels + readable content bands
   Tokens come from main.css — nothing is redefined here.
   ============================================================ */

/* ------------------------------------------------------------
   THE PICKED COLOUR
   Scene 3's palette (#palette) writes these on :root; everything that
   should follow the choice reads them. The defaults are exactly the
   purple the atmosphere already used, so nothing moves until a swatch
   is picked. Registering them as <color> is what lets the change ease
   in rather than jump — gradients can't be transitioned otherwise.
   ------------------------------------------------------------ */
@property --gx-pick      { syntax:"<color>"; inherits:true; initial-value:#6D28FF }
@property --gx-pick-soft { syntax:"<color>"; inherits:true; initial-value:#8b5cff }
@property --gx-pick-deep { syntax:"<color>"; inherits:true; initial-value:#5a1fd0 }

/* Declared on .universe, not on :root — and that is a performance decision as
   much as a scoping one.

   These are registered <color>s that INHERIT, carrying a one-second
   transition. Every frame of that transition invalidates the computed style
   of every element below whichever element declares them. On :root that is
   the entire document — all seven scenes, the pillar grid, the ten-tile
   gallery, the quotes wall, the footer — restyled sixty times a second for a
   full second, on every section change, i.e. six times over the length of the
   story. Measured, it was by a wide margin the most expensive thing happening
   while this page scrolled, and it is what the scroll stutter on phones
   actually was.

   Exactly two things read these — .universe__fallback below, and the
   artboard's two gradient stops in the Scene 3 figure — and both live inside
   .universe. Declaring them on that nearest common ancestor keeps the eased
   colour change identical and takes the rest of the document out of it.
   story.js writes to #universe rather than to the root to match; if you add a
   consumer outside this layer, that pairing is what has to move. */
.universe{
  --gx-pick:#6D28FF;
  --gx-pick-soft:#8b5cff;
  --gx-pick-deep:#5a1fd0;
  transition:--gx-pick 1s var(--ease), --gx-pick-soft 1s var(--ease), --gx-pick-deep 1s var(--ease);
}
@media (prefers-reduced-motion:reduce){
  .universe{transition:none}
}

/* ------------------------------------------------------------
   LAYER A — ATMOSPHERE
   ------------------------------------------------------------ */
.universe{
  position:fixed;
  inset:0;
  z-index:0;
  pointer-events:none;
  overflow:hidden;
}

/* Visible until the WebGL scene reports ready, and permanently visible
   when WebGL or motion is unavailable. */
.universe__fallback{
  position:absolute;
  inset:0;
  opacity:1;
  transition:opacity 1.4s var(--ease);
  background:
    radial-gradient(ellipse 62% 56% at 50% 42%, rgba(109,40,255,.40), transparent 63%),
    radial-gradient(ellipse 42% 38% at 50% 44%, rgba(139,92,255,.24), transparent 60%),
    radial-gradient(ellipse 90% 70% at 50% 50%, rgba(7,7,7,.35), var(--bg) 78%);
}
/* Same gradient, expressed against the picked colour — so the no-WebGL path
   recolours too instead of staying purple under a gold universe. The rgba
   values above are the identical fallback for engines without color-mix. */
@supports (background:color-mix(in srgb, red 40%, transparent)){
  .universe__fallback{
    background:
      radial-gradient(ellipse 62% 56% at 50% 42%, color-mix(in srgb, var(--gx-pick) 40%, transparent), transparent 63%),
      radial-gradient(ellipse 42% 38% at 50% 44%, color-mix(in srgb, var(--gx-pick-soft) 24%, transparent), transparent 60%),
      radial-gradient(ellipse 90% 70% at 50% 50%, rgba(7,7,7,.35), var(--bg) 78%);
  }
}
.universe.is-live .universe__fallback{opacity:0}

.universe__canvas{position:absolute;inset:0}
.universe__canvas canvas{display:block;width:100%;height:100%}

/* ------------------------------------------------------------
   ORBITING STUDIO ARTIFACTS
   Ride the three shader rings, so these radii and the centre must
   match hero.js (0.158 / 0.268 / 0.381 of viewport height, CENTRE_X / 44.3%).
   ------------------------------------------------------------ */
.orbits{
  position:absolute;
  inset:0;
  /* Two independent factors, multiplied: the arrival timeline animates
     --orbit-intro, the scroll handler sets --orbit-scroll. */
  --orbit-intro:0;
  --orbit-scroll:1;
  opacity:calc(var(--orbit-intro) * var(--orbit-scroll));
  color:var(--gold-soft);
  /* Two masks, intersected: the first keeps artifacts off the fixed nav, the
     second dissolves them as they swing toward the headline's corner so they
     never compete with the type. */
  -webkit-mask-image:
    linear-gradient(to bottom,transparent 0,#000 13%,#000 100%),
    radial-gradient(ellipse 74% 80% at 72% 40%,#000 44%,transparent 92%);
  mask-image:
    linear-gradient(to bottom,transparent 0,#000 13%,#000 100%),
    radial-gradient(ellipse 74% 80% at 72% 40%,#000 44%,transparent 92%);
  -webkit-mask-composite:source-in;
  mask-composite:intersect;
}
/* Scene 1 is over. story.js sets this once --orbit-scroll reaches 0, because
   opacity:0 is not the same as gone: the three ring animations keep ticking
   and this layer's two intersected masks keep being composited over the whole
   viewport, behind every later scene, for the rest of the page. */
.orbits.is-gone{visibility:hidden}
.orbit{
  position:absolute;
  left:65%;          /* must match CENTRE_X in hero.js */
  top:44.3%;
  translate:-50% -50%;
  border-radius:50%;
  animation:orbitSpin var(--dur) linear infinite;
}
.orbit--1{--r:15.8svh;--dur:54s;width:31.6svh;height:31.6svh}
.orbit--2{--r:26.8svh;--dur:78s;width:53.6svh;height:53.6svh;animation-direction:reverse}
.orbit--3{--r:38.1svh;--dur:104s;width:76.2svh;height:76.2svh}

.orbit__slot{position:absolute;inset:0;transform:rotate(var(--a))}
/* Artifacts stay upright two ways: `transform` cancels the slot's fixed angle,
   and the counter-spin animation on `rotate` cancels the ring's rotation.
   (They're separate properties, so both compose without fighting.) */
.orbit__item{
  position:absolute;
  left:50%;
  top:0;
  translate:-50% -50%;
  transform:rotate(calc(-1 * var(--a)));
  display:grid;
  place-items:center;
  animation:orbitSpin var(--dur) linear infinite reverse;
}
.orbit--2 .orbit__item{animation-direction:normal}

@keyframes orbitSpin{to{rotate:360deg}}

/* Glass chip housing the line-art */
.chip{
  width:clamp(46px,4.2vw,64px);
  height:clamp(46px,4.2vw,64px);
  border-radius:14px;
  position:relative;
  border:1px solid rgba(240,221,160,.72);
  background:linear-gradient(150deg,rgba(247,247,247,.22),rgba(247,247,247,.06) 55%,rgba(109,40,255,.18));
  /* Star-bright edge: hot rim, tight bloom, wide halo. */
  box-shadow:
    0 0 9px rgba(255,244,214,.36),
    0 0 22px rgba(231,201,107,.32),
    0 0 54px rgba(212,175,55,.16),
    0 8px 26px -10px rgba(0,0,0,.85),
    inset 0 1px 0 rgba(255,255,255,.42),
    inset 0 0 14px rgba(255,240,200,.14);
}
/* Radial flare behind each artifact, breathing out of sync per item. */
.chip::after,.shard::after{
  content:"";
  position:absolute;
  inset:-55%;
  z-index:-1;
  border-radius:50%;
  pointer-events:none;
  background:radial-gradient(circle,rgba(255,241,205,.26),rgba(212,175,55,.10) 42%,transparent 72%);
  animation:artifactTwinkle 6.5s ease-in-out infinite;
}
.shard{position:relative}
.shard::after{background:radial-gradient(circle,rgba(214,196,255,.26),rgba(139,92,255,.12) 44%,transparent 72%)}
.orbit__slot:nth-child(2) .chip::after,.orbit__slot:nth-child(2) .shard::after{animation-delay:-1.6s}
.orbit__slot:nth-child(3) .chip::after,.orbit__slot:nth-child(3) .shard::after{animation-delay:-3.2s}
.orbit__slot:nth-child(4) .chip::after,.orbit__slot:nth-child(4) .shard::after{animation-delay:-4.6s}
.orbit__slot:nth-child(5) .chip::after,.orbit__slot:nth-child(5) .shard::after{animation-delay:-2.4s}
@keyframes artifactTwinkle{
  0%,100%{opacity:.42;transform:scale(.95)}
  50%    {opacity:.82;transform:scale(1.07)}
}
.chip--wide{width:clamp(72px,6.6vw,102px)}
.chip svg{width:58%;height:auto;display:block;color:#fff6de;
  filter:drop-shadow(0 0 4px rgba(255,246,222,.68)) drop-shadow(0 0 12px rgba(231,201,107,.42)) drop-shadow(0 0 26px rgba(212,175,55,.20))}
.chip--wide svg{width:70%}

/* Abstract glass shards — cut prisms, not blank chips. The clip-path removes
   any border, so the lit edge comes from a bright inner gradient plus an
   outer glow. */
.shard{
  width:clamp(34px,3.1vw,48px);
  height:clamp(44px,4vw,62px);
  transform:rotate(calc(-1 * var(--a) - 14deg));
  filter:drop-shadow(0 0 7px rgba(226,214,255,.52)) drop-shadow(0 0 18px rgba(139,92,255,.44)) drop-shadow(0 6px 18px rgba(0,0,0,.6));
}
.shard--sm{
  width:clamp(26px,2.3vw,36px);
  height:clamp(32px,2.9vw,46px);
  transform:rotate(calc(-1 * var(--a) + 12deg));
  filter:drop-shadow(0 0 7px rgba(255,244,214,.55)) drop-shadow(0 0 16px rgba(212,175,55,.4)) drop-shadow(0 6px 16px rgba(0,0,0,.6));
}
.shard i{
  display:block;
  width:100%;
  height:100%;
  clip-path:polygon(52% 0%,100% 30%,84% 100%,14% 86%,0% 34%);
  background:
    linear-gradient(150deg,rgba(255,255,255,.92) 0%,rgba(255,255,255,.22) 16%,transparent 38%),
    linear-gradient(198deg,rgba(139,92,255,.55),rgba(247,247,247,.10) 46%,rgba(212,175,55,.34));
}
.shard--sm i{
  clip-path:polygon(46% 0%,100% 26%,88% 92%,10% 100%,0% 40%);
  background:
    linear-gradient(140deg,rgba(255,255,255,.95) 0%,rgba(255,255,255,.24) 18%,transparent 40%),
    linear-gradient(210deg,rgba(212,175,55,.50),rgba(247,247,247,.10) 50%,rgba(176,68,140,.34));
}

/* Golden ribbons */
.ribbon{width:clamp(64px,6vw,92px)}
.ribbon--long{width:clamp(84px,7.6vw,120px)}
.ribbon svg{width:100%;height:auto;display:block;filter:drop-shadow(0 0 4px rgba(255,246,222,.6)) drop-shadow(0 0 12px rgba(231,201,107,.4)) drop-shadow(0 0 28px rgba(212,175,55,.18))}

/* Accent motes */
.dot{width:7px;height:7px;border-radius:50%;background:radial-gradient(circle,#fff 0%,#c9b3ff 40%,rgba(139,92,255,.9) 100%);
  box-shadow:0 0 7px 2px rgba(255,255,255,.4),0 0 16px 4px rgba(139,92,255,.55),0 0 34px 8px rgba(139,92,255,.18);
  animation:artifactTwinkle 5.2s ease-in-out infinite}
.dot--gold{background:radial-gradient(circle,#fff 0%,#ffeeb8 40%,rgba(212,175,55,.95) 100%);
  box-shadow:0 0 7px 2px rgba(255,255,255,.42),0 0 16px 4px rgba(231,201,107,.58),0 0 34px 8px rgba(212,175,55,.2);
  animation-delay:-2.8s}

@media (max-width:900px){
  /* Too crowded at small sizes — keep the ribbons and motes only. */
  .orbits .chip,.orbits .shard{display:none}
  /* One mask instead of two intersected ones. The second was there to
     dissolve artifacts as they swung toward the headline's corner — but the
     things it was dissolving are the chips and shards, and those are gone by
     this width. What is left is a full-viewport mask composite running under
     an animating subtree for no visible benefit. */
  .orbits{
    -webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 16%,#000 100%);
    mask-image:linear-gradient(to bottom,transparent 0,#000 16%,#000 100%);
  }
  /* Three stacked drop-shadows is a three-pass blur of the same element, per
     frame, while it orbits. One carries the glow at this size. */
  .ribbon svg{filter:drop-shadow(0 0 9px rgba(231,201,107,.55))}
}
@media (prefers-reduced-motion:reduce){
  .orbit,.orbit__item,.chip::after,.shard::after,.dot{animation:none}
}

/* ------------------------------------------------------------
   SCENE 2 — CREATION: the detonation rays
   Same anchor as the rings. 1 viewBox unit = 0.1svh.
   ------------------------------------------------------------ */
.burst{
  position:absolute;
  left:65%;          /* must match CENTRE_X in hero.js */
  top:44.3%;
  width:100svh;
  height:100svh;
  translate:-50% -50%;
  opacity:var(--burst,0);
  scale:calc(.72 + .28 * var(--burst,0));
  overflow:visible;
  filter:drop-shadow(0 0 9px rgba(255,255,255,.35)) drop-shadow(0 0 24px rgba(139,92,255,.5)) drop-shadow(0 0 54px rgba(212,175,55,.2));
  will-change:opacity,scale;
}
.burst__rays{
  transform-origin:0 0;
  animation:burstDrift 90s linear infinite;
}
.burst__rays line{stroke-linecap:round}
.burst__core{
  /* Fill comes from #gxCoreGlow in universe.php — the same white->purple ramp
     the nebula shader's seed dot uses, so the two read as one dot handing off
     as --burst rises (see the seedRecede/burstHeat comment in hero.js) rather
     than two independently-lit dots at different sizes. */
  filter:drop-shadow(0 0 10px rgba(255,255,255,.9)) drop-shadow(0 0 26px rgba(139,92,255,.8));
  opacity:calc(.12 + .88 * var(--burst,0));
}
@keyframes burstDrift{to{rotate:360deg}}

@media (max-width:900px){
  /* Capped against the viewport's WIDTH as well as its height. On a tall
     phone 118svh is over 1000px, and the longest ray is 0.42 of that — the
     figure was being sized by a dimension the screen does not have.

     The 65% anchor stays, unlike every other figure below. This one is welded
     to the shader's seed dot (hero.js, CENTRE_X 0.65): the core here takes
     over from that dot as the detonation fires, and CSS cannot move the
     shader. Moving the burst alone would read as two dots, not one igniting. */
  .burst{width:min(118svh,180vw);height:min(118svh,180vw)}
  /* One shadow, not three. This filter is re-applied every frame the burst
     scrubs, over a box the size of the screen. */
  .burst{filter:drop-shadow(0 0 20px rgba(139,92,255,.5))}
}
@media (prefers-reduced-motion:reduce){
  .burst__rays{animation:none}
}

/* ------------------------------------------------------------
   SCENE 3 — DESIGN: the identity under construction
   Staged from a single --design value so the whole figure is
   scrubbed by scroll: grid, then the emblem draws, then the
   anchors and handles, then the palette.
   ------------------------------------------------------------ */
.design{
  position:absolute;
  left:65%;          /* must match CENTRE_X in hero.js */
  top:44.3%;
  width:100svh;
  height:100svh;
  translate:-50% -50%;
  overflow:visible;
  --d:var(--design,0);
  opacity:clamp(0,calc(var(--d) * 2.4),1);
}
.design__grid{opacity:clamp(0,calc(var(--d) * 2.2),1)}
/* Draw-on: the diamond's perimeter is ~622 units. */
.design__diamond{
  stroke-dasharray:622;
  stroke-dashoffset:clamp(0px,calc((0.72 - var(--d)) * 1120px),622px);
  filter:drop-shadow(0 0 6px rgba(212,175,55,.5));
}
.design__inner{opacity:clamp(0,calc((var(--d) - .34) * 3),.55)}
.design__dot{opacity:clamp(0,calc((var(--d) - .40) * 4),1)}
.design__nodes{
  opacity:clamp(0,calc((var(--d) - .44) * 3.4),1);
  filter:drop-shadow(0 0 7px rgba(139,92,255,.55));
}
/* The board's own glow follows the picked swatch (stops carry the hex too,
   so a stylesheet-less render still gets the purple). */
.design__board-core{stop-color:var(--gx-pick)}
.design__board-mid{stop-color:var(--gx-pick-deep)}

/* ---- Scene 3's own motion ----
   The particle field withdraws here (see hero.js), so this scene moves like a
   drafting table instead: a plotter pass sweeps the board, the construction
   circle turns, the handles flex, and the palette is chosen one at a time. */
.design__scan{
  animation:designScan 7s cubic-bezier(.65,0,.35,1) infinite;
  filter:drop-shadow(0 0 8px rgba(231,201,107,.7));
}
@keyframes designScan{
  0%      {transform:translateY(-278px);opacity:0}
  8%      {opacity:.95}
  46%     {opacity:.95}
  56%     {transform:translateY(278px);opacity:0}
  100%    {transform:translateY(278px);opacity:0}
}

.design__ring{animation:designTurn 46s linear infinite;transform-origin:0 0}
.design__inner{animation:designTurn 34s linear infinite reverse;transform-origin:0 0}
@keyframes designTurn{to{rotate:360deg}}

/* The bézier handles keep adjusting — the curve is never quite finished. */
.design__handles{animation:designFlex 9s ease-in-out infinite;transform-origin:0 -110px}
@keyframes designFlex{
  0%,100%{rotate:-3.2deg}
  50%    {rotate:3.2deg}
}

.design__ticks{animation:designTicks 5.5s ease-in-out infinite}
@keyframes designTicks{0%,100%{opacity:.28}50%{opacity:.62}}

@media (prefers-reduced-motion:reduce){
  .design__scan,.design__ring,.design__inner,
  .design__handles,.design__ticks{animation:none}
  .design__scan{opacity:0}
}

/* See the matching .palette block below — these two are one figure and every
   number in this query has to be repeated there. */
@media (max-width:900px){
  .design{
    left:50%;
    width:min(124svh,230vw);
    height:min(124svh,230vw);
  }
}

/* ------------------------------------------------------------
   SCENE 3 — THE PALETTE (interactive)
   Geometrically part of the #design figure, but its own layer: the
   atmosphere is pointer-events:none and sits below .story, so this is
   the one piece of the figure that has to clear it. Every number here
   (left, top, size, viewBox, the --design staging) matches .design —
   change one, change both.
   ------------------------------------------------------------ */
.palette{
  position:fixed;
  left:65%;          /* must match CENTRE_X in hero.js and .design */
  top:44.3%;
  width:100svh;
  height:100svh;
  translate:-50% -50%;
  z-index:3;         /* .story is 2 — the only layer that has to be cleared */
  overflow:visible;
  pointer-events:none;
  --d:var(--design,0);
  --in:clamp(0,calc((var(--d) - .56) * 3.6),1);
  opacity:var(--in);
}
/* The rise-in lives on the row, since .palette's own translate is centring. */
.palette__row{translate:0 calc((1 - var(--in)) * 26px)}

/* Only clickable while scene 3 is actually up — story.js adds .is-live at
   --design > .68, by which point the swatches are ~half opaque. Below that
   they are invisible and must not eat clicks meant for the copy. */
.palette.is-live .palette__sw{pointer-events:auto;cursor:pointer}
.palette__sw{outline:none}

.palette__ring{
  fill:none;
  stroke:var(--white);
  stroke-width:2.6;
  opacity:0;
  transition:opacity .35s var(--ease);
}
@media (hover:hover){.palette.is-live .palette__sw:hover .palette__ring{opacity:.55}}
.palette__sw:focus-visible .palette__ring{opacity:1;stroke-dasharray:7 6}
.palette__sw[aria-pressed="true"] .palette__ring{opacity:1}

/* "Colour is chosen with intent" — until one is, each swatch is picked in
   turn on its own. Once the visitor picks, the demo stops and their choice
   stays lifted. */
.palette__sw{animation:designPick 8s ease-in-out infinite}
.palette__sw:nth-child(2){animation-delay:2s}
.palette__sw:nth-child(3){animation-delay:4s}
.palette__sw:nth-child(4){animation-delay:6s}
@keyframes designPick{
  0%,72%,100%{translate:0 0;filter:brightness(1)}
  12%,24%    {translate:0 -12px;filter:brightness(1.35)}
  40%        {translate:0 0;filter:brightness(1)}
}
.palette.is-picked .palette__sw{animation:none;translate:0 0;filter:brightness(.72)}
.palette.is-picked .palette__sw[aria-pressed="true"]{translate:0 -12px;filter:brightness(1.35)}
.palette__sw{transition:translate .5s var(--ease),filter .5s var(--ease)}

@media (prefers-reduced-motion:reduce){
  .palette__sw{animation:none;transition:none}
}

/* ---- The palette on a phone ----
   Two separate bugs, one fix. The swatch row runs from -140 to +148 viewBox
   units, i.e. 0.288 of the figure's width; at 124svh on a 390px handset that
   is 302px of row hung off an anchor 253px from the left edge, so the White
   swatch — an interactive control — sat past the right edge of the screen and
   could not be tapped. It was also pushing the document wider than the
   viewport, which is what the horizontal drift at that point in the scroll
   was.

   Centring the figure fixes the reach; capping its width against 230vw stops
   the row growing past the screen on a short, wide viewport (a handset in
   landscape). The height cap has to move with it or the artboard stops being
   square and the blueprint's grid shears. 230vw keeps the swatches clear of
   the copy vertically — they sit at 0.29-0.36 of the figure's height below
   the anchor, which is still below the scene's text block.

   Every number here MUST match the .design query above: same left, same
   width, same height. They are the same drawing. */
@media (max-width:900px){
  .palette{
    left:50%;
    width:min(124svh,230vw);
    height:min(124svh,230vw);
  }
}

/* ------------------------------------------------------------
   SCENE 4 — DEVELOPMENT: the identity on every screen
   Overlapping glass panes, traced from the concept frame. They arrive one
   at a time and drift at different depths; light runs their perimeters.
   ------------------------------------------------------------ */
.devices{
  position:absolute;
  left:65%;          /* must match CENTRE_X in hero.js */
  top:44.3%;
  width:100svh;
  height:100svh;
  translate:-50% -50%;
  overflow:visible;
  /* --v : presence, 0→1→0, gates what is visible.
     --t : travel, 0→1 monotonic, scrubs depth and the in-screen scrolling.
     Both are published per frame by story.js. */
  --v:var(--devices,0);
  --t:var(--devices-t,0);
  /* Gate only — the staging happens per frame below, so this must clear
     before the first frame starts or it would clip the sequence. */
  opacity:clamp(0,calc(var(--v) * 4.5),1);
  /* The camera pushes gently through the cluster as the panel passes. */
  scale:calc(1.055 - var(--t) * .085);
}

/* Each pane rises into place in turn — desktop, phone, tablet, i.e. back to
   front — then breathes at its own depth. The stages finish by --v ≈ .45:
   story.js gives figures a tighter window than the atmosphere (0.52 vs 1.15)
   so Scene 5's tiles can't share the screen, and at the old .69 the panes
   would only be fully assembled across 0.16 of a viewport of scroll. */
/* Two things share `translate`, summed in one expression: the staged rise
   (off --in) and the scroll parallax (off --t). They can't be separate
   declarations, so --par carries each pane's depth and reduced-motion just
   zeroes it. The idle float rides `transform`, a different property, so it
   composes with both instead of fighting them. --par is unitless. */
.devices__unit{--par:0;transform-box:fill-box;transform-origin:center}
.devices__unit--a{
  --in:clamp(0,calc((var(--v) - .04) * 4.8),1);
  --par:26;                                     /* back plane — drifts least */
  opacity:var(--in);
  translate:0 calc((1 - var(--in)) * 34px + (.5 - var(--t)) * var(--par) * 1px);
  animation:deviceFloatA 13s ease-in-out infinite;
}
.devices__unit--b{
  --in:clamp(0,calc((var(--v) - .14) * 4.8),1);
  --par:48;
  opacity:var(--in);
  translate:0 calc((1 - var(--in)) * 30px + (.5 - var(--t)) * var(--par) * 1px);
  animation:deviceFloatB 11s ease-in-out infinite;
}
.devices__unit--c{
  --in:clamp(0,calc((var(--v) - .24) * 4.8),1);
  --par:62;                                     /* front pane — drifts most */
  opacity:var(--in);
  translate:0 calc((1 - var(--in)) * 26px + (.5 - var(--t)) * var(--par) * 1px);
  animation:deviceFloatC 15s ease-in-out infinite;
}
@keyframes deviceFloatA{0%,100%{transform:translate(0,0)}50%{transform:translate(-4px,-9px)}}
@keyframes deviceFloatB{0%,100%{transform:translate(0,0)}50%{transform:translate(6px,-6px)}}
@keyframes deviceFloatC{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,7px)}}

/* A slow highlight travelling across each glass surface. */
.devices__sheen{animation:deviceSheen 9s ease-in-out infinite}
@keyframes deviceSheen{0%,100%{opacity:.03}45%{opacity:.11}}

/* Live content scrolling behind the glass. Each feed is stamped twice, one
   set-height apart in universe.php, and slides by exactly that height — so
   the loop closes on itself with no jump. The three distances differ but the
   durations hold them all to ~19 units/s: one hand scrolling three screens,
   not three screens racing. Negative delays desync the start. */
/* Longhands, not the shorthand: a nameless `animation:linear infinite` is
   ambiguous enough to be worth not relying on, and the name/duration are set
   per pane below anyway. */
.devices__feed{
  --adv:0;
  opacity:calc(var(--in,0) * .8);
  /* Scrubbed: scrolling the page scrolls the screens. `translate` is a
     separate property from the ambient loop's `transform`, so the two sum —
     the content always drifts, and drifts faster while the user scrolls.
     Each --adv is inside its feed's spare content (2H - paneH), so the
     combined shift can never run past the last stamp and expose the clip. */
  translate:0 calc(var(--t) * var(--adv) * -1px);
  animation-timing-function:linear;
  animation-iteration-count:infinite;
}
.devices__unit--a .devices__feed{--adv:260;animation-name:deviceScrollA;animation-duration:22s}
.devices__unit--b .devices__feed{--adv:180;animation-name:deviceScrollB;animation-duration:14s;animation-delay:-3s}
.devices__unit--c .devices__feed{--adv:150;animation-name:deviceScrollC;animation-duration:10.5s;animation-delay:-6s}
@keyframes deviceScrollA{to{transform:translateY(-420px)}}
@keyframes deviceScrollB{to{transform:translateY(-260px)}}
@keyframes deviceScrollC{to{transform:translateY(-200px)}}

/* "Connected by light" — a lit arc travels each pane's own perimeter, so the
   light rides edges the concept frame already has instead of adding new
   geometry over approved art. Each dash pattern's period IS that frame's
   perimeter (1894 / 728 / 767 units, see universe.php), so exactly one arc
   is in flight at a time; the durations hold all three to ~150 units/s.
   The traces live inside the units, so they inherit the staging above. */
.devices__flow{
  fill:none;
  stroke:var(--gold-soft);
  stroke-width:2.2;
  stroke-linecap:round;
  filter:drop-shadow(0 0 6px rgba(231,201,107,.75));
  animation:deviceFlow linear infinite;
}
.devices__unit--a .devices__flow{stroke-dasharray:340 1554;stroke-dashoffset:1894;animation-duration:12.6s}
.devices__unit--b .devices__flow{stroke-dasharray:150 578; stroke-dashoffset:728; animation-duration:4.9s;animation-delay:1.1s}
.devices__unit--c .devices__flow{stroke-dasharray:158 609; stroke-dashoffset:767; animation-duration:5.1s;animation-delay:2.2s}
@keyframes deviceFlow{to{stroke-dashoffset:0}}

/* ---- Scene 4 on a phone ----
   This figure is the one that was plainly broken, and it is broken in a way
   the other scenes are not: its composition is NOT centred in its own
   viewBox. The three panes occupy x -447 to +241 — 688 units wide, centred on
   -103, well left of the origin the figure is anchored by.

   So `translate:-50%` centred the viewBox, not the picture. At 132svh on a
   390px handset the panes came out 766px wide, hung off an anchor at 65% of
   the screen: the desktop pane's whole left half was off the left edge and
   the phone pane's right edge was off the right one. Only the middle third of
   a composition built around overlapping panes was ever visible, which is why
   it read as "the image isn't responsive" rather than as a layout that was
   merely tight.

   Two corrections, both from the geometry rather than by eye:

   1. Size the figure so the PANES fit, not the viewBox. 688 of 1000 units is
      0.688 of the width, so 133vw puts the composition at 91% of the screen.
      The svh cap still wins on a desktop-shaped viewport.
   2. Shift by the composition's own offset. Its centre sits at -103 units,
      i.e. -10.3% of the figure's width, so translating +10.3% on top of the
      centring puts the picture — not the box — over the anchor. Same for the
      41-unit vertical offset (-4.05%).

   Anchor moves to 50%: the panes are wide and this scene's atmosphere is a
   broad haze with no point source to stay welded to (unlike .burst above).

   And it moves into a BAND. On a wide screen the copy is a column on the left
   and the figure lives beside it; from 900px down the copy runs the full
   width at the foot of the panel, so the only room left is above it — but
   that room has a fixed bar sitting on top of it, and the bar is opaque at
   this width (see .nav.scrolled in main.css). Lifting the figure clear of the
   copy alone just slid its top edge under the nav instead, which is a change
   of one problem for another.

   Both edges of the band, then, measured rather than guessed:

     top    the scrolled bar's own height — 88px at 600px and under, 99px
            above it — plus air. 6.5rem covers the worse of the two.
     bottom the copy's top edge. story.js parks the copy block's centre at
            0.55 of the viewport and the block runs about 236px tall on a
            phone, so its top edge is near 55svh - 118px.

   Centre of that band is (6.5rem + 55svh - 118px) / 2, which is the 27.5% -
   10px below; its height is the difference, and the third term in the width
   caps is that height converted through this figure's own proportion (the
   panes are 0.381 of the figure's width tall, so 1/0.381 = 2.62 of it).

   The min-height gate is not decoration. Below about 560px of viewport the
   band has no height left — 55svh - 118px has already dropped to the bar's
   own bottom edge — and the third term goes negative, which min() would hand
   back as a negative width and erase the figure. There is genuinely nothing
   to fit anything into at that size, so those viewports get the plain centred
   rule below instead and let the panel's scrim carry the legibility, which is
   what every width did before this band existed. */
@media (max-width:900px) and (min-height:560px){
  .devices{
    left:50%;
    top:calc(27.5% - 10px);
    width:min(97svh,133vw,144.4svh - 630px);
    height:min(97svh,133vw,144.4svh - 630px);
    translate:calc(-50% + 10.3%) calc(-50% - 4.05%);
  }
}
@media (max-width:900px) and (max-height:559px){
  .devices{
    left:50%;
    top:44.3%;
    width:min(97svh,120vw);
    height:min(97svh,120vw);
    translate:calc(-50% + 10.3%) calc(-50% - 4.05%);
  }
}
@media (max-width:900px){
  /* The lit arc travelling each perimeter is a blurred stroke redrawn every
     frame; one shadow is enough at this size. */
  .devices__flow{filter:drop-shadow(0 0 5px rgba(231,201,107,.8))}
}
@media (prefers-reduced-motion:reduce){
  .devices__unit,.devices__sheen,.devices__flow,.devices__feed{animation:none}
  .devices__flow{opacity:0}
  /* Kill the scrubbed motion too — parallax and in-screen scrolling are
     scroll-driven, not keyframed, so `animation:none` doesn't reach them. */
  .devices{scale:1}
  .devices__unit{--par:0}
  .devices__feed{--adv:0}
}

/* ------------------------------------------------------------
   SCENE 5 — GROWTH: content tiles climbing a performance curve
   Traced from the concept frame. Tiles land one at a time along the
   curve; the curve itself draws on scroll, not on a timer.
   ------------------------------------------------------------ */
.growth{
  position:absolute;
  left:65%;          /* must match CENTRE_X in hero.js */
  top:44.3%;
  width:100svh;
  height:100svh;
  translate:-50% -50%;
  overflow:visible;
  --v:var(--growth,0);
  --t:var(--growth-t,0);
  /* The curve is the one thing here scrubbed to travel rather than presence,
     because it should reward scrolling rather than sit at a fixed state.

     travel() maps the copy's centre to 0->1 as (1.15h - centre) / 1.3h, so:
       --t .08  = centre 1.046h — copy still below the fold, i.e. mid 4->5 handoff
       --t .46  = centre 0.550h — Scene 5 centred, the composition's peak
     Drawing between those two means the line starts as Development hands off
     and lands finished exactly as Growth arrives. The earlier .25 -> .75
     window finished at centre 0.175h, with the copy nearly off the top — so
     it read as an animation belonging to the scroll into Scene 6. */
  --draw:clamp(0,calc((var(--t) - .08) * 2.62),1);
  opacity:clamp(0,calc(var(--v) * 4.5),1);
}

/* Each tile rises, scales up and settles, in curve order. Three separate
   properties again: `translate` carries the rise + parallax, `scale` the
   entrance, `transform` the idle float — so none of them overwrite another. */
/* The radiant behind the cluster. Comes up before the first tile — it's the
   light the tiles arrive into, not a highlight on them — and drifts least of
   anything in the figure, because it is the backdrop. */
.growth__glow{
  --in:clamp(0,calc(var(--v) * 3.2),1);
  opacity:calc(var(--in) * .95);
  transform-box:fill-box;
  transform-origin:center;
  translate:0 calc((.5 - var(--t)) * 18px);
  animation:growthGlow 12s ease-in-out infinite;
}
@keyframes growthGlow{0%,100%{transform:scale(1)}50%{transform:scale(1.07)}}

/* Defaults first: the --1..--4 modifiers below are the same specificity, so
   anything defaulted after them would win and flatten the staging. */
.growth__tile{--in:0;--par:0;transform-box:fill-box;transform-origin:center}
/* Same compression as the Scene 4 panes, for the same reason: the figure
   window is 0.52 viewport heights, so all four tiles must be standing by
   --v ≈ .48 to leave the composition assembled for a useful stretch. */
.growth__tile--1{
  --in:clamp(0,calc((var(--v) - .03) * 4.8),1);
  --par:30;
  filter:drop-shadow(0 0 26px rgba(109,40,255,.5));
}
.growth__tile--2{
  --in:clamp(0,calc((var(--v) - .11) * 4.8),1);
  --par:40;
  filter:drop-shadow(0 0 26px rgba(212,175,55,.42));
}
.growth__tile--3{
  --in:clamp(0,calc((var(--v) - .19) * 4.8),1);
  --par:46;
  filter:drop-shadow(0 0 24px rgba(30,138,128,.45));
}
.growth__tile--4{
  --in:clamp(0,calc((var(--v) - .27) * 4.8),1);
  --par:58;
  filter:drop-shadow(0 0 28px rgba(200,60,130,.48));
}
.growth__tile{
  opacity:var(--in);
  translate:0 calc((1 - var(--in)) * 40px + (.5 - var(--t)) * var(--par) * 1px);
  scale:calc(.86 + var(--in) * .14);
}
.growth__tile--1{animation:growthFloatA 14s ease-in-out infinite}
.growth__tile--2{animation:growthFloatB 11.5s ease-in-out infinite}
.growth__tile--3{animation:growthFloatC 16s ease-in-out infinite}
.growth__tile--4{animation:growthFloatB 12.5s ease-in-out infinite reverse}
@keyframes growthFloatA{0%,100%{transform:translate(0,0)}50%{transform:translate(-5px,-10px)}}
@keyframes growthFloatB{0%,100%{transform:translate(0,0)}50%{transform:translate(6px,-7px)}}
@keyframes growthFloatC{0%,100%{transform:translate(0,0)}50%{transform:translate(4px,8px)}}

.growth__curve{
  stroke-dasharray:710;
  stroke-dashoffset:calc((1 - var(--draw)) * 710px);
  filter:drop-shadow(0 0 7px rgba(212,175,55,.55));
}

/* A spark riding the head of the curve as it draws. Gated behind @supports
   so browsers without offset-path get no stray dot parked at the origin. */
.growth__head{opacity:0}
@supports (offset-path:path("M0 0L1 1")){
  .growth__head{
    offset-path:path("M-362 211Q-130 75 198 -205");
    offset-distance:calc(var(--draw) * 100%);
    offset-rotate:0deg;
    /* Peaks mid-draw, gone at both ends: 4d(1-d). */
    opacity:calc(var(--draw) * (1 - var(--draw)) * 4);
    filter:drop-shadow(0 0 8px rgba(255,243,207,.9));
  }
}

/* ---- Scene 5 on a phone ----
   The same correction as Scene 4, and for the same reason: the tiles and the
   curve run x -362 to +198, so the drawing is 560 units wide centred on -82,
   not on the origin the figure is anchored by. At 124svh the curve's foot and
   the first tile were off the left edge of a handset.

   560 of 1000 units means 158vw puts the composition at ~89% of the screen;
   the -82 unit offset is -8.2% of the figure's width, so +8.2% on top of the
   centring lands the picture on the anchor. Vertically the drawing is centred
   on +3 units, which rounds to nothing and is left alone.

   The radiant (.growth__glow) is deliberately not counted in that box — it is
   a soft ellipse reaching further out on both sides, and it is meant to bleed
   past the frame. .universe clips it. */
/* Same band as .devices — read the note there for where 27.5% - 10px and the
   third width term come from. Only the proportion differs: the tiles and the
   curve are 0.416 of the figure's width tall, so the band height converts
   through 1/0.416 = 2.40 instead of 2.62 — and the same min-height gate, for
   the same reason. */
@media (max-width:900px) and (min-height:560px){
  .growth{
    left:50%;
    top:calc(27.5% - 10px);
    width:min(89svh,158vw,132.2svh - 577px);
    height:min(89svh,158vw,132.2svh - 577px);
    translate:calc(-50% + 8.2%) -50%;
  }
}
@media (max-width:900px) and (max-height:559px){
  .growth{
    left:50%;
    top:44.3%;
    width:min(89svh,140vw);
    height:min(89svh,140vw);
    translate:calc(-50% + 8.2%) -50%;
  }
}
@media (prefers-reduced-motion:reduce){
  .growth__tile{animation:none;--par:0}
  .growth__glow{animation:none;translate:0}
  /* --draw is scroll-driven, not keyframed, so animation:none can't reach
     it — just show the finished curve. */
  .growth{--draw:1}
  .growth__head{opacity:0}
}

/* ------------------------------------------------------------
   SCENE 6 — PORTFOLIO: work emerging from the dark, fanned in 3D
   The one figure that isn't SVG. Cards rise out of black, and the fan
   opens as you scroll — reaching the concept's exact angles at the
   composition's peak, the same neutral-point trick Scene 4 uses.
   ------------------------------------------------------------ */
.works{
  /* fixed, not absolute: the fan now lives outside .universe (see the note in
     universe.php). Its old parent was itself fixed at inset:0, so the same
     left/top resolves to the same pixel — this is a change of ancestor, not of
     position. z-index 3 puts it over .story, like .works-nav, and the layer
     itself stays click-through so only the cards catch a pointer. */
  position:fixed;
  z-index:3;
  pointer-events:none;
  left:65%;          /* must match CENTRE_X in hero.js */
  top:44.3%;
  width:0;
  height:0;
  --v:var(--works,0);
  --t:var(--works-t,0);
  opacity:clamp(0,calc(var(--v) * 4.5),1);
  /* Shallow enough to read as depth, deep enough not to shear the outer
     cards into wedges at these rotations. */
  perspective:135svh;
  transform-style:preserve-3d;
}

/* Card geometry is now split in two: the CARD owns its size and colour, the
   SLOT owns where it sits and how far it is turned. That is what lets six
   cards share the concept's three positions — a card slides between slots
   while the fan itself never moves. Placement is `translate`, not left/top,
   so a slot change animates on the compositor instead of relayouting. */
.works__card{
  position:absolute;
  left:calc(var(--w) / -2);
  top:calc(var(--h) / -2);
  width:var(--w);
  height:var(--h);
  --in:0;
  --par:0;
  --ry:0;
  --sx:0svh;
  --sy:0svh;
  border-radius:2.2svh;
  opacity:var(--in);
  translate:var(--sx) var(--sy);
  transform:
    translateY(calc((1 - var(--in)) * 7svh + (.5 - var(--t)) * var(--par) * 1svh))
    rotateY(calc(var(--ry) * 1deg))
    scale(calc(.92 + var(--in) * .08));
  transition:translate .85s var(--ease),opacity .55s var(--ease);
  /* The photo cards are <button> so they can be opened; strip the UA's chrome
     so a button and the placeholder <div> beside it are the same object. The
     card rules own background and border, so neither is reset here. */
  appearance:none;
  padding:0;
  font:inherit;
  color:inherit;
  text-align:inherit;
}

/* Clickable only while scene 6 is actually up — story.js adds .is-live at the
   same threshold that lights the slider, so a card can never be pressed
   through a scene that has scrolled past. Off-stage cards are transparent and
   parked beyond the viewport edge: never a target, whatever the fan is doing. */
.works.is-live .works__card{pointer-events:auto;cursor:pointer}
.works__card[data-slot="pre"],
.works__card[data-slot="post"]{pointer-events:none}
.works__card:focus-visible{outline:2px solid var(--gold);outline-offset:6px}

/* The card is already a 3D-transformed, glowing object — a hover that moved or
   recoloured it would fight the fan. Lifting the rim is enough to say "this
   one opens", and it costs no layout. */
@media (hover:hover){
  .works.is-live .works__card.is-photo:hover{border-color:rgba(247,247,247,.5)}
}

/* The three slots, traced from assets/concept/scene6-portfolio.jpg. --sx/--sy
   are the CENTRE of each position (the old left/top values plus half the card
   they were written for), because a card is now placed by its own centre.
   The staggered --in thresholds belong to the slot, not the card, so the fan
   still deals itself left-to-right however the cards are ordered. */
.works__card[data-slot="0"]{
  --sx:-29.6svh; --sy:4.60svh; --par:3.2;
  --ry:calc(4 + var(--t) * 30);
  --in:clamp(0,calc((var(--v) - .04) * 4.8),1);
  z-index:1;
}
.works__card[data-slot="1"]{
  --sx:4.90svh; --sy:2.10svh; --par:2.0;
  --ry:calc(-1 + var(--t) * 4);          /* the hero card stays near frontal */
  --in:clamp(0,calc((var(--v) - .14) * 4.8),1);
  z-index:2;
}
.works__card[data-slot="2"]{
  --sx:37.55svh; --sy:4.85svh; --par:3.6;
  --ry:calc(-4 - var(--t) * 30);
  --in:clamp(0,calc((var(--v) - .24) * 4.8),1);
  z-index:1;
}
/* Off-stage, on the side each card will arrive from. Turned harder than any
   on-stage angle so they read as continuing round a carousel rather than
   sliding in flat. */
.works__card[data-slot="pre"] { --sx:-74svh; --sy:4.6svh;  --ry:30;  --in:0 }
.works__card[data-slot="post"]{ --sx:82svh;  --sy:4.85svh; --ry:-30; --in:0 }
.works__card > i{
  position:absolute;
  inset:0;
  border-radius:inherit;
  background:linear-gradient(150deg,transparent 30%,rgba(255,255,255,.16) 46%,transparent 60%);
  animation:workSheen 11s ease-in-out infinite;
}
@keyframes workSheen{0%,100%{opacity:.25}50%{opacity:.85}}

/* Each card sinks to near-black at its foot — "emerging from the dark" is
   literal here, the card is only lit at the top. The first three are the
   concept's own gold / purple / pink; the three added to fill the slider take
   their hues from the section palette so the fan still reads as one set.

   Heights run 3svh above the sizes traced from the concept frame; the widths
   are untouched. The card artwork in uploads/portfolio/cards/ is 9:16, far
   taller than any plate here, so background-size:cover was throwing away more
   than half of each image's height. Every svh of card height buys back roughly
   two of picture — the fan keeps the traced positions and the traced rhythm of
   sizes, the marks just sit in more of their own frame. */
.works__card--1{
  --w:29.2svh; --h:42.0svh;
  background:linear-gradient(168deg,#e8c95a 0%,#c9a636 34%,#4a3c14 66%,#100d07 92%);
  border:1px solid rgba(232,201,90,.55);
  box-shadow:0 0 7svh rgba(212,175,55,.26);
}
.works__card--2{
  --w:32.2svh; --h:43.4svh;
  background:linear-gradient(168deg,#7c3aff 0%,#5a1fd0 36%,#241063 68%,#0d0a1c 92%);
  border:1px solid rgba(124,58,255,.6);
  box-shadow:0 0 8svh rgba(109,40,255,.32);
}
.works__card--3{
  --w:30.7svh; --h:42.5svh;
  background:linear-gradient(168deg,#e8478f 0%,#c23a76 34%,#521832 68%,#170a12 92%);
  border:1px solid rgba(232,71,143,.55);
  box-shadow:0 0 7svh rgba(200,60,130,.28);
}
.works__card--4{
  --w:30.2svh; --h:42.8svh;
  background:linear-gradient(168deg,#3fd0c0 0%,#1e8a80 34%,#0d3a35 66%,#071310 92%);
  border:1px solid rgba(63,208,192,.5);
  box-shadow:0 0 7svh rgba(30,138,128,.28);
}
.works__card--5{
  --w:31.4svh; --h:43.0svh;
  background:linear-gradient(168deg,#ef5f77 0%,#c2384f 34%,#4e1620 66%,#150809 92%);
  border:1px solid rgba(239,95,119,.5);
  box-shadow:0 0 7svh rgba(194,56,79,.28);
}
.works__card--6{
  --w:29.8svh; --h:42.2svh;
  background:linear-gradient(168deg,#5f9bff 0%,#2f6be8 34%,#16306b 66%,#070c1a 92%);
  border:1px solid rgba(95,155,255,.5);
  box-shadow:0 0 7svh rgba(47,107,232,.3);
}

/* ---- Real work ----
   Once a project in the portfolio table has a cover_image, its card carries
   the photo instead of a placeholder gradient (universe.php sets it inline).
   The scene's whole read is "emerging from the dark", so a photo can't just
   sit there lit corner to corner: the scrim sinks its foot to near-black the
   same way every placeholder gradient does, and the rim goes neutral so the
   photo's own colour leads. The glow keeps the project's accent.

   Two shadows, not one: a wide 10svh halo that puts the card's colour into the
   air around it, and a tight 2.6svh one that keeps the edge itself lit. A
   single large blur spends its whole alpha budget far from the card, which is
   why simply raising the old 7svh value made the fan hazier without making it
   brighter. The placeholder cards keep their one shadow — they are a flat
   gradient and have no edge worth picking out. */
.works__card.is-photo{
  background-size:cover;
  background-position:center;
  border-color:rgba(247,247,247,.22);
  box-shadow:
    0 0 10svh var(--card-glow,rgba(212,175,55,.38)),
    0 0 2.6svh var(--card-glow,rgba(212,175,55,.38));
}
.works__card.is-photo::after{
  content:"";
  position:absolute;
  inset:0;
  border-radius:inherit;
  background:linear-gradient(168deg,transparent 34%,rgba(7,7,7,.36) 58%,rgba(7,7,7,.86) 82%,#070707 97%);
  pointer-events:none;
}

/* ------------------------------------------------------------
   SLIDER CONTROLS
   Shared by both sliders on the page — Scene 6's card fan and the
   Selected work band — so the two never drift apart.
   ------------------------------------------------------------ */
.slider-btn{
  display:grid;place-items:center;
  width:44px;height:44px;       /* a real touch target, not a glyph */
  border:1px solid var(--line);
  border-radius:100px;
  color:var(--grey);
  background:transparent;
  cursor:pointer;
  transition:color .4s,border-color .4s,background .4s,opacity .4s;
}
@media (hover:hover){.slider-btn:hover:not(:disabled){color:var(--white);border-color:rgba(212,175,55,.45);background:rgba(212,175,55,.07)}}
.slider-btn:disabled{opacity:.3;cursor:default}
.slider-btn:focus-visible{outline:1px solid var(--gold);outline-offset:3px}
.slider-btn__icon{width:18px;height:18px;display:block}

/* ---- Scene 6's slider ----
   Its own layer, above .story, for the reason given in universe.php. Follows
   the fan's anchor so it stays under the cards at every width, and is only
   reachable while Scene 6 is the scene on screen — story.js adds .is-live and
   the tabindex, exactly as it does for the Scene 3 palette. */
.works-nav{
  position:fixed;
  left:65%;                     /* must match CENTRE_X and .works */
  /* 26.5svh, not 25: the offset clears the deepest card in the fan (slot 2's
     centre plus half the tallest card), so it has to follow --h. */
  top:calc(44.3% + 26.5svh);
  translate:-50% 0;
  z-index:3;                    /* .story is 2 */
  display:flex;
  align-items:center;
  gap:1rem;
  pointer-events:none;
  /* Tracks the fan's own fade-in (.works is opaque from --works .22) so the
     controls are there for as long as there are cards to drive. */
  opacity:clamp(0,calc((var(--works,0) - .20) * 4),1);
  transition:opacity .45s var(--ease);
}
.works-nav.is-live .works-nav__btn{pointer-events:auto}

/* This pair sits over the atmosphere rather than the page, so it needs a
   ground of its own to stay legible against whatever the cards are doing. */
.works-nav__btn{background:rgba(7,7,7,.45);backdrop-filter:blur(6px)}
@media (hover:hover){/* Scoped one level up so it outranks .slider-btn:hover:not(:disabled), whose
   translucent gold would wash the dark ground straight off these two. */
.works-nav .works-nav__btn:hover{background:rgba(30,22,6,.72)}}

.works-nav__count{
  display:flex;align-items:baseline;gap:.4rem;
  font-family:var(--serif);font-size:.9rem;color:var(--grey-dim);
  min-width:5ch;justify-content:center;
}
.works-nav__count em{font-style:normal;color:var(--gold)}
.works-nav__count span{opacity:.5}

@media (max-width:900px){
  /* The fan is 97svh wide — far past a phone. Pull it in and flatten the
     rotations, which read as distortion rather than depth at this scale.

     It also has to move up, for the same reason .devices and .growth do: from
     900px down the copy is full width at the foot of the panel, and at 40%
     the centre card was sitting squarely on the headline — "Proof, not
     promises." was behind a project plate, and the slider's own buttons were
     on the paragraph under it.

     .55 rather than .62, and anchored at 47% rather than 50%, because the fan
     is not symmetric about its anchor: the slots run -44svh to +53svh, so
     centring the ANCHOR leaves 9svh more of the fan hanging off the right
     edge than the left. Half that offset, taken off the anchor, splits what
     bleeds evenly — which is what makes the outer two cards read as a
     carousel continuing rather than as cards that have been cut off.

     Vertically this scene has to fit TWO things between the fixed nav bar and
     the copy — the fan and the slider under it — so unlike .devices and
     .growth it is anchored from the top of that band rather than centred in
     it. Three variables, all measured off the fan's real extremes rather than
     off the middle card, because the three slots do not sit level: slot 1 is
     2.1svh below the anchor and slots 0 and 2 are 4.6 and 4.85svh below it.

       --fan-top   the scrolled bar's own height plus air. 6.5rem clears the
                   88px bar at 600px and under and the 99px one above it.
       --fan-lead  anchor to the HIGHEST card's top edge: 21.7svh (half a
                   card) less slot 1's own 2.1svh drop, times the scale.
       --fan-drop  anchor to the DEEPEST card's bottom: 21.7 + 4.85svh, times
                   the scale. This is the one the slider hangs off, and using
                   a middle-slot figure instead is what left the buttons
                   sitting on the outer two cards' bottom corners.

     Change --fan-scale and both of the others move with it, or the slider
     walks up into the fan. */
  .works,.works-nav{--fan-scale:.55;--fan-lead:10.8svh;--fan-drop:25.4svh;--fan-top:6.5rem}
  .works{perspective:180svh;scale:var(--fan-scale);left:47%;
         top:calc(var(--fan-top) + var(--fan-lead))}
  /* Follows the fan: below the deepest card, above the copy. */
  .works-nav{left:47%;top:calc(var(--fan-top) + var(--fan-drop) + .65rem)}
  /* One halo instead of two. The wide 10svh shadow is an 80px+ blur on a
     handset, on six cards, re-rendered every frame the fan turns; the tight
     one is what actually keeps the card's edge lit, so that is the one kept
     and it is opened up slightly to carry the colour on its own. */
  .works__card.is-photo{box-shadow:0 0 4svh var(--card-glow,rgba(212,175,55,.38))}
  /* backdrop-filter blurs whatever is behind the button on every frame the
     atmosphere moves, which is every frame. A solid ground reads the same
     against a near-black scene. */
  .works-nav__btn{backdrop-filter:none;background:rgba(7,7,7,.78)}
}
/* A short screen — a small handset, or a tablet in landscape. The fan at .55
   plus the slider under it does not fit the band between the bar and the copy
   below about 780px of viewport: on a 667px screen it wants 227px of a 181px
   gap, and no amount of moving it fixes that. Solving
   6.5rem + 46.15svh*s + .65rem + 44px <= the copy's top edge gives s <= .412
   at 667px and .421 at 700px, so .40 is the one value that clears both.

   The fan gives way rather than anything else here, because it is the only
   element in the scene whose size is arbitrary — the other figures are traced
   off the concept frames. */
@media (max-width:900px) and (max-height:780px){
  .works,.works-nav{--fan-scale:.40;--fan-lead:7.8svh;--fan-drop:18.5svh}
}
@media (prefers-reduced-motion:reduce){
  .works__card > i{animation:none;opacity:.4}
  .works__card{--par:0;transition:none}
  /* --ry is scroll-driven, so pin each slot at the concept's angle. */
  .works__card[data-slot="0"]{--ry:18}
  .works__card[data-slot="1"]{--ry:1}
  .works__card[data-slot="2"]{--ry:-18}
  .works-nav{transition:none}
}

/* ------------------------------------------------------------
   SCENE 7 — CONTACT: the story resolves
   Everything dissolves into a still, warm glow behind the finished
   emblem. presence()-driven opacity is enough on its own — unlike
   scenes 4-6 this figure never needs to build or travel, only breathe.
   ------------------------------------------------------------ */
.resolve{
  position:absolute;
  left:50%;
  top:55%;             /* matches presence()'s h*0.55 target centre, not CENTRE_X */
  width:110svh;
  height:110svh;
  translate:-50% -50%;
  border-radius:50%;
  background:radial-gradient(circle,rgba(212,175,55,.30) 0%,rgba(212,175,55,.12) 38%,transparent 70%);
  --v:var(--resolve,0);
  opacity:calc(var(--v) * .9);
}

.universe__grain{
  position:absolute;
  inset:0;
  opacity:.38;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.05'/%3E%3C/svg%3E");
}

/* ------------------------------------------------------------
   LAYER C — SCENE PANELS
   ------------------------------------------------------------ */
.story{position:relative;z-index:2}

/* The concept frames set type against a ~4.4vw margin, edge-aligned rather
   than inside the 1600px content column the inner pages use. */
.story .wrap{max-width:none;padding-inline:clamp(1.25rem,4.4vw,7rem)}

.scene{
  position:relative;
  min-height:110svh;
  display:flex;
  align-items:flex-end;
  padding-block:clamp(5rem,14vh,11rem);
}
/* Left scrim keeps type legible over the particle field. */
.scene::before{
  content:"";
  position:absolute;
  inset:0;
  pointer-events:none;
  background:linear-gradient(100deg, rgba(7,7,7,.86) 0%, rgba(7,7,7,.55) 38%, transparent 68%);
}
.scene__inner{position:relative;width:100%}

.scene__label{
  display:flex;
  align-items:center;
  gap:.9rem;
  font-size:.72rem;
  font-weight:600;
  letter-spacing:.28em;
  text-transform:uppercase;
  color:var(--grey);
  /* Concept frame: 52px of air between the label and the headline's cap line
     at 1999px wide. The display face's half-leading eats ~20px of any margin
     set here, so the declared value runs ahead of the visible gap. */
  margin-bottom:clamp(1rem,1.6vw,2.4rem);
}
.scene__rule{display:block;width:46px;height:1px;background:var(--gold);flex:none}

.scene__title{
  font-family:var(--serif);
  /* Measured off all seven frames: 128–135px at 1999px wide. */
  font-size:clamp(2.6rem,6.5vw,8.1rem);
  line-height:1.06;
  letter-spacing:-.025em;
  color:var(--white);
  max-width:16ch;
}
.scene__title em{font-style:italic;font-weight:300;color:var(--gold)}
/* Masked lines: the inner span rises out from behind the clipped outer one.
   padding-bottom gives descenders room so they aren't shaved by the mask. */
.scene__title .line{display:block;overflow:hidden;padding-bottom:.08em;margin-bottom:-.08em}
.scene__title .line > span{display:block}
/* 7.1vw ≈ 142px at the concept frame's 1999px width, measured off the art. */
.scene__title--hero{font-size:clamp(3rem,6.85vw,8.6rem)}

.scene__body{
  margin-top:1.8rem;
  max-width:38ch;
  color:var(--grey);
  font-weight:300;
  font-size:1.02rem;
  line-height:1.7;
}

.scene__foot{
  /* Concept frame: ~40px between the headline's descender line and the body. */
  margin-top:clamp(1.6rem,3.15vw,4.2rem);
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  gap:1.4rem 2.6rem;
}
.scene__foot .scene__body{margin-top:0}
.scene__cta{flex:none}

/* Scene 1 chrome */
.scene__scroll{
  position:absolute;
  /* The panel is 110svh, so offset by the overflow or the cue lands below
     the fold and never gets seen. */
  bottom:calc(10svh + 1.8rem);
  left:70%;
  transform:translateX(-50%);
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:.6rem;
  font-size:.66rem;
  letter-spacing:.26em;
  text-transform:uppercase;
  color:var(--grey-dim);
}
.scene__scroll i{
  display:block;width:1px;height:42px;
  background:linear-gradient(var(--gold),transparent);
  animation:sceneScroll 2.2s var(--ease) infinite;
}
@keyframes sceneScroll{0%{transform:scaleY(0);transform-origin:top}50%{transform:scaleY(1);transform-origin:top}
  51%{transform:scaleY(1);transform-origin:bottom}100%{transform:scaleY(0);transform-origin:bottom}}

.story__skip{
  position:absolute;
  right:var(--pad);
  bottom:2rem;
  font-size:.68rem;
  letter-spacing:.2em;
  text-transform:uppercase;
  color:var(--grey-dim);
  border-bottom:1px solid transparent;
  transition:color .35s,border-color .35s;
}
@media (hover:hover){.story__skip:hover{color:var(--gold);border-color:var(--gold)}}

/* Scene 7 */
.scene--center{align-items:center;text-align:center}
/* Purple accent at the panel's top, the studio's signature purple token.
   Layered ahead of the existing dark vignette: multiple backgrounds
   paint first-listed-on-top. */
.scene--center::before{
  background:
    radial-gradient(ellipse 60% 34% at 50% 0%, rgba(109,40,255,.28), transparent 70%),
    radial-gradient(ellipse 70% 60% at 50% 50%, rgba(7,7,7,.82), rgba(7,7,7,.4) 55%, transparent 75%);
}
.scene--center .scene__inner{display:flex;flex-direction:column;align-items:center}
.scene--center .scene__label{justify-content:center}
.scene--center .scene__title{max-width:20ch}
.scene__emblem{width:clamp(64px,7vw,92px);height:auto;color:var(--gold);margin-bottom:2.2rem;filter:drop-shadow(0 0 26px rgba(212,175,55,.35))}
.scene--center .scene__cta{margin-top:2.4rem}
.scene__sign{margin-top:1.6rem;font-size:.92rem;color:var(--grey-dim);display:flex;flex-wrap:wrap;gap:.5rem;justify-content:center}
.scene__sign a{color:var(--grey);transition:color .35s}
@media (hover:hover){.scene__sign a:hover{color:var(--gold)}}
.scene__sign em{font-family:var(--serif);font-style:italic;color:var(--gold)}

/* ------------------------------------------------------------
   LAYER C — READABLE CONTENT BANDS
   ------------------------------------------------------------ */
.band{position:relative;padding-block:clamp(5rem,12vh,8.5rem)}
.band::before{
  content:"";
  position:absolute;
  inset:0;
  pointer-events:none;
  background:radial-gradient(ellipse 92% 70% at 50% 50%, rgba(7,7,7,.90), rgba(7,7,7,.62) 58%, rgba(7,7,7,.25));
}
.band__inner{position:relative}

.band__eyebrow{
  display:flex;align-items:center;gap:.9rem;
  font-size:.72rem;font-weight:600;letter-spacing:.28em;text-transform:uppercase;
  color:var(--gold);
}
.band__title{
  font-family:var(--serif);
  font-size:clamp(2rem,4.6vw,3.8rem);
  line-height:1.03;
  letter-spacing:-.02em;
  margin-top:1.2rem;
  max-width:20ch;
}
.band__title em{font-style:italic;font-weight:300;color:var(--gold)}
.band__cols{
  display:grid;grid-template-columns:1fr 1fr;gap:2rem clamp(2rem,5vw,4rem);
  margin-top:2.2rem;color:var(--grey);font-weight:300;line-height:1.75;max-width:80ch;
}
.band__cols em.gold,.gold{color:var(--gold);font-family:var(--serif);font-style:italic}

.link-gold{
  display:inline-flex;align-items:center;gap:.6rem;
  margin-top:2.6rem;
  font-size:.82rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;
  color:var(--gold);
  border-bottom:1px solid rgba(212,175,55,.35);
  padding-bottom:.35rem;
  transition:border-color .4s,gap .4s var(--ease);
}
.link-gold::after{content:"→";font-size:1rem;line-height:1}
@media (hover:hover){.link-gold:hover{border-color:var(--gold);gap:1rem}}

/* Services — one card per pillar, laid on a six-column bed so a row can be
   divided three ways or two.

   auto-fit was the first attempt at holding five cards and it is exactly what
   stranded the fifth under a row of four: it packs in as many as will fit and
   leaves the remainder alone on the last line, however few that is.

   Here the last row claims the width it is short of instead. Three to a row
   normally; when the final row would hold two, those two widen to a half
   each; when it would hold one, that one takes the whole row. Five composes
   as 3 + 2, and no count leaves a card sitting on its own at card width. */
.pillars-grid{
  display:grid;
  grid-template-columns:repeat(6,1fr);
  gap:clamp(1.2rem,2.4vw,2rem);
  margin-top:3.2rem;
}
.pillar-card{grid-column:span 2}

/* Starts a row with exactly one card after it — the pair take a half each. */
.pillars-grid > .pillar-card:nth-child(3n+1):nth-last-child(2),
.pillars-grid > .pillar-card:nth-child(3n+1):nth-last-child(2) ~ .pillar-card{grid-column:span 3}
/* Starts a row with nothing after it — it takes the row. */
.pillars-grid > .pillar-card:nth-child(3n+1):last-child{grid-column:span 6}

/* Two on a row instead of three: the same idea, halved. The span rules above
   are written against the six-column bed, so they have to stand down here or
   a card would try to span three of two columns. */
@media (max-width:1024px){
  .pillars-grid{grid-template-columns:repeat(2,1fr)}
  .pillars-grid > .pillar-card,
  .pillars-grid > .pillar-card:nth-child(3n+1):nth-last-child(2),
  .pillars-grid > .pillar-card:nth-child(3n+1):nth-last-child(2) ~ .pillar-card{grid-column:span 1}
  .pillars-grid > .pillar-card:nth-child(3n+1):last-child,
  .pillars-grid > .pillar-card:last-child:nth-child(odd){grid-column:span 2}
}
@media (max-width:560px){
  .pillars-grid{grid-template-columns:1fr}
  .pillars-grid > .pillar-card,
  .pillars-grid > .pillar-card:last-child:nth-child(odd){grid-column:span 1}
}
.pillar-card{
  position:relative;
  padding:clamp(1.4rem,2.2vw,2rem);
  border:1px solid var(--line);
  border-radius:14px;
  background:linear-gradient(165deg,rgba(17,17,17,.72),rgba(7,7,7,.35));
  transition:border-color .5s,transform .6s var(--ease);
}
@media (hover:hover){.pillar-card:hover{border-color:rgba(212,175,55,.38);transform:translateY(-6px)}}
.pillar-card__head{display:flex;align-items:center;justify-content:space-between;gap:1rem}
.pillar-card__no{font-family:var(--serif);font-size:.95rem;color:var(--gold)}
/* The pillar's mark. Held back at rest so the number and the headline lead,
   and lit on hover along with the service list under it. */
.pillar-card__icon{
  width:clamp(30px,2.4vw,38px);
  height:auto;
  color:var(--gold);
  opacity:.42;
  transition:opacity .5s var(--ease),transform .6s var(--ease);
}
@media (hover:hover){.pillar-card:hover .pillar-card__icon{opacity:.95;transform:translateY(-2px)}}
@media (prefers-reduced-motion:reduce){
  .pillar-card__icon{transition:none}
  @media (hover:hover){.pillar-card:hover .pillar-card__icon{transform:none}}
}
.pillar-card__title{font-size:clamp(1.25rem,1.9vw,1.6rem);line-height:1.1;margin:.9rem 0 .7rem}
.pillar-card__lead{color:var(--grey);font-size:.9rem;font-weight:300;line-height:1.6}
.pillar-card__list{list-style:none;margin-top:1.4rem;display:flex;flex-direction:column;gap:.5rem}
.pillar-card__list li{
  font-size:.84rem;color:var(--grey-dim);padding-left:1rem;position:relative;transition:color .4s;
}
.pillar-card__list li::before{content:"";position:absolute;left:0;top:.62em;width:5px;height:1px;background:var(--gold);opacity:.7}
@media (hover:hover){.pillar-card:hover .pillar-card__list li{color:var(--grey)}}

/* Process */
.steps-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(1.4rem,3vw,2.6rem);margin-top:3.2rem}
.step{padding-top:1.8rem;border-top:1px solid var(--line)}
.step__no{font-family:var(--serif);font-size:.95rem;color:var(--gold)}
.step__title{font-size:clamp(1.3rem,2.2vw,1.85rem);margin:.8rem 0 .7rem}
.step__text{color:var(--grey);font-size:.92rem;font-weight:300;line-height:1.65}

/* ---------------- Selected work — the gallery ----------------
   This band was a horizontally scrolling row of text cards. It is a
   mosaic of brand plates now: the work is the point of a work band,
   and nothing on the old homepage showed any of it.

   Every tile wears its project's --accent as the ground and CONTAINS
   the mark rather than cropping it. That is not a preference — these
   are logos, and a cropped logo is a broken one. Containing also
   means the tile shape is free, which is what lets the wall be
   irregular without hurting anything in it.

   The palette strip along the bottom edge is the idea: the same
   measured colours the case study lists, ten brands at a glance. It
   ties the homepage to /work/{slug} without a word of copy.
   ------------------------------------------------------------ */
.work-head{display:flex;flex-direction:column;gap:.9rem}
.work-head__title{margin-bottom:.4rem}

.gal{
  display:grid;
  grid-template-columns:repeat(12,1fr);
  gap:clamp(.7rem,1.6vw,1.3rem);
  margin:clamp(2rem,4vh,3rem) 0 clamp(2.2rem,5vh,3.4rem);
}

/* The rhythm: 7/5 across one row, then 4/4/4 across the next — a
   five-tile repeat, so ten featured projects fill it exactly twice
   and the wall is irregular without ever being random. */
.gal__tile{
  position:relative;
  grid-column:span 4;
  aspect-ratio:1/1;
  border-radius:16px;
  overflow:hidden;
  border:1px solid rgba(var(--accent-rgb),.26);
  background:
    radial-gradient(120% 100% at 20% 0%, rgba(var(--accent-rgb),.42), transparent 66%),
    linear-gradient(160deg, rgba(var(--accent-rgb),.20), #0a0a0b 72%);
  transition:transform .8s var(--ease),border-color .5s var(--ease),box-shadow .8s var(--ease);
  isolation:isolate;
}
.gal__tile:nth-child(5n+1){grid-column:span 7;aspect-ratio:16/10}
.gal__tile:nth-child(5n+2){grid-column:span 5;aspect-ratio:4/3}

@media (hover:hover){
  .gal__tile:hover{
    transform:translateY(-8px);
    border-color:rgba(var(--accent-rgb),.7);
    box-shadow:0 40px 80px -46px rgba(var(--accent-rgb),.95);
  }
}
.gal__tile:focus-visible{outline:2px solid var(--gold-soft);outline-offset:4px}

/* ---- The plate ---- */
.gal__plate{
  position:absolute;inset:0;
  display:grid;place-items:center;
  padding:clamp(.9rem,2vw,1.8rem);
  /* The bottom third of a tile belongs to the meta block and the palette
     strip, so the mark has to stop above them. Sized off the tallest thing
     that can sit there: a two-line title plus the category plus the strip.
     Too small and the artwork runs under its own caption, which is what it
     was doing on the square tiles. */
  padding-bottom:clamp(3.6rem,6vw,5.2rem);
}
.gal__plate img{
  max-width:100%;max-height:100%;
  width:auto;height:auto;
  object-fit:contain;
  border-radius:7px;
  transition:transform 1.1s var(--ease);
}
@media (hover:hover){
  .gal__tile:hover .gal__plate img{transform:scale(1.04)}
}
.gal__plate .plate{position:absolute;inset:0;width:100%;height:100%;color:#fff;opacity:.5}

/* A wash that deepens on hover so the title has something to sit on.
   Bottom-weighted: the mark is centred, the text is not. */
.gal__veil{
  position:absolute;inset:0;
  background:linear-gradient(180deg,transparent 44%,rgba(6,6,8,.82));
  opacity:.72;
  transition:opacity .6s var(--ease);
}
@media (hover:hover){
  .gal__tile:hover .gal__veil{opacity:.94}
}

/* ---- Meta ---- */
.gal__meta{
  position:absolute;left:0;right:0;bottom:0;
  z-index:2;
  display:flex;align-items:flex-end;gap:.8rem;
  padding:clamp(.9rem,2vw,1.4rem);
  /* Clears the palette strip, which is flush to the edge below it. */
  padding-bottom:clamp(1.4rem,2.4vw,1.9rem);
}
.gal__no{
  font-family:var(--serif);
  font-size:.82rem;
  color:var(--accent);
  flex:none;line-height:1.4;
}
.gal__text{display:flex;flex-direction:column;gap:.15rem;min-width:0}
.gal__title{
  font-family:var(--serif);
  font-size:clamp(1rem,1.9vw,1.5rem);
  line-height:1.15;letter-spacing:-.01em;
  color:var(--white);
  /* "KM Law Advocates & Associates" is four lines on a small tile and would
     climb over the mark. Two, then ellipsis. */
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;
  overflow:hidden;
}
.gal__cat{
  font-size:.68rem;letter-spacing:.14em;text-transform:uppercase;
  color:var(--grey-dim);
  /* Long categories would wrap a small tile to three lines. */
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}

/* ---- The palette strip ----
   Flush to the bottom edge, full bleed, and it GROWS on hover — the
   one piece of motion on the tile that says something rather than
   just moving. */
.gal__pal{
  position:absolute;left:0;right:0;bottom:0;
  z-index:3;
  display:flex;
  height:6px;
  transition:height .5s var(--ease);
}
.gal__pal i{flex:1;background:var(--sw)}
@media (hover:hover){
  .gal__tile:hover .gal__pal{height:12px}
}

/* ---- Narrow ---- */
@media (max-width:900px){
  .gal{grid-template-columns:repeat(6,1fr)}
  .gal__tile,
  .gal__tile:nth-child(5n+1),
  .gal__tile:nth-child(5n+2){grid-column:span 3;aspect-ratio:4/3}
}
@media (max-width:560px){
  .gal{grid-template-columns:1fr}
  .gal__tile,
  .gal__tile:nth-child(5n+1),
  .gal__tile:nth-child(5n+2){grid-column:span 1;aspect-ratio:16/10}
}

@media (prefers-reduced-motion:reduce){
  @media (hover:hover){
    .gal__tile:hover{transform:none}
    .gal__tile:hover .gal__plate img{transform:none}
  }
}

/* ---------------- Testimonials ----------------
   Structure adapted from assets/concept/testimonial.jpg — the oversized quote
   mark, gold star rating, pulled headline over a supporting line, and an
   avatar beside the attribution. Rendered dark: the reference is a light
   theme on grey, and white cards would tear a hole in this palette, so the
   card keeps the same glass treatment the rest of the story uses. */
/* Three real columns, each its own stack. CSS multi-column would balance the
   cards by height automatically, which packs them neatly but makes placement
   uncontrollable — you cannot ask for a given card to sit in a given column.
   A grid of flex columns keeps the staggered look AND lets 'col' decide.
   `align-items:start` is what stops the columns stretching to equal height. */
.quotes-wall{
  display:grid;
  grid-template-columns:repeat(3,1fr);
  gap:clamp(1.2rem,2.4vw,2rem);
  align-items:start;
  margin-top:2.6rem;
}
.quotes-col{
  display:flex;
  flex-direction:column;
  gap:clamp(1.2rem,2.4vw,2rem);
  min-width:0;
}
.quote{
  position:relative;
  /* Grid, not flex: the photo variants place the image beside the copy, and
     figcaption must stay a direct child of figure — so there is no wrapper to
     flex against. Grid placement does it without one. */
  display:grid;
  break-inside:avoid;
  border:1px solid var(--line);
  border-radius:18px;
  padding:clamp(1.6rem,2.5vw,2.3rem);
  background:linear-gradient(160deg,rgba(17,17,17,.72),rgba(7,7,7,.32));
  transition:border-color .5s var(--ease),transform .5s var(--ease);
}
/* Explicit order so a variant can lift the attribution to the top without
   the markup changing. */
.quote__stars{order:2}
.quote__lead{order:3}
.quote__text{order:4}
.quote__by{order:5}
/* A thread of gold along the top edge — the signature, not a highlight. */
.quote::before{
  content:"";
  position:absolute;
  inset:0 0 auto;
  height:1px;
  background:linear-gradient(90deg,transparent,rgba(212,175,55,.55),transparent);
}
@media (hover:hover){.quote:hover{border-color:rgba(212,175,55,.4);transform:translateY(-4px)}}

/* Set in the display serif and kept faint — watermark, not punctuation.
   Sized and placed to sit inside the card, because two of the three variants
   need overflow:visible for their tail and overhanging avatar. */
.quote__mark{
  position:absolute;
  top:.2rem;
  right:1rem;
  font-family:var(--serif);
  font-size:5.4rem;
  line-height:1;
  color:var(--gold);
  opacity:.13;
  pointer-events:none;
}

.quote__stars{display:flex;gap:.32rem;margin-bottom:1.15rem}
.quote__stars svg{
  width:15px;height:15px;
  fill:var(--gold-soft);
  filter:drop-shadow(0 0 5px rgba(212,175,55,.45));
}
.quote__stars svg.is-dim{fill:var(--line);filter:none}

.quote__lead{
  font-family:var(--serif);
  font-size:clamp(1.25rem,2.1vw,1.75rem);
  line-height:1.28;
  letter-spacing:-.015em;
  color:var(--white);
  margin-bottom:.85rem;
}
/* No font-family: the supporting line inherits the body sans, so it sits
   under the serif lead instead of competing with it. */
.quote__text{font-size:.98rem;line-height:1.72;font-weight:300;color:var(--grey)}

.quote__by{
  display:flex;
  align-items:center;
  gap:.9rem;
  margin-top:1.8rem;
  padding-top:1.4rem;
  border-top:1px solid var(--line);
}
.quote__avatar{
  flex:none;
  width:46px;height:46px;
  border-radius:50%;
  overflow:hidden;
  display:grid;
  place-items:center;
  font-size:.82rem;
  font-weight:600;
  letter-spacing:.06em;
  color:var(--gold);
  background:radial-gradient(circle at 32% 28%,rgba(109,40,255,.5),rgba(7,7,7,.9));
  border:1px solid rgba(212,175,55,.45);
}
.quote__avatar img{width:100%;height:100%;object-fit:cover;display:block}
.quote__id{display:flex;flex-direction:column;gap:.15rem;min-width:0}
.quote__name{color:var(--gold);font-weight:600;font-size:.92rem}
.quote__role{color:var(--grey-dim);font-size:.84rem}

/* ---- Variant: bubble ----
   Speech bubble with a tail at the lower left, attribution reversed so the
   avatar sits on the outer edge, as in the sheet's first card. */
.quote--bubble{overflow:visible;border-radius:18px 18px 18px 4px}
.quote--bubble::after{
  content:"";
  position:absolute;
  left:-1px;
  bottom:-13px;
  width:22px;
  height:22px;
  /* Solid, matching the foot of the card's gradient — a gradient cannot be
     carried across a rotated pseudo-element cleanly. */
  background:rgba(9,9,9,.97);
  border-left:1px solid var(--line);
  border-bottom:1px solid var(--line);
  border-radius:0 0 0 5px;
  transform:skewY(24deg);
}
.quote--bubble .quote__by{flex-direction:row-reverse;justify-content:flex-end;gap:.85rem}
.quote--bubble .quote__id{text-align:right}

/* ---- Variant: star ----
   Avatar overhangs the top edge, everything centred beneath it. */
.quote--star{overflow:visible;margin-top:1.9rem;padding-top:2.6rem;text-align:center}
.quote--star .quote__mark{display:none}       /* the avatar owns the top edge */
.quote--star .quote__avatar{
  position:absolute;
  top:-24px;
  left:50%;
  translate:-50% 0;
  width:52px;
  height:52px;
  box-shadow:0 0 0 6px var(--bg);             /* punches the card's border line */
}
.quote--star .quote__stars{justify-content:center}
.quote--star .quote__by{justify-content:center;text-align:center}
.quote--star .quote__id{align-items:center}

/* ---- Variant: plain ----
   The sheet's quietest card: left aligned, no tail, no overhang. Base
   styles already describe it, so it only needs the clip for the mark. */
.quote--plain{overflow:hidden}

/* ---- The photo variants ----
   Images are placeholders (assets/images/testimonials/) and ship as SVG, so
   they need explicit box sizing — an SVG with a viewBox has no intrinsic
   pixel size to fall back on the way a JPG does. */
.quote__photo{display:block;overflow:hidden;border-radius:12px;background:#0b0b0b}
.quote__photo img{display:block;width:100%;height:100%;object-fit:cover}

/* split — photo down the left, copy to its right (the sheet's rows 2 and 3). */
.quote--split{
  overflow:hidden;
  grid-template-columns:clamp(84px,26%,116px) 1fr;
  column-gap:clamp(1rem,1.8vw,1.4rem);
  align-items:start;
}
.quote--split .quote__photo{grid-row:1 / -1;grid-column:1;aspect-ratio:5 / 7}
.quote--split > *:not(.quote__photo){grid-column:2}
.quote--split .quote__mark{display:none}      /* no room beside the image */
.quote--split .quote__by{margin-top:1.2rem;padding-top:1.1rem}

/* portrait — tall image on top, copy beneath, name set as a signature.
   The sheet's third card, and the one that carries the section. */
.quote--portrait{overflow:hidden;padding:0}
.quote--portrait .quote__photo{
  border-radius:0;
  aspect-ratio:4 / 3;
  margin-bottom:clamp(1.3rem,2vw,1.7rem);
}
.quote--portrait > *:not(.quote__photo){
  margin-left:clamp(1.6rem,2.5vw,2.3rem);
  margin-right:clamp(1.6rem,2.5vw,2.3rem);
}
.quote--portrait .quote__by{margin-bottom:clamp(1.6rem,2.5vw,2.3rem)}
.quote--portrait .quote__mark{display:none}
/* Signed rather than credited — the studio's own motif, applied to the client. */
.quote--portrait .quote__name{
  font-family:var(--serif);
  font-style:italic;
  font-weight:400;
  font-size:1.24rem;
  letter-spacing:-.01em;
}

/* Below the three-column layout the hand-placed columns stop making sense —
   `display:contents` dissolves the wrappers so the six cards become direct
   children again and CSS multi-column can rebalance them for the narrower
   space. Placement is a desktop concern; fitting is the priority here. */
@media (max-width:1024px){
  .quotes-wall{display:block;columns:2;column-gap:clamp(1.2rem,2.4vw,2rem)}
  .quotes-col{display:contents}
  .quote{margin-bottom:clamp(1.2rem,2.4vw,2rem)}
}
@media (max-width:720px){
  .quotes-wall{columns:1}
  /* Side-by-side collapses badly in one narrow column. */
  .quote--split{grid-template-columns:1fr}
  .quote--split .quote__photo{grid-row:auto;grid-column:1;aspect-ratio:16 / 9;margin-bottom:1.2rem}
  .quote--split > *:not(.quote__photo){grid-column:1}
}

/* ------------------------------------------------------------
   SCENE RAIL
   ------------------------------------------------------------ */
.rail{
  position:fixed;
  right:clamp(1rem,2.4vw,2.4rem);
  top:50%;
  transform:translateY(-50%);
  z-index:60;
  display:flex;
  flex-direction:column;
  align-items:flex-end;
  gap:1.1rem;
}
.rail__item{
  display:flex;align-items:center;gap:.7rem;
  font-size:.66rem;letter-spacing:.2em;text-transform:uppercase;
  color:var(--grey-dim);
  opacity:.65;
  transition:color .45s,opacity .45s;
}
.rail__no{font-variant-numeric:tabular-nums}
@media (hover:hover){.rail__item:hover{color:var(--grey);opacity:1}}
.rail__item.is-active{color:var(--gold);opacity:1}
/* The rail is part of the story; it retires once the story ends. */
.rail{transition:opacity .6s var(--ease),visibility .6s}
/* !important, and it has to be: the arrival timeline in story.js fades the
   rail in with GSAP, which leaves an INLINE opacity:1 on the element. A class
   cannot outrank an inline style, so without this the rail never faded — it
   waited out the transition and then vanished on the visibility flip alone.
   The visibility half is kept so a retired rail is also out of the tab order
   and off the accessibility tree, not merely transparent. */
.rail.is-gone{opacity:0 !important;visibility:hidden}

/* Reserve the rail's gutter so it never sits on top of content. */
@media (min-width:1025px){
  .scene:not(.scene--center) .scene__inner,
  .band__inner{padding-right:calc(var(--pad) + 96px)}
}

/* ------------------------------------------------------------
   ENTRANCE CHOREOGRAPHY
   Hidden state only applies once story.js confirms it can reveal,
   so a JS failure leaves the page fully readable.
   ------------------------------------------------------------ */
/* Scene 1's chrome is faded in by the arrival timeline; only hide it when
   story.js has confirmed it can run that timeline. */
.js-intro .scene--first .scene__scroll,
.js-intro .scene--first .story__skip,
.js-intro .rail{opacity:0}

.js-story .scene__inner > *,
.js-story .band__inner > *{
  opacity:0;
  transform:translateY(30px);
  transition:opacity .95s var(--ease), transform .95s var(--ease);
}
.js-story .scene.is-in .scene__inner > *,
.js-story .band.is-in .band__inner > *{opacity:1;transform:none}
.js-story .scene.is-in .scene__inner > *:nth-child(2),
.js-story .band.is-in .band__inner > *:nth-child(2){transition-delay:.10s}
.js-story .scene.is-in .scene__inner > *:nth-child(3),
.js-story .band.is-in .band__inner > *:nth-child(3){transition-delay:.20s}
.js-story .scene.is-in .scene__inner > *:nth-child(4),
.js-story .band.is-in .band__inner > *:nth-child(4){transition-delay:.30s}
.js-story .scene.is-in .scene__inner > *:nth-child(5),
.js-story .band.is-in .band__inner > *:nth-child(5){transition-delay:.40s}

/* ------------------------------------------------------------
   RESPONSIVE
   ------------------------------------------------------------ */
@media (max-width:1024px){
  .rail{display:none}
  /* .pillars-grid handles its own breakpoints beside its span rules — the
     two have to change together or a card spans columns that aren't there. */
  .steps-grid{grid-template-columns:1fr 1fr}
  /* The scrim turns vertical here because the copy spans the full width — a
     diagonal one would leave half the text over bare particles.
     It must still CLEAR at the top, though: .scene is align-items:flex-end, so
     the copy sits in the bottom third and everything above it is meant to be
     the universe. The previous values started at 55% black and reached 92%,
     which put a near-opaque curtain over the whole animation on every phone —
     the WebGL was rendering the entire time, behind a wall.
     Transparent down to a third, then darkening only where the text is. */
  .scene::before{
    background:linear-gradient(180deg,
      transparent 0%,
      transparent 30%,
      rgba(7,7,7,.34) 50%,
      rgba(7,7,7,.76) 74%,
      rgba(7,7,7,.92) 100%);
  }
}
@media (max-width:720px){
  .scene{min-height:92svh}
  .scene__title{max-width:none}
  /* .scene__scroll offsets its bottom by 10svh because the panel is 110svh
     and the cue would otherwise sit below the fold. The panel is 92svh here,
     so that offset is 10svh of thin air that was pushing the cue up into the
     CTA. Nothing to correct for at this width. */
  .scene__scroll{bottom:1.8rem}
  /* .quotes-wall is multi-column and collapses in its own block below. */
  .band__cols{grid-template-columns:1fr}
  .scene__foot{gap:1.2rem}
  .story__skip{right:auto;left:var(--pad)}
}
@media (max-width:560px){
  .steps-grid{grid-template-columns:1fr}
}

@media (prefers-reduced-motion:reduce){
  .universe__fallback{opacity:1!important}
  .scene__scroll i{animation:none}
  .js-story .scene__inner > *,
  .js-story .band__inner > *{opacity:1;transform:none}
}

/* ------------------------------------------------------------
   BAND · THE RECORD
   The band that holds the testimonials slot until the quotes are
   real. Deliberately plain next to the scenes around it: the point
   it makes is "all of this is checkable", and a figure that has
   been decorated reads as a figure that has been chosen.
   ------------------------------------------------------------ */
.proof__lede{
  max-width:52ch;
  margin-top:1.4rem;
  font-size:clamp(1rem,1.4vw,1.2rem);
  font-weight:300;
  color:var(--grey);
}

.proof__grid{
  list-style:none;
  display:grid;
  grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));
  gap:clamp(1.6rem,3.5vw,3rem);
  margin-top:clamp(2.8rem,6vw,4.5rem);
}

.proof__item{
  display:flex;flex-direction:column;align-items:flex-start;
  padding-top:1.6rem;
  border-top:1px solid var(--line);
}

/* The word that opens each item. Serif italic and gold — one word, not a
   figure: a count invites the reader to weigh the studio against a number
   they have already imagined, and there is no total that wins that.

   All three are the same element at the same size, so their labels sit on
   one line by construction. An earlier version mixed digits and a word and
   needed padding to fake the alignment; that is gone. */
.proof__fig{
  font-family:var(--serif);
  font-style:italic;
  font-size:clamp(1.75rem,3.4vw,2.6rem);
  line-height:1.15;
  letter-spacing:-.02em;
  color:var(--gold);
}

.proof__lab{
  margin-top:.9rem;
  font-size:.74rem;font-weight:600;letter-spacing:.2em;text-transform:uppercase;
  color:var(--white);
}
.proof__note{
  margin-top:.8rem;
  font-size:.95rem;font-weight:300;line-height:1.65;
  color:var(--grey-dim);
  max-width:34ch;
}

.proof__links{list-style:none;margin-top:1.1rem;display:grid;gap:.45rem}
.proof__links a{
  font-size:.95rem;
  color:var(--white);
  border-bottom:1px solid rgba(212,175,55,.45);
  padding-bottom:1px;
  transition:color .35s var(--ease),border-color .35s var(--ease);
}
@media (hover:hover){
  .proof__links a:hover{color:var(--gold);border-color:var(--gold)}
}

@media (max-width:700px){
  .proof__item{padding-top:1.2rem}
  .proof__note{max-width:none}
}
