/* ============================================================
   "The Map You Travel" — Quill & Compass marketing journey
   Grammar: 15th–16th-c. Northern-European illuminated manuscript
   + cartographer's workshop, staged as a PAPER PUPPET THEATRE.

   The page is a proscenium the visitor sits inside. Every beat is a
   scene change: flats slide in on runners and overshoot, footlights
   throw warm light UP the figures, the camera pushes into the set.
   Tokens = the design firewall (brief §8).
   ============================================================ */

/* The @font-face declarations, the parchment/ink/gold palette, the type stack,
   the easing curves and --measure all moved to tokens.css on 2026-08-04, so
   that index.html could adopt this same look without a second copy of the
   values drifting out of step. tokens.css MUST be loaded before this file —
   map.html links it first. Everything below is stage machinery that only this
   page has. */
:root{
  /* ---- THEATRE DIALS (all live-tunable via ?tune — see dev/tune) ---------
     These carry the committed defaults. The tune panel only overrides them. */
  --cam-push:0.28;        /* how far the world pushes in across a beat */
  --cam-drift:26px;       /* vertical camera drift across a beat */
  --cam-lead-push:0.10;   /* extra push when the camera finds the chosen lead.
                             Anchored low (on the lead's feet), so this compounds
                             fast — 0.3 magnified the company straight off-stage. */
  --foot-glow:0.58;       /* footlight intensity */
  --foot-reach:44%;       /* how far up the stage the footlights throw */
  --foot-warm:#f08433;    /* footlight colour */
  --spot-size:30%;        /* follow-spot radius */
  --spot-glow:0.42;
  --floor-depth:26%;      /* how much of the stage is boards */
  --sway-amp:1.5deg;      /* fallback amplitude when a puppet declares no --amp */
  --sway-period:6s;       /* fallback period when a puppet declares no --sd */
  --sway-scale:1;         /* global MULTIPLIER on every puppet's own amplitude */
  --sway-rate:1;          /* global MULTIPLIER on every puppet's own period */
  --far-fade:0.34;        /* atmospheric perspective: distance fades toward paper */
  --mid-fade:0.62;
  --drape-part:0s;        /* set to a duration by JS on curtain-up */
}

*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{
  font-family:var(--font-body);
  color:var(--ink-700);
  background-color:var(--paper-300);
  background-image:
    url('./assets/parchment-grain.svg'),
    url('./assets/parchment-grid.svg'),
    radial-gradient(120% 80% at 50% -10%, rgba(216,184,120,.0) 40%, rgba(74,51,32,.10) 100%);
  background-size:220px 220px, 128px 128px, cover;
  background-repeat:repeat,repeat,no-repeat;
  background-blend-mode:soft-light,multiply,normal;
  line-height:1.65;
  -webkit-font-smoothing:antialiased;
  overflow-x:hidden;
}

/* focus visibility (WCAG 2.4.7) */
:focus-visible{outline:2px solid var(--ink-900);outline-offset:3px;border-radius:4px}
.seal-cta:focus-visible,.cast:focus-visible{outline:3px solid var(--gold-bright);outline-offset:4px}

/* the sprite sheet itself never renders — only its <use> references do */
.cast-defs{position:absolute;width:0;height:0;overflow:hidden}
/* woodcut voids. A cut is UNPRINTED PAPER, not a second ink — which is why it is
   a fixed pale value rather than the backdrop behind it: on a real cut-out the
   uninked paper reads the same whatever it is standing in front of. Cuts do the
   work outlines can't: separating a beard from a chest, an arm from a torso. */
/* .cut / .deep moved to cast.css — they are the cast's drawing contract, not this
   page's, and realms.html renders the same art without ever loading map.css. */

/* ---- first-load intro: blank canvas, each element arrives in sequence ---- */
.intro{transition:opacity .85s var(--scrolled), transform .85s var(--scrolled);transition-delay:var(--d,0s)}
.preload .intro{opacity:0}
.preload .masthead.intro{transform:translateY(-16px)}
.preload .kicker.intro{transform:translateY(10px)}
.preload .lede.intro{transform:translateY(14px)}
.preload .aside-note.intro{transform:translateY(8px)}

/* ============================================================
   THE PROSCENIUM — a fixed frame the whole journey plays inside
   ============================================================ */
.theatre{position:fixed;inset:0;z-index:25;pointer-events:none}

/* masking legs: narrow ink bands at the extreme edges. Cheap, and they turn
   "a web page" into "a view through an opening" on every single beat. */
.theatre::before,.theatre::after{
  content:"";position:absolute;top:0;bottom:0;width:clamp(10px,2.6vw,38px);
  background:linear-gradient(90deg,rgba(28,19,10,.55),rgba(28,19,10,.06));
}
.theatre::before{left:0}
.theatre::after{right:0;transform:scaleX(-1)}

/* aged edge — like the page is darker toward the margins */
.vignette{
  position:absolute;inset:0;pointer-events:none;
  box-shadow:inset 0 0 240px rgba(43,32,22,.34), inset 0 0 60px rgba(43,32,22,.18);
  mix-blend-mode:multiply;
}

/* ---- the curtain. Parts sideways on "Curtain up".
   OPEN is the default state and closing is what needs an opt-in class: if the
   default were "closed" then any JS failure would trap the visitor behind a
   curtain with no way through. The head script closes it and carries its own
   4s failsafe, exactly like .preload. -------------------------------------- */
.drape{position:fixed;top:0;bottom:0;width:56vw;z-index:60;color:#5c1a20;
  transition:transform 2.4s var(--scrolled);will-change:transform;
  transform:translateX(-101%)}
.drape svg{display:block;width:100%;height:100%}
.drape--l{left:0}
.drape--r{right:0;transform:scaleX(-1) translateX(-101%)}
.curtain-closed .drape--l{transform:none}
.curtain-closed .drape--r{transform:scaleX(-1)}
/* the pelmet stays: a valance across the top for the whole journey */
.pelmet{position:fixed;top:0;left:0;right:0;height:clamp(18px,3.2vh,38px);z-index:59;color:#5c1a20;pointer-events:none}
.pelmet svg{display:block;width:100%;height:100%}


/* ============================================================
   WORLD + ROUTE
   ============================================================ */
/* full-bleed on purpose: the atmosphere and the floor must reach the viewport
   edges. Only the copy is measured — a max-width here used to clip the weather
   at 1280px and leave a visible vertical seam on wide screens. */
.world{position:relative;z-index:2;width:100%}
.route{position:absolute;inset:0;width:100%;height:100%;z-index:0;pointer-events:none;overflow:visible}
.route path{
  fill:none;stroke:var(--ink-500);stroke-width:1.6;
  stroke-linecap:round;stroke-linejoin:round;opacity:.5;
  filter:drop-shadow(0 1px 0 rgba(244,233,207,.5));
}
.route .route-ghost{opacity:.12;stroke-dasharray:2 7;stroke-width:1}

/* ============================================================
   BEATS (waypoints) — each one a scene on the stage
   ============================================================ */
/* Beats are EXACTLY one viewport tall. That is load-bearing, not cosmetic: every
   figure is positioned with `bottom:%`, so a beat that grows past the viewport
   posts the whole company below the fold — which is how the original hero ended
   up with its party off-screen AND under the text. The copy is sized to fit. */
.beat{
  position:relative;z-index:2;height:100svh;min-height:600px;
  display:grid;align-content:center;
  padding:clamp(4rem,10vh,7rem) clamp(2rem,7vw,7rem);
  --wash:transparent;
}
.beat--right{justify-items:end}
.beat--left{justify-items:start}
.beat--center{justify-items:center}
/* beats whose stage needs the lower half clear (the hero, with its casting call) */
.beat--top{align-content:start;padding-top:clamp(4.5rem,11vh,7rem)}

/* ---- the playbill: copy on an object, not floating over the art ----------
   This is the fix for the overlap. Text on parchment with its own edge is
   legible whatever is behind it, and a placard is what a puppet show would
   actually use. Legibility stops depending on where the puppets happen to be. */
.playbill{
  position:relative;z-index:12;max-width:min(var(--measure),36rem);
  padding:clamp(1.2rem,2.2vw,1.9rem) clamp(1.4rem,2.6vw,2.2rem);
  background:
    linear-gradient(rgba(250,243,225,.97),rgba(240,228,198,.95)),
    url('./assets/parchment-grain.svg');
  background-size:auto,220px 220px;
  background-blend-mode:normal,soft-light;
  border:1px solid rgba(74,51,32,.3);
  box-shadow:
    0 2px 0 rgba(255,255,255,.5) inset,
    0 18px 40px rgba(28,19,10,.28),
    0 3px 10px rgba(28,19,10,.18);
  transform:rotate(var(--tilt,-.35deg));
}
.beat--right .playbill{--tilt:.4deg;text-align:right}
.beat--left .playbill{--tilt:-.5deg;text-align:left}
.beat--center .playbill{--tilt:.25deg;text-align:center}
/* pin-holes: the placard is tacked to the proscenium */
.playbill::before,.playbill::after{
  content:"";position:absolute;top:9px;width:7px;height:7px;border-radius:50%;
  background:radial-gradient(circle at 35% 32%, #7d6640, #2b2016 70%);
  box-shadow:0 1px 2px rgba(28,19,10,.5);
}
.playbill::before{left:14px}
.playbill::after{right:14px}

.kicker{
  font-family:var(--font-rubric);text-transform:uppercase;letter-spacing:.26em;
  font-size:.74rem;color:var(--rubric-deep);margin-bottom:1rem;display:flex;gap:.6rem;align-items:center}
.kicker::before{content:"";width:26px;height:1px;background:var(--rubric);opacity:.7}
.beat--right .kicker{flex-direction:row-reverse;justify-content:flex-start}
.beat--center .kicker{justify-content:center}

/* inked headers — the Quill writes them (mask wipe = the line being written) */
.ink-on{
  font-family:var(--font-display);font-weight:400;color:var(--ink-900);
  line-height:.98;letter-spacing:-.01em;position:relative;
  font-size:clamp(1.9rem,4vw,3.1rem);
  opacity:0;   /* revealed by the real ink-GIF canvas (ink-reveal.js) — .is-inked = shown */
}
.ink-on.is-inked{opacity:1}

.lede{font-size:clamp(.98rem,1.25vw,1.12rem);color:var(--ink-700);margin-top:.9rem;max-width:46ch}
.beat--right .lede{margin-left:auto}
.beat--center .lede{margin-left:auto;margin-right:auto}
.lede em{font-style:italic;color:var(--ember-text)}
.aside-note{font-family:var(--font-script);color:var(--ink-soft);font-size:1.05rem;margin-top:1rem}

/* drop-cap on the first beat's lede */
.dropcap::first-letter{
  font-family:var(--font-rubric);float:left;font-size:3.4em;line-height:.72;
  padding:.06em .12em 0 0;color:var(--rubric)}

/* hero ductus flourish under the title */
.flourish{display:block;width:min(420px,70%);height:26px;margin-top:.4rem;overflow:visible}
.beat--center .flourish{margin-left:auto;margin-right:auto}
.beat--right .flourish{margin-left:auto}
.flourish path{fill:none;stroke:var(--ember-deep);stroke-width:2.2;stroke-linecap:round}

/* ---- act placards: the puppet show's own scene cards.
   Always hung top-left, clear of the playbill wherever the playbill sits — a
   centred card collided with the hero copy and read as a duplicate kicker. --- */
.act{
  position:absolute;top:clamp(6rem,11vh,8rem);left:clamp(1rem,3vw,2.4rem);z-index:13;
  padding:.42rem 1rem .5rem;
  background:linear-gradient(rgba(250,243,225,.96),rgba(236,222,190,.94));
  border:1px solid rgba(74,51,32,.34);
  box-shadow:0 8px 18px rgba(28,19,10,.26);
  transform:rotate(-1.6deg) translateY(-8px);opacity:0;
  transition:opacity .7s ease, transform .9s var(--runner);
}
.beat.is-live .act{opacity:1;transform:rotate(-1.6deg) translateY(0)}
.act b{display:block;font-family:var(--font-rubric);font-size:.66rem;letter-spacing:.3em;
  text-transform:uppercase;color:var(--rubric-deep)}
.act span{display:block;font-family:var(--font-script);font-size:1.02rem;color:var(--ink-700);line-height:1.2}

/* ============================================================
   THE STAGE — camera element. Faked, deliberately: a scale anchored to a
   focus point, driven by scroll in map.js. --cam-x/--cam-y are the focus
   (the casting choice moves them onto the chosen character); --cam-z is the
   push-in. Nested inside, .stage-layer keeps its own parallax transform —
   the two compose.
   ============================================================ */
.stage{
  position:absolute;inset:0;z-index:1;pointer-events:none;overflow:hidden;
  transform-origin:var(--cam-x,50%) var(--cam-y,78%);
  transform:scale(var(--cam-z,1)) translate3d(0,var(--cam-y-off,0px),0);
  will-change:transform;
  /* The stage MUST be its own stacking context, and it must not depend on the
     camera transform for it. #dragon sits at z-index:-1 so the whole animal —
     body and rods — drops behind the scenery; that only stays *inside* .stage
     while .stage forms a stacking context. Both the mobile media query and the
     reduced-motion block set `transform:none` here, which removed the only
     thing creating one, so the dragon escaped to .beat's context and painted
     behind the paper: invisible on mobile and invisible under reduced motion.
     `isolation` creates the context unconditionally and is layout-neutral. */
  isolation:isolate;
}

/* the painted backdrop flat — per-beat lighting wash.
   Feathered top and bottom: each beat's stage clips its own wash, so without a
   mask two adjacent beats meet at a hard horizontal line across the viewport,
   which reads as a rendering fault rather than a scene change. */
.backdrop{position:absolute;inset:0;z-index:0;
  background:
    radial-gradient(130% 86% at 50% 6%, transparent 34%, var(--wash) 100%),
    linear-gradient(var(--sky-top,transparent), transparent 62%);
  opacity:0;transition:opacity 2.4s ease;
  -webkit-mask-image:linear-gradient(transparent 0, #000 14%, #000 86%, transparent 100%);
  mask-image:linear-gradient(transparent 0, #000 14%, #000 86%, transparent 100%)}
.beat.is-live .backdrop{opacity:1}

.stage-layer{position:absolute;inset:0;will-change:transform}
/* atmospheric perspective: on parchment, distance fades toward the paper */
.lyr--far{opacity:var(--far-fade)}
.lyr--mid{opacity:var(--mid-fade)}
.lyr--near{opacity:1}
.lyr--fore{opacity:1;z-index:6}

/* the dragon needs room to be absurd: its head arrives long before its tail */
.beat--tall{min-height:190svh}

/* ---- THE DRAGON ---------------------------------------------------------
   One flex row of overlapping parts, not a scatter of absolutely-placed ones.
   The overlap is the whole trick: same-ink ellipses that overlap MERGE into a
   continuous body, whereas spaced-out segments read as a row of bushes (they
   did). Scroll then slides the whole row across, so the head arrives long
   before the tail. */
.dragon{
  position:absolute;top:14%;left:0;
  /* behind the hills, as one cohesive object: body and rods together */
  z-index:-1;
  /* it used to inherit .lyr--mid's atmospheric fade; moving it out of that layer
     lost it and the animal jumped to full-strength ink */
  opacity:var(--mid-fade,.72);
  display:flex;align-items:flex-end;width:max-content;
  color:var(--ink-900);will-change:transform;
  transform:translateX(var(--dx,100%));
}
/* --dscale exists so the whole animal can be resized in ONE place for narrow
   screens. Every authored --dh/--dov/--dy is in vh against the desktop stage
   (the full-height beat); the mobile stage is 44vh and 390px wide, so at 1:1
   only about three segments fit across and the head never enters the frame —
   the body reads as a row of bushes. Defaults to 1, so desktop is unchanged. */
.dseg{display:block;height:calc(var(--dh,12vh) * var(--dscale,1));
  margin-right:calc(var(--dov,-2.6vh) * var(--dscale,1));
  transform:translateY(calc(var(--dy,0) * var(--dscale,1)))}
.dseg:last-child{margin-right:0}
.dseg > .sway{display:block;height:100%;transform-origin:50% 100%;
  animation-name:sway;
  animation-duration:calc(var(--sd,7s) * var(--sway-rate,1));
  animation-timing-function:ease-in-out;
  animation-iteration-count:infinite;
  animation-delay:var(--sdelay,0s)}
.dseg svg{display:block;height:100%;width:auto;
  filter:drop-shadow(0 -12px 12px rgba(28,19,10,.32))}
@media (prefers-reduced-motion:reduce){
  /* state applies: the dragon is fully present and in shot, it just doesn't move */
  :root:not(.force-motion) .dragon{transform:translateX(-32%)}
  :root:not(.force-motion) .dseg > .sway{animation:none!important}
}

/* ---- the boards. A rotateX'd plane gives real converging perspective for
   the price of one gradient, and anchors every figure to a floor. ---------- */
.floor{
  position:absolute;left:-30%;right:-30%;bottom:0;height:var(--floor-depth);z-index:2;
  transform-origin:50% 100%;transform:perspective(760px) rotateX(66deg);
  background:
    repeating-linear-gradient(90deg,
      rgba(74,51,32,.40) 0 3px, transparent 3px 76px),
    linear-gradient(rgba(74,51,32,.08), rgba(74,51,32,.34));
  opacity:0;transition:opacity 1.8s ease;
}
.beat.is-live .floor{opacity:1}
/* the front edge of the stage — a hard ink line the figures stand behind */
.floor::after{content:"";position:absolute;left:0;right:0;bottom:0;height:3px;background:rgba(28,19,10,.5)}

/* ---- footlights. The cheapest trick that reads as "stage": light thrown
   UPWARD from the lip, plus a row of individual lamps along it. ------------ */
.footlights{
  position:absolute;left:0;right:0;bottom:0;height:var(--foot-reach);z-index:3;
  background:linear-gradient(to top,
    color-mix(in srgb, var(--foot-warm) 62%, transparent),
    color-mix(in srgb, var(--foot-warm) 16%, transparent) 42%,
    transparent 78%);
  mix-blend-mode:screen;opacity:0;transition:opacity 2s ease;
}
.beat.is-live .footlights{opacity:var(--foot-glow)}
.footlights::after{
  content:"";position:absolute;left:0;right:0;bottom:0;height:14px;
  background:repeating-radial-gradient(circle at 26px 15px,
    color-mix(in srgb, var(--foot-warm) 85%, transparent) 0 4px,
    transparent 4px 52px);
  filter:blur(2px);
}

/* ---- the follow-spot: isolates the lead at the moments that matter ------- */
.spot{
  position:absolute;inset:0;z-index:4;pointer-events:none;opacity:0;
  transition:opacity 1s ease, background-position 1.1s var(--scrolled);
  background:radial-gradient(circle var(--spot-size) at var(--spot-x,50%) var(--spot-y,64%),
    rgba(255,244,214,.55), rgba(255,238,200,.14) 52%, transparent 74%);
  mix-blend-mode:screen;
}
.beat.is-spotlit .spot{opacity:var(--spot-glow)}

/* ============================================================
   PUPPETS — woodcut cut-outs held up on rods
   ============================================================ */
.puppet{
  position:absolute;bottom:6%;opacity:0;transform-origin:50% 100%;
  color:var(--ink-700);height:var(--h,20vh);z-index:5;
  /* left/height are what the "fall in line behind the lead" choreography moves.
     Animating them (rather than transform) keeps it clear of the riseIn keyframes,
     which own transform and would otherwise win via animation-fill-mode. */
  transition:left 1.15s var(--runner), height 1.15s var(--runner);
}
/* wide painted flats (horizons, skylines) size by width, not height */
.puppet.wide{height:auto;width:var(--w,100%)}
.puppet.wide svg{width:100%;height:auto}
.puppet svg{display:block;height:100%;width:auto;
  /* lit from BELOW: a warm lick at the feet and the cast shadow thrown UP the
     backdrop. Light direction is the whole reason this reads as a stage. */
  filter:
    drop-shadow(0 -14px 12px rgba(28,19,10,.30))
    drop-shadow(0 5px 5px rgba(240,132,51,.34));
}
/* weighted "lifted onto the stage on a stick" entrance:
   anticipation (low, tilted, squashed) → fast rise → one damped overshoot → settle */
.beat.is-live .puppet{animation:riseIn 1.05s var(--scrolled) both}
@keyframes riseIn{
  0%{opacity:0;transform:translateY(135%) rotate(-9deg) scaleY(.82)}
  10%{opacity:1}
  58%{transform:translateY(-7%) rotate(5deg) scaleY(1.06)}
  80%{transform:translateY(2.5%) rotate(-2deg) scaleY(.985)}
  100%{opacity:1;transform:translateY(0) rotate(0) scaleY(1)}
}

/* the rod + the held-up sway. The seams ARE the charm — a visible rod is the
   difference between a puppet show and clip-art, so the rod stays. */
/* --sway-scale / --sway-rate are MULTIPLIERS, not replacements. Almost every
   puppet carries its own --amp and --sd for variety, so a global dial has to
   scale those rather than overwrite them or it would flatten the whole company
   to one rhythm. */
.puppet .sway{display:block;height:100%;transform-origin:50% 100%;will-change:transform;
  animation-name:sway;
  animation-duration:calc(var(--sd,var(--sway-period)) * var(--sway-rate,1));
  animation-timing-function:ease-in-out;
  animation-iteration-count:infinite;
  animation-delay:var(--sdelay,0s)}
.puppet .stick{position:absolute;left:50%;top:100%;width:3px;height:40vh;transform:translateX(-50%);
  background:linear-gradient(rgba(74,51,32,.55),rgba(74,51,32,0));pointer-events:none}
@keyframes sway{
  0%{transform:rotate(calc(var(--amp,var(--sway-amp)) * var(--sway-scale,1) * -1)) translateY(0)}
  50%{transform:rotate(calc(var(--amp,var(--sway-amp)) * var(--sway-scale,1))) translateY(calc(var(--bob,5px)*-1))}
  100%{transform:rotate(calc(var(--amp,var(--sway-amp)) * var(--sway-scale,1) * -1)) translateY(0)}
}

/* flats: scenery arrives laterally on runners, overshoots, settles */
.flat{transition:transform 1.15s var(--runner), opacity .8s ease;will-change:transform}
.beat:not(.is-live) .flat[data-from="left"]{transform:translateX(-130%)}
.beat:not(.is-live) .flat[data-from="right"]{transform:translateX(130%)}
.beat:not(.is-live) .flat[data-from="below"]{transform:translateY(90%)}
.beat:not(.is-live) .flat{opacity:0}

/* puppet pigments (single-ink woodcut tinting via currentColor) */
.tint-ink{color:var(--ink-700)} .tint-ink-900{color:var(--ink-900)}
.tint-sepia{color:#5a4023} .tint-ember{color:var(--ember-deep)}
.tint-rubric{color:var(--rubric)} .tint-lapis{color:var(--lapis)} .tint-gold{color:#9a7b3a}

/* the lead figure: the one the visitor cast. Gets the spot and the sigil. */
.puppet[data-lead]{z-index:7}
.puppet.is-cast{animation:recast .8s var(--runner) both}
@keyframes recast{
  0%{transform:translateY(24%) scaleY(.86) rotate(-5deg)}
  55%{transform:translateY(-6%) scaleY(1.05) rotate(3deg)}
  100%{transform:translateY(0) scaleY(1) rotate(0)}
}

/* ambient sky actors (birds on rods) */
.sky-actors{position:absolute;inset:0;z-index:1;pointer-events:none;overflow:hidden}
.bird{position:absolute;left:0;color:var(--ink-500);animation:flyacross var(--fd,30s) linear infinite;animation-delay:var(--fdelay,0s);will-change:transform}
@keyframes flyacross{from{transform:translateX(-14vw)}to{transform:translateX(118vw)}}
.bird .wing{transform-origin:50% 90%;animation:flap 1.5s ease-in-out infinite}
.bird .wing.r{animation-delay:-.75s}
@keyframes flap{0%,100%{transform:rotate(-14deg)}50%{transform:rotate(20deg)}}

/* chimney smoke — a town that is lived in has something coming off its roofs */
.smoke{position:absolute;width:26px;height:26px;border-radius:50%;
  background:radial-gradient(circle,rgba(74,51,32,.5),rgba(74,51,32,.16) 55%,transparent 74%);
  animation:smokeup var(--smd,7s) linear infinite;animation-delay:var(--smdelay,0s)}
@keyframes smokeup{
  0%{opacity:0;transform:translateY(0) scale(.4)}
  16%{opacity:.85}
  100%{opacity:0;transform:translateY(-18vh) scale(3.2)}
}

/* weather veil (dialed per beat via --storm) */
.beat .weather{position:absolute;inset:0;z-index:6;pointer-events:none;opacity:var(--storm,0);transition:opacity 2.6s ease;
  background:radial-gradient(120% 90% at 50% 100%, rgba(43,40,60,.0) 30%, rgba(20,19,34,.5) 100%);
  -webkit-mask-image:linear-gradient(transparent 0, #000 12%, #000 88%, transparent 100%);
  mask-image:linear-gradient(transparent 0, #000 12%, #000 88%, transparent 100%)}
.beat .flash{position:absolute;inset:0;z-index:7;pointer-events:none;opacity:0;mix-blend-mode:screen;
  background:radial-gradient(70% 50% at 60% 16%, rgba(216,226,255,.85), rgba(180,200,255,.15) 45%, transparent 70%)}

/* ============================================================
   THE WAX-SEAL CTA (Beat 5)
   ============================================================ */
.seal-cta{display:inline-flex;flex-direction:column;align-items:center;gap:1rem;margin-top:2rem;border:none;background:none;cursor:pointer}
.seal{width:120px;height:120px;color:var(--rubric);transition:transform .4s var(--snap);filter:drop-shadow(0 8px 16px rgba(120,30,25,.4))}
.seal-cta:hover .seal{transform:scale(1.05) rotate(-3deg)}
.seal-label{font-family:var(--font-display);font-size:1.05rem;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-900)}

/* the two halves shown together */
.halves{display:flex;gap:clamp(1rem,4vw,3rem);align-items:center;justify-content:center;margin:1.6rem 0;color:var(--ink-700)}
.halves svg{filter:drop-shadow(0 6px 12px rgba(28,19,10,.3))}

/* masthead — the brand/pill rules that lived here are gone: this page now uses the
   shared .qnav plaque (nav.css) like every other page. The old pill also carried a
   blurred drop shadow and a specular inset white line, both of which the cut-paper
   grammar outlaws. Only the visible "skip to the call" link is page-specific, and it
   now sits in the masthead's aside slot, cut from the same stock as the plaque. */
.qnav-aside .skip{font-family:var(--font-body);font-weight:700;font-size:.82rem;
  color:var(--ink-900);text-decoration:none;background-color:var(--paper-100);
  padding:.35rem .8rem;border:1px solid var(--ink-500);box-shadow:2px 2px 0 rgba(74,51,32,.26)}

/* ---- the rule, being printed --------------------------------------------------
   This was a 3px ember-gradient reading-progress bar: the most generic element on
   the page, in the most privileged position on the site (fixed, top, across all
   15.9 screens). Delete it and nobody would have noticed — which is the argument
   for replacing it rather than keeping it.

   It is now a woodcut hairline that is WET AT THE TIP and dry behind, carrying the
   story's structure: seven registration ticks, ghosted ahead of you and struck as
   you cross them. On a page with 8.2 continuous screens containing no ask and no
   clickable element, this is the only thing telling a visitor the story has a
   shape and an end.

   `width:var(--road,0%)` was dead — map.js writes style.width directly — so the
   custom property is gone rather than leaving two paths to the same value. */
.road-progress{position:fixed;top:0;left:0;height:1.5px;width:0;z-index:63;
  background:var(--ink-700,#4a3320);transition:width .12s linear}

/* The wet leading edge: a stipple of hard 2px ink dashes, thinned toward the tip in
   four DISCRETE mask steps so it dithers out instead of fading. No blur anywhere —
   this is a halftone screen, which is how a press renders a tone at all. */
.road-progress::after{
  content:"";position:absolute;right:0;top:0;height:100%;width:34px;
  background:repeating-linear-gradient(90deg,
    var(--ink-700,#4a3320) 0 2px, transparent 2px 4px);
  -webkit-mask-image:linear-gradient(90deg,
    rgba(0,0,0,1) 0 25%, rgba(0,0,0,.72) 25% 50%,
    rgba(0,0,0,.42) 50% 75%, rgba(0,0,0,.18) 75% 100%);
  mask-image:linear-gradient(90deg,
    rgba(0,0,0,1) 0 25%, rgba(0,0,0,.72) 25% 50%,
    rgba(0,0,0,.42) 50% 75%, rgba(0,0,0,.18) 75% 100%)}

/* the seven acts, as registration marks on the sheet */
.road-ticks{position:fixed;top:0;left:0;right:0;height:6px;z-index:63;pointer-events:none}
.road-ticks i{position:absolute;top:0;width:1px;height:6px;
  background:var(--paper-500,#cdb894);
  transition:background-color .1s linear}
/* struck: filled in rubric, in one frame, as the rule passes it */
.road-ticks i.is-struck{background:var(--rubric-deep,#9c2b1c);height:6px}

/* ============================================================
   CASTING CALL — the visitor chooses who leads the party.
   Icons are the curated QCIcon glyphs, driven through a CSS mask so they take
   the ink colour rather than shipping a second, differently-coloured icon set.
   ============================================================ */
.casting{margin-top:1.5rem;position:relative}

/* The stencil covering the casting row until the intro draws it off. It uses the
   same runner easing as every .flat on the site, because it IS one — an opaque
   paper sheet sliding off to uncover what was already printed underneath. Never
   interactive, and it cannot delay a click or a tab. */
.casting__stencil{
  position:absolute;left:-.35rem;right:-.35rem;bottom:0;
  top:2.1rem;                                  /* below the "Who leads the party?" line */
  background:var(--paper-300,#e6d5b2);
  border:1px solid rgba(74,51,32,.2);border-radius:2px;
  pointer-events:none;z-index:3;
  transform:translateX(0)}
/* Travel distance must NOT be expressed in % alone. A percentage in translateX
   resolves against the ELEMENT'S OWN border-box width (~432px), not the viewport, so
   -118% moved the sheet only ~-510px and parked it, fully opaque, over the hillside —
   and the error grew with the viewport, because the sheet's start-x is width-proportional
   inside a centred parent while its travel is not. `calc(-100vw - 100%)` is the correct
   distance at ANY width: -100% exactly cancels the sheet's own width, and -100vw then
   carries its right edge a full viewport further left. Final right edge = startLeft - 100vw,
   and startLeft can never exceed the viewport, so the sheet is always off-stage. (100vw
   includes the classic scrollbar gutter, which only adds margin in our favour.)

   Why a two-segment keyframe and not a plain transition to that distance: the sheet's job
   is to UNCOVER the six cards, and that reading is finished once it has travelled its own
   width (-100%). A single transition across the much longer new distance, with the same
   front-loaded runner easing, reached -100% in 58ms instead of the original 264ms — the
   wipe became a pop. Pinning -100% at 23% of 1.15s puts full uncovering back at 264ms
   exactly, then the tail eases IN to carry the sheet off-screen. So the reveal keeps its
   original rhythm and the geometry is still guaranteed at any width. Do not collapse this
   back into one segment: the two requirements pull in opposite directions. */
.casting__stencil.is-drawn{
  transform:translateX(calc(-100vw - 100%));
  animation:qcCastingSheetOff 1.15s forwards}
@keyframes qcCastingSheetOff{
  0%{transform:translateX(0);animation-timing-function:var(--runner,cubic-bezier(.22,1,.36,1))}
  23%{transform:translateX(-100%);animation-timing-function:cubic-bezier(.55,0,1,.45)}
  100%{transform:translateX(calc(-100vw - 100%))}
}
/* Absent for reduced-motion visitors: the cards are inked, legible and operable from
   the first frame. The reveal is a transient, and skipping a transient costs nothing
   -- unlike skipping a state, which is the bug this project keeps re-learning. */
@media (prefers-reduced-motion: reduce){
  :root:not(.force-motion) .casting__stencil{display:none}
}
.casting__title{font-family:var(--font-script);color:var(--ink-soft);font-size:1.02rem;letter-spacing:.02em;margin-bottom:.6rem}
.casting__row{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem}
.cast{
  display:flex;align-items:center;justify-content:flex-start;gap:.5rem;
  padding:.5rem .7rem;
  background:rgba(247,238,214,.6);
  border:1px solid rgba(74,51,32,.28);
  border-radius:2px;
  color:var(--ink-700);
  font:inherit;font-size:.88rem;letter-spacing:.01em;text-align:left;
  cursor:pointer;
  transition:background .28s var(--scrolled), border-color .28s var(--scrolled),
             color .28s ease, transform .28s var(--scrolled);
}
/* The glyphs are 156×153 artwork with their own internal padding, so masking
   them straight into a small box left them ragged and off-centre. Giving the
   mask a fixed square box and sizing to CONTAIN inside a slightly larger well
   normalises every glyph's optical centre without touching the curated files. */
.cast i{
  width:24px;height:24px;flex:0 0 24px;display:block;background:currentColor;
  -webkit-mask:var(--g) center/86% 86% no-repeat; mask:var(--g) center/86% 86% no-repeat;
}
.cast:hover{background:rgba(247,238,214,.95);border-color:rgba(74,51,32,.5);transform:translateY(-1px)}
/* ---- the stamp -------------------------------------------------------------
   The chosen card is not lifted, it is PRESSED. translateY(-1px) went up, away from
   the sheet, which is backwards for a thing you just pushed: it now goes DOWN 2px
   and its flat cast collapses to nothing, because a struck card is in contact with
   the paper. This is where the visitor learns the site's physical verb, on a choice
   with no stakes at screen 0.6 -- so that the same gesture is already legible when
   it is the die in Act III and the seal at 91%. Teaching it here is the whole
   reason this item is cheap and the others read as inevitable. */
.cast{box-shadow:2px 2px 0 rgba(74,51,32,.22)}
.cast[aria-checked="true"]{
  background:var(--ink-900);border-color:var(--ink-900);color:var(--paper-100);
  transform:translateY(2px);box-shadow:0 0 0 rgba(74,51,32,0)}

/* The register cross: a printer's mark, struck beside the name and kept. It prints
   in ONE frame and never animates again -- decor.css records the settled decision
   that ornament must not breathe, and a state mark least of all. Drawn as two hard
   1px rules, no glyph and no icon file, so it costs nothing and cannot go ragged. */
.cast[aria-checked="true"]::after{
  content:"";position:absolute;top:5px;right:5px;width:7px;height:7px;
  background:
    linear-gradient(var(--rubric,#e34234) 0 0) center/7px 1px no-repeat,
    linear-gradient(var(--rubric,#e34234) 0 0) center/1px 7px no-repeat}
.cast{position:relative}
.casting__hint{margin-top:.6rem;font-family:var(--font-script);color:var(--ink-soft);font-size:.92rem;min-height:1.4em}
.casting.is-chosen .casting__hint{color:var(--ember-text)}

/* the lead figure wears their sigil, held above the puppet like a banner */
.puppet .sigil{
  position:absolute;left:50%;top:-26px;width:30px;height:30px;
  transform:translateX(-50%) scale(0);transform-origin:50% 120%;
  background:currentColor;opacity:0;pointer-events:none;
  -webkit-mask:var(--sig) center/contain no-repeat; mask:var(--sig) center/contain no-repeat;
  transition:transform .55s var(--runner), opacity .4s ease;
}
.puppet.has-lead .sigil{transform:translateX(-50%) scale(1);opacity:.92}

/* ============================================================
   MOBILE — keep grid + inked headers + static route-spine; drop heavy staging.
   Desktop is Q&C's primary platform (lesson #9); mobile must not break.
   ============================================================ */
@media (max-width:760px){
  /* height:auto + min-height is load-bearing, not cosmetic. The base rule is a hard
     `height:100svh`, and .beat:not(.beat--journey) is overflow:hidden — so on a phone
     the hero's casting call ("Who leads the party?" and the class names) and beat 5's
     CTA label were pushed past the ceiling and CLIPPED AWAY, with no scroll to reach
     them. Letting the beat grow to fit its content removes the overflow at source, so
     overflow:hidden can stay and keep containing the 250vw dragon. */
  .beat{justify-items:start!important;height:auto;min-height:100svh;padding:4.5rem 1.2rem;overflow:visible}
  .playbill{max-width:100%;transform:none}
  .beat--right .playbill,.beat--center .playbill{text-align:left}
  .stick,.drape,.pelmet,.prosc,.act{display:none}
  .theatre::before,.theatre::after{width:6px}
  .ink-on{font-size:clamp(1.9rem,10vw,3rem)}
  .casting__row{grid-template-columns:repeat(2,1fr)}
  /* width:100% is REQUIRED. .beat is display:grid and this block forces
     justify-items:start, so the moment .stage stops being absolutely positioned it
     becomes a grid item sized to fit-content — and every one of its children is
     position:absolute, so fit-content resolved to ZERO. Measured at 375x812: all six
     stages 0px wide, 58 of 79 puppets collapsed, i.e. the whole puppet theatre blank. */
  .stage{position:relative;width:100%;height:44vh;transform:none}
  .stage-layer{position:absolute}
  .puppet{height:var(--hm,15vh)}

  /* The dragon beat carries no copy — the animal IS the beat — so if it does not
     paint, the visitor gets a blank screen. It was doing exactly that on mobile.
     Cause: every --dy on a .dseg is authored in vh against the desktop stage,
     which is the full 100vh beat. Here the stage is 44vh, so the tail segments
     (--dy up to 11vh, and the tail is the only part inside the narrow horizontal
     window) are pushed clean out of the stage's overflow:hidden box — measured
     at 0 painted pixels. The clip exists to contain the camera scale-up, and
     mobile sets transform:none, so there is no scale-up here to contain.
     Horizontal containment is unaffected: .beat:not(.beat--journey) still has
     overflow:hidden, which is what keeps the 250vw animal off the page scroll. */
  #beat-3 .stage{overflow:visible}
  /* Half scale puts the head and a readable length of body in a 390px frame
     instead of three ambiguous humps, and brings every --dy back inside the
     short stage. Raised top:14% -> 26% because the shrunken animal would
     otherwise ride high on the paper with nothing under it. */
  #beat-3 .dragon{--dscale:.5;top:26%}
  /* Mobile already hides every puppet rod (.stick, above). The dragon's rods are
     a different class, so they were still hanging in open paper with no hill
     band down here to terminate in — same rule, same reason. */
  #beat-3 .dstick{display:none}
}

@media (prefers-reduced-motion:reduce){
  :root:not(.force-motion){scroll-behavior:auto}
  /* Rule: state must still APPLY under RM — only the tween is skipped. A frost
     overlay bug came from exactly this, and Dave's machine has RM ON, so the
     RM branch is the one he sees first. Everything below stays VISIBLE. */
  :root:not(.force-motion) .puppet{transition:opacity .4s ease!important;transform:none!important;animation:none!important}
  /* the two outcome flats are the exception: they carry .puppet for its positioning
     but must NOT be present until reveal() writes data-outcome, or the dragon breathes
     fire before a die is thrown. Their own reduced-motion state is set further down. */
  :root:not(.force-motion) .beat.is-live .puppet:not(.fx-fire):not(.fx-bolt){opacity:1!important}
  :root:not(.force-motion) .intro{transition:none!important;opacity:1!important;transform:none!important}
  :root:not(.force-motion) .sway,
  :root:not(.force-motion) .bird,
  :root:not(.force-motion) .bird .wing,
  :root:not(.force-motion) .smoke{animation:none!important}
  :root:not(.force-motion) .smoke{opacity:.35}
  :root:not(.force-motion) .stick{display:none}
  :root:not(.force-motion) .ink-on{opacity:1!important}
  :root:not(.force-motion) .road-progress{transition:none!important}
  :root:not(.force-motion) .beat .weather{transition:none!important;opacity:0!important}
  /* no scroll-driven zoom at all (nausea), but the scene stays fully legible */
  :root:not(.force-motion) .stage{transform:none}
  /* the curtain is OPEN immediately rather than animating apart */
  :root:not(.force-motion) .drape{transition:none!important;transform:translateX(-101%)}
  :root:not(.force-motion) .drape--r{transform:scaleX(-1) translateX(-101%)}
  /* flats are simply present */
  :root:not(.force-motion) .flat{transition:none!important;transform:none!important;opacity:1!important}
  :root:not(.force-motion) .act{transition:none!important;opacity:1!important}
  :root:not(.force-motion) .backdrop,
  :root:not(.force-motion) .floor,
  :root:not(.force-motion) .footlights{transition:none!important}
  :root:not(.force-motion) .puppet.is-cast{animation:none!important}
}

/* ===== THE JOURNEY — sticky sideways travel ===============================
   The party is held at a fixed mark and the WORLD slides past them, which is
   what makes it read as following someone rather than paging through frames.
   Everything here is driven by --jt (a px offset written by map.js from scroll
   progress), so there is no animation to skip under reduced motion — the whole
   section behaves identically with prefers-reduced-motion on.
   ------------------------------------------------------------------------- */
.beat--journey{height:auto;min-height:0;padding:0;display:block}

.journey-track{position:relative;height:var(--journey-len,420vh)}

.journey-view{
  position:sticky;top:0;height:100svh;overflow:hidden;
  /* --paper-200 is not in the palette. It needs the fallback: a bare var() to an
     undeclared property is invalid at computed-value time, which drops the WHOLE
     shorthand — taking the --sky-wash layer with it, so setSky() painted nothing.
     #e7d4a6 is --paper-300, the tone this view was already showing through. */
  background:
    radial-gradient(120% 80% at 50% 108%, var(--sky-wash,transparent) 0%, transparent 62%),
    var(--paper-200,#e7d4a6);
  transition:background-color 1.8s ease;
}

/* the moving world. Layers counter-translate a fraction of --jt so the far band
   drifts slowly and the foreground overtakes the party. */
.journey-world{
  position:absolute;top:0;bottom:0;left:0;width:var(--jw,460%);
  transform:translate3d(var(--jt,0px),0,0);will-change:transform;
}
.jw-layer{position:absolute;inset:0;color:var(--ink-700)}
.jw--far {opacity:.26;transform:translate3d(calc(var(--jt,0px) * -.72),0,0)}
.jw--mid {opacity:.60;transform:translate3d(calc(var(--jt,0px) * -.12),0,0)}
.jw--near{opacity:.88;transform:translate3d(calc(var(--jt,0px) *  .16),0,0)}

.jw-art{position:absolute;bottom:var(--jb,13%);height:var(--jh,14vh);width:auto;display:block}
.jw-hills{position:absolute;bottom:9%;left:0;width:100%;height:24vh}
.jw-cave{bottom:0;--jb:0%}

/* the ground the company walks on */
.journey-view::after{
  content:"";position:absolute;left:0;right:0;bottom:0;height:13%;z-index:2;
  background:linear-gradient(to bottom, rgba(74,51,32,.16), rgba(74,51,32,.30));
  border-top:1px solid rgba(74,51,32,.28);
}

/* the party — fixed mark, world slides past */
.journey-cast{position:absolute;inset:0;left:13%;width:52%;z-index:6;pointer-events:none}

/* weather veil + lightning, dialled per stage by map.js */
.journey-weather{position:absolute;inset:0;z-index:7;pointer-events:none;
  opacity:var(--sky-veil,0);transition:opacity 1.8s ease;
  background:var(--sky-veil-paint,none)}
.journey-flash{position:absolute;inset:0;z-index:8;pointer-events:none;opacity:0;
  background:radial-gradient(60% 40% at 60% 18%, rgba(255,248,230,.92), transparent 70%)}
.journey-flash.is-lit{animation:jflash .5s ease-out}
@keyframes jflash{0%{opacity:0}12%{opacity:1}26%{opacity:.25}40%{opacity:.85}100%{opacity:0}}

.journey-copy{position:absolute;z-index:9;left:clamp(2rem,7vw,7rem);bottom:17%;max-width:36ch}
.journey-act{font:600 .68rem/1 var(--font-mono,ui-monospace,monospace);letter-spacing:.18em;
  text-transform:uppercase;color:var(--rubric-deep);margin:0 0 .5rem;display:flex;gap:.6rem;align-items:baseline}
.journey-act span{color:var(--ink-soft);letter-spacing:.1em}
.journey-line{font:italic 1.05rem/1.5 Georgia,serif;color:var(--ink-700);margin:0;
  opacity:0;transform:translateY(.4rem);transition:opacity .7s ease,transform .7s ease}
.journey-line.is-on{opacity:1;transform:none}

@media (prefers-reduced-motion: reduce){
  :root:not(.force-motion) .journey-view,
  :root:not(.force-motion) .journey-weather{transition:none!important}
  :root:not(.force-motion) .journey-flash.is-lit{animation:none!important}
  /* state must still apply — only the tween is skipped */
  :root:not(.force-motion) .journey-line{transition:none!important}
  :root:not(.force-motion) .journey-line.is-on{opacity:1;transform:none}
}

/* --- journey composition fixes ------------------------------------------
   1. the company stands ON the ground band, not sunk through it (the band is
      the bottom 13%, so their feet belong at its top edge, not at 6%);
   2. the caption moves up into the sky, which both kills the overlap with the
      figures and uses the dead space the wide frame leaves;
   3. set pieces scale up together from one multiplier rather than 17 edits. */
.journey-cast .puppet{bottom:12.5%}
.journey-copy{top:15%;bottom:auto}
.jw-art{height:calc(var(--jh,14vh) * 1.55)}
.jw-hills{height:34vh;bottom:11%}
.jw-cave{height:calc(var(--jh,14vh) * 1.15);bottom:11%}

/* --- journey: explicit widths ------------------------------------------
   Every child of .journey-view is absolutely positioned, so the view has no
   in-flow content to size from. Anything that puts it in a shrink-to-fit
   context collapses it to 0 and the whole section silently disappears — which
   is exactly what happened at <=760px. State the width; do not infer it. */
.journey-track,.journey-view{width:100%}

/* --- journey on small screens ------------------------------------------
   The sideways journey survives on a phone, but three things have to change:
   the shared mobile rule .puppet{height:var(--hm,15vh)} ignores the inline --h
   the cast is authored with, so the company has to opt back in; the world needs
   a bigger percentage because that percentage is of a much narrower viewport;
   and the caption gets the full width rather than a 36ch column. */
@media (max-width:760px){
  .beat--journey{padding:0}
  .journey-track{height:320vh}
  .journey-world{width:var(--jw-m,900%)}
  .journey-cast{left:7%;width:78%}
  .journey-cast .puppet{height:var(--hm-j,21vh)}
  .jw-art{height:calc(var(--jh,14vh) * 1.12)}
  .jw-hills{height:26vh}
  .journey-copy{left:1.2rem;right:1.2rem;top:10%;max-width:none}
  .journey-line{font-size:1rem;line-height:1.45}
  .journey-view::after{height:16%}
  .journey-cast .puppet{bottom:15%}
}

/* On a phone the cast box is ~300px, so four figures at 0/11/21/30% overlap into
   one mass. Drop to three and spread them across the full box. */
@media (max-width:760px){
  .journey-cast .puppet[data-jrole="3"]{display:none}
  .journey-cast .puppet[data-jrole="lead"]{left:0%}
  .journey-cast .puppet[data-jrole="1"]{left:30%}
  .journey-cast .puppet[data-jrole="2"]{left:58%}
  .jw-art{--jb:17%}
}

/* The cast puppets carry inline left:/--h, so mobile placement has to win with
   !important — same convention as the other mobile overrides in this file.
   Three figures at ~95px in a 345px box is the most a phone frame will take. */
@media (max-width:760px){
  .journey-cast{left:4%;width:92%}
  .journey-cast .puppet{height:18vh!important}
  .journey-cast .puppet[data-jrole="lead"]{left:0%!important}
  .journey-cast .puppet[data-jrole="1"]{left:34%!important}
  .journey-cast .puppet[data-jrole="2"]{left:68%!important}
}

/* ===== THE SKY ============================================================
   Clouds are DOM elements over a canvas rather than more canvas work: they are
   flat woodcut shapes that only ever drift, so the compositor can own them and
   the canvas stays free for precipitation, which genuinely needs per-frame work.
   ------------------------------------------------------------------------- */
.journey-sky{position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none}

.jsky-sun{position:absolute;right:14%;top:9%;width:clamp(96px,15vw,190px);
  color:var(--ember,#f08433);opacity:var(--sun-on,0);transition:opacity 1.6s ease}
.jsky-sun svg{width:100%;height:auto;display:block;
  animation:sunspin 140s linear infinite;transform-origin:50% 50%}
@keyframes sunspin{to{transform:rotate(360deg)}}
/* a soft bloom behind the disc so it reads as light, not a gear */
.jsky-sun::before{content:"";position:absolute;inset:-45%;border-radius:50%;
  background:radial-gradient(circle, rgba(240,164,64,.34) 0%, rgba(240,164,64,.10) 45%, transparent 70%)}

/* Both perpetual sky loops PAUSE until the journey is the live beat.
   The journey's sky is tall enough to reach into beat-1's viewport, so at y~1200
   thirty drifting clouds and the 140s sun spin were running on top of beat-1's own
   33 sways and 5 smoke plumes -- ~63 concurrent animations in one frame, with
   beat-1's ink reveal expected to land inside it. That is a collision, not a
   crescendo. Pausing (not disabling) keeps every cloud exactly where it was placed,
   so nothing moves or reflows when the journey goes live -- the deck simply starts
   drifting. Layout, the sticky .journey-view and the counter-parallax bands are
   untouched, because animation-play-state affects no geometry. */
.jcloud{animation:jdrift var(--jd,140s) linear infinite;animation-delay:var(--jdd,0s)}
.jcloud,.jsky-sun svg{animation-play-state:paused}
.beat--journey.is-live .jcloud,
.beat--journey.is-live .jsky-sun svg{animation-play-state:running}

.jsky-clouds{position:absolute;inset:0}
.jcloud{position:absolute;color:var(--ink-500,#7a6a58);display:block;
  opacity:0;transition:opacity 1.8s ease;will-change:transform}
.jcloud svg{width:100%;height:auto;display:block}
.journey-sky.is-clouded .jcloud{opacity:var(--cl-op,.5)}

/* ===== PRECIPITATION + FROST ============================================= */
.journey-fx{position:absolute;inset:0;z-index:7;pointer-events:none;
  opacity:var(--fx-on,0);transition:opacity 1.2s ease}

/* Screen-edge frost — the app's construction: a milky turbulence film plus
   corner glows, creeping IN from the edges rather than washing the whole frame. */
.journey-frost{position:absolute;inset:0;z-index:8;pointer-events:none;
  opacity:var(--frost-on,0);transition:opacity 2.4s ease;
  background:
    radial-gradient(58% 46% at 2% 98%, rgba(224,238,252,.52) 0%, rgba(205,225,246,.22) 44%, transparent 70%),
    radial-gradient(54% 42% at 98% 4%, rgba(232,242,254,.46) 0%, rgba(210,229,248,.18) 46%, transparent 72%),
    radial-gradient(46% 38% at 99% 96%, rgba(224,238,252,.40) 0%, transparent 66%),
    radial-gradient(46% 38% at 1% 3%, rgba(224,238,252,.40) 0%, transparent 66%),
    url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' seed='9' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.86  0 0 0 0 0.91  0 0 0 0 0.98  0 0 0 0.14 0'/></filter><rect width='240' height='240' filter='url(%23g)'/></svg>");
}

/* forked lightning — drawn as a real path, so it is a bolt and not a flash */
.journey-bolt{position:absolute;inset:0;z-index:8;pointer-events:none;
  color:rgba(255,250,235,.95);opacity:0}
.journey-bolt.is-lit{animation:jbolt .42s ease-out}
@keyframes jbolt{0%{opacity:0}8%{opacity:1}22%{opacity:.15}34%{opacity:.9}100%{opacity:0}}

/* the company walks: a short vertical bob, each figure on its own phase so the
   party never bobs in lockstep (which reads as a lift, not a walk) */
.journey-cast .sway{animation:jwalk var(--wd,1.05s) ease-in-out infinite;
  animation-delay:var(--sdelay,0s)}
@keyframes jwalk{
  0%,100%{transform:translateY(0) rotate(calc(var(--amp,1deg) * -.35))}
  50%    {transform:translateY(-2.6%) rotate(calc(var(--amp,1deg) * .35))}
}

@media (prefers-reduced-motion: reduce){
  :root:not(.force-motion) .jsky-sun svg,
  :root:not(.force-motion) .journey-cast .sway{animation:none!important}
  /* opacity:1 is REQUIRED alongside animation:none. .journey-bolt sits at opacity:0
     and @keyframes jbolt ENDS at opacity:0, so killing the animation alone leaves the
     struck bolt invisible -- map.js strikes one fork under RM and this is what makes
     it appear on the sheet. Held lit, never flashing: the state, not the tween. */
  :root:not(.force-motion) .journey-bolt.is-lit{animation:none!important;opacity:1}
  /* `.journey-fx{display:none}` used to live here. It was the SECOND deletion of the
     same thing -- map.js already forced fxKind to 'none' -- so rain, storm and snow
     were removed twice over while their captions still announced them. map.js now
     paints one still frame per stage instead; this canvas has to be visible for it. */
}

/* clouds drift on their own clock, independent of scroll */
@keyframes jdrift{from{transform:translateX(-7vw)}to{transform:translateX(7vw)}}
@media (prefers-reduced-motion: reduce){
  :root:not(.force-motion) .jcloud{animation:none!important}
}

/* A <canvas> is a REPLACED element: `inset:0` alone will not stretch it, because
   with width/height auto it keeps its intrinsic 300x150 no matter what the insets
   say. Same trap as <img>. State the size explicitly. */
.journey-fx{width:100%;height:100%}

/* Clouds must NOT share the scenery's ink or they read as hills in the sky.
   Lighter, cooler, and translucent so the deck layers instead of stacking slabs. */
.jcloud{color:#b9ab98;mix-blend-mode:multiply}
.journey-sky.is-clouded .jcloud{opacity:calc(var(--cl-op,.5) * .62)}
[data-sky="storm"] .jcloud{color:#8d8172}
[data-sky="snow"] .jcloud{color:#c3c6cc}

/* the caption needs to survive a full sky behind it — carried by the paper scrim
   alone. It used to also run backdrop-filter:blur(1.5px), which this sheet's own
   comment (see the cut-paper note above) calls outlawed. The gradient stops were
   raised .88→.94 and .62→.74 to take over the legibility work the blur was doing. */
.journey-copy{padding:.7rem .9rem;border-radius:2px;
  background:linear-gradient(to right, rgba(244,236,220,.94), rgba(244,236,220,.74) 78%, transparent)}

/* full-frame stage tint, so a storm actually darkens the whole scene rather
   than only glowing at the horizon */
.journey-view::before{content:"";position:absolute;inset:0;z-index:5;pointer-events:none;
  background:var(--sky-tint,transparent);transition:background-color 1.8s ease}

/* A second ink tone for genuine darkness inside a shape — the mouth of a cave is
   not paper-coloured, so class="cut" is wrong for it. Used by set-cavemouth. */
/* .deep now lives in cast.css alongside .cut */

/* ---- the clash actually resolves ----------------------------------------
   reveal() has always written data-outcome="win"|"lose" onto #beat-battle
   (map.js:855) and NOTHING in the site has ever read it — the roll's entire
   response lived inside the playbill card, so the stage never moved. These rules
   are that missing consumer. Everything below hangs off the one attribute, so not
   a line of existing JS changes.
   Deliberately NOT transformed: the party puppets. They carry the riseIn entrance
   and the sway, and overriding `transform`/`animation` on them would fight both. The
   fire sweeping across them is what reads as "engulfed". */
/* id-scoped on purpose: these carry .puppet for its positioning, and .puppet's beat
   entrance animation fills opacity:1, which outranks any plain class rule here — so
   both flats were on stage before a die was ever thrown. `animation:none` drops the
   entrance; the outcome rules below are more specific still and reinstate their own. */
/* z above .lyr--near: the fire has to pass IN FRONT of the party to engulf them, and
   the bolt has to land in front of the dragon. */
#beat-battle .fx-fire,#beat-battle .fx-bolt{opacity:0;animation:none;pointer-events:none;z-index:26}
#beat-battle .fx-fire{transform-origin:100% 50%}
#beat-battle .fx-bolt{transform-origin:50% 0}

/* --- the dragon wins: it rears off the rock and lets go --- */
/* the head rears UP and tips its snout DOWN. The art faces left, so a POSITIVE
   rotation drops the muzzle — it used to be -9deg, i.e. nose in the air, which is
   why the breath could only ever sail over the party. */
/* `animation:none` is load-bearing, not tidiness: .puppet's `riseIn` entrance is still
   filling `transform` on this element, and an ANIMATION outranks a normal declaration
   in the cascade — so the rear below was being discarded outright and the head never
   left the mountains. Dropping the finished entrance hands transform back to the
   cascade; riseIn ends at identity, so nothing shifts when it goes. */
#beat-battle[data-outcome="lose"] .bat-dragon{
  animation:none;
  /* riseIn was filling opacity as well as transform, so dropping it made the dragon
     vanish outright — the entrance starts from opacity 0. Restate it. */
  opacity:1;
  transform:translate(-3%,-30%) rotate(-12deg);
  transition:transform .75s var(--snap)}
/* fire follows a beat later, so the rear reads first */
#beat-battle[data-outcome="lose"] .fx-fire{
  animation:breathe .85s var(--snap) .32s forwards}
/* rotate FIRST, then grow along the flame's own axis: transform-origin is the muzzle
   end (right), so the far end sweeps down-left across the party as it extends. */
/* NEGATIVE: the art extends LEFT of its origin, and for a point left of the pivot a
   positive CSS rotation lifts it — +12deg fired the breath into the sky. */
@keyframes breathe{
  from{opacity:0;transform:rotate(-12deg) scaleX(.12) translateX(16%)}
  50%{opacity:1}
  to{opacity:1;transform:rotate(-12deg) scaleX(1) translateX(0)}}

/* --- the party wins: the spell lands and the dragon goes down --- */
/* steps() is deliberate — a stop-motion snap, not a slide. But 3 frames across .42s
   read as a stutter rather than as a strike, so: more frames, less time. */
#beat-battle[data-outcome="win"] .fx-bolt{
  animation:strike .3s steps(5) forwards}
@keyframes strike{
  from{opacity:0;transform:translateY(-46%) scaleY(.35)}
  to{opacity:1;transform:none}}

/* The landing burst, timed to the impact rather than to the bolt's departure.
   `strike` finishes at .3s, so the burst starts there — it is now something the bolt
   CAUSED. Sharing the bolt's symbol meant it appeared on frame one, floating beside a
   bolt that had not arrived yet, which is what read as a stray shape. */
#beat-battle .fx-bolt .bolt-burst{opacity:0;transform-box:fill-box;transform-origin:50% 50%}
#beat-battle[data-outcome="win"] .fx-bolt .bolt-burst{
  animation:burst .26s var(--snap) .3s forwards}
@keyframes burst{
  from{opacity:0;transform:scale(.3)}
  55%{opacity:1;transform:scale(1.3)}
  to{opacity:1;transform:scale(1)}}

#beat-battle[data-outcome="win"] .bat-dragon{
  animation:none;   /* same reason as the lose branch above */
  /* Was translateY(38%) + opacity:.3, and it read as "the dragon vanished" rather than
     "the dragon went down". Two things compounded: .bat-dragon lives inside .lyr--mid,
     which carries opacity var(--mid-fade) = .62, so .3 became ~.19 on screen; and 38%
     of its own height carried it into .stage's overflow:hidden. Felled, but still in
     the scene — you have to be able to SEE what you just beat. */
  transform:translateY(16%) rotate(24deg);opacity:.72;
  transition:transform .9s .3s var(--calm),opacity .9s .3s linear}

/* reduced motion still APPLIES the outcome — only the tween is skipped, which
   matters doubly here because on a reduced-motion machine the roll's die tumble
   and numeral flicker are already nulled, so this beat IS the feedback. */
@media (prefers-reduced-motion:reduce){
  /* Every transform below needs !important: map.css:574 blanket-sets
     `.puppet{transform:none!important}` under reduced motion, which would erase the
     whole outcome — the dragon would never rear and the flame would never angle. The
     POSE is state; only the tween is skipped. Dave's machine has RM on, so this branch
     is the one that actually ships. */
  :root:not(.force-motion) #beat-battle[data-outcome] .fx-fire,
  :root:not(.force-motion) #beat-battle[data-outcome] .fx-bolt{animation:none}
  :root:not(.force-motion) #beat-battle[data-outcome] .bat-dragon{transition:none}
  :root:not(.force-motion) #beat-battle[data-outcome="lose"] .fx-fire{
    opacity:1!important;transform:rotate(-12deg)!important}
  :root:not(.force-motion) #beat-battle[data-outcome="lose"] .bat-dragon{
    transform:translate(-3%,-30%) rotate(-12deg)!important}
  :root:not(.force-motion) #beat-battle[data-outcome="win"] .fx-bolt{
    opacity:1!important;transform:none!important}
  /* the burst is part of the outcome STATE, not decoration — it must still show,
     only its pop is skipped. Without this it would stay at its authored opacity:0. */
  :root:not(.force-motion) #beat-battle[data-outcome="win"] .fx-bolt .bolt-burst{
    animation:none;opacity:1!important;transform:none!important}
  :root:not(.force-motion) #beat-battle[data-outcome="win"] .bat-dragon{
    transform:translateY(16%) rotate(24deg)!important;opacity:.72}
}

/* the wing sits BEHIND the body row and is pulled onto the shoulder */
.dseg--wing{margin-left:calc(-16vh * var(--dscale,1));opacity:.92}

/* the sun must clear the mountain on the right; smaller and higher */
.jsky-sun{right:7%;top:3%;width:clamp(80px,11vw,150px)}

/* FROST — the previous alphas were far too low to see. This is edge creep:
   strong at the four corners, reaching in, with the milky turbulence film over it. */
.journey-frost{
  background:
    radial-gradient(62% 52% at 0% 100%, rgba(236,246,255,.92) 0%, rgba(214,232,250,.52) 38%, transparent 70%),
    radial-gradient(60% 50% at 100% 0%,  rgba(236,246,255,.86) 0%, rgba(214,232,250,.46) 40%, transparent 72%),
    radial-gradient(54% 46% at 100% 100%,rgba(236,246,255,.80) 0%, rgba(214,232,250,.40) 42%, transparent 72%),
    radial-gradient(54% 46% at 0% 0%,    rgba(236,246,255,.80) 0%, rgba(214,232,250,.40) 42%, transparent 72%),
    linear-gradient(to right, rgba(226,240,254,.55) 0%, transparent 16%, transparent 84%, rgba(226,240,254,.55) 100%),
    linear-gradient(to bottom, rgba(226,240,254,.42) 0%, transparent 14%, transparent 86%, rgba(226,240,254,.48) 100%),
    url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' seed='9' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.86  0 0 0 0 0.91  0 0 0 0 0.98  0 0 0 0.20 0'/></filter><rect width='240' height='240' filter='url(%23g)'/></svg>");
}

/* ---- the feature dial list (beat 2) --------------------------------------
   Concrete capability list under a lede. Two-column on desktop so six items
   read as a spec sheet rather than a wall; one column on a phone. */
.dial-list{list-style:none;margin:1.1rem 0 0;padding:0;
  display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.55rem .9rem;max-width:46ch}
.dial-list li{display:flex;flex-direction:column;gap:.1rem;
  padding:.4rem .55rem;border-left:2px solid var(--rubric);
  background:linear-gradient(to right, rgba(74,51,32,.06), transparent)}
.dial-list b{font:600 .78rem/1.2 var(--font-mono,ui-monospace,monospace);
  letter-spacing:.04em;color:var(--ink-900);text-transform:uppercase}
.dial-list span{font:italic .8rem/1.3 Georgia,serif;color:var(--ink-soft,#6b5a47)}
@media (max-width:760px){
  .dial-list{grid-template-columns:1fr;max-width:none}
}

/* the rule sits on the left edge of each item, so the text must start there too */
.dial-list,.dial-list li{text-align:left}

/* The spec sheet moved out of the old beat 2 and into the proof section on
   2026-08-04, so it no longer needs the right-aligned-beat correction. Here it
   has the full column, so the 46ch cap that kept it clear of the beat art goes. */
.proof-dial{max-width:60ch;margin:2.6rem auto 0}
.proof-dial h3{font-family:var(--font-display);font-size:1.35rem;color:var(--ink-900);
  margin:0 0 .5rem;padding-bottom:.5rem;border-bottom:1px solid rgba(74,51,32,.22)}
.proof-dial-lede{font:.96rem/1.55 Georgia,serif;color:var(--ink-700);margin:0}
/* One column here, not two. At this measure a 2-up grid gives each item ~230px
   and four of the six descriptions wrap to a second line; stacked, every one
   fits on a single line and the block lines up with the card below it. */
.proof-dial .dial-list{max-width:none;margin-top:.9rem;grid-template-columns:1fr}

/* ===== THE PROOF SECTION =================================================
   Mobile-first: one column, thumbs full width. Desktop gets two columns and
   the extra polish. This section is deliberately plain-flowing (not a .beat)
   because it is the page's crawlable weight — real headings, real captions.
   ------------------------------------------------------------------------- */
.proof{position:relative;z-index:2;padding:clamp(3rem,9vh,6rem) clamp(1.1rem,6vw,6rem);
  background:linear-gradient(to bottom, rgba(74,51,32,.05), transparent 30%)}
.proof-head{max-width:60ch;margin:0 auto 2rem}
.proof-head h2{font-family:var(--font-display);font-size:clamp(1.8rem,6vw,2.9rem);
  color:var(--ink-900);margin:.3rem 0 .7rem;line-height:1.04}
.proof-lede{font:1.02rem/1.55 Georgia,serif;color:var(--ink-700)}

.proof-cols{display:grid;grid-template-columns:1fr;gap:2.2rem;max-width:1180px;margin:0 auto}
.proof-col h3{font:600 .74rem/1 var(--font-mono,ui-monospace,monospace);letter-spacing:.16em;
  text-transform:uppercase;color:var(--rubric-deep);margin:0 0 .9rem;
  padding-bottom:.45rem;border-bottom:1px solid rgba(74,51,32,.22)}
.proof-list{list-style:none;margin:0;padding:0;display:grid;gap:1.4rem}

/* The sheet, and the corner you lift it by.
   The figure is the positioning context for the curl; the img keeps the frame. */
.proof figure{margin:0;position:relative}
.proof figure img{display:block;width:100%;height:auto;border:1px solid rgba(74,51,32,.28);
  border-radius:2px;background:var(--ink-900);
  /* flat offsets: these were `0 10px 26px` / `0 16px 34px`, blurred shadows on a
     zero-blur-offset site. */
  box-shadow:4px 4px 0 rgba(28,19,10,.22);cursor:zoom-in;
  transition:box-shadow .2s ease}

/* The hover was `transform:translateY(-3px)` -- a hover-lift, the single most generic
   motion on the web, and the ONLY motion investment in this 4.7-screen section. It is
   replaced by a thing paper actually does: the top-right corner lifts off the sheet.
   A hard two-stop gradient, so it is a fold and not a shadow. The image itself never
   moves, which is the point: nothing travels, so there is nothing to disable. */
.proof figure::after{
  content:"";position:absolute;top:0;right:0;width:0;height:0;
  background:linear-gradient(225deg, var(--paper-300,#e6d5b2) 50%, var(--ink-700,#4a3320) 50%);
  border-left:1px solid rgba(74,51,32,.28);border-bottom:1px solid rgba(74,51,32,.28);
  transition:width .18s var(--snap,ease),height .18s var(--snap,ease);
  pointer-events:none}
.proof figure:hover::after,
.proof figure:focus-within::after{width:15px;height:15px}
/* the flat cast notches back as the corner leaves the sheet */
.proof figure:hover img,
.proof figure:focus-within img{box-shadow:2px 5px 0 rgba(28,19,10,.30)}
/* map.js promotes these to role=button/tabindex=0, so they are keyboard openers and
   need an authored ring — the UA default is unreliable on a transformed image. */
.proof figure img:focus-visible{outline:2px solid var(--ink-900);outline-offset:3px}
.proof figcaption{font:.9rem/1.5 Georgia,serif;color:var(--ink-700);margin-top:.55rem}
.proof figcaption b{font-family:var(--font-display);font-weight:600;color:var(--ink-900);
  display:block;font-size:1.02rem;margin-bottom:.1rem}

.proof-deal{max-width:60ch;margin:2.6rem auto 0;padding:1.2rem 1.3rem;
  background:rgba(255,252,244,.72);border:1px solid rgba(74,51,32,.24);border-radius:3px}
.proof-deal h3{font-family:var(--font-display);font-size:1.25rem;color:var(--ink-900);margin:0 0 .7rem}
.proof-deal ul{margin:0;padding-left:1.1rem;display:grid;gap:.45rem}
.proof-deal li{font:.98rem/1.5 Georgia,serif;color:var(--ink-700)}
.proof-deal b{color:var(--ink-900)}
.proof-note{font:italic .88rem/1.5 Georgia,serif;color:var(--ink-soft,#6b5a47);margin:.9rem 0 0}

@media (min-width:860px){
  .proof-cols{grid-template-columns:1fr 1fr;gap:3rem}
}

/* ---- the overprint: Act IV's second pass ------------------------------------
   Set in rubric, not ink, and knocked 2px off the surrounding baseline — the
   printer's tell for a plate run over an already-printed sheet. It must NOT match
   the lede above it; if it lines up perfectly it reads as original copy and the
   whole point is lost. Flat offsets only, no blur: the ink-reveal supplies the
   soak. --font-rubric is exempt from the 1.6rem display floor (different face). */
.overprint{
  font:600 .96rem/1.5 var(--font-rubric,Georgia,serif);
  color:var(--rubric-deep);
  margin:.9rem 0 0;padding-left:.15em;
  transform:translateY(2px) rotate(-.35deg);
  transform-origin:0 50%;
  max-width:34ch}
.overprint[hidden]{display:none}
@media (prefers-reduced-motion: reduce){
  /* The line still PRINTS for RM visitors — ink-reveal.js adds .is-inked (opacity:1)
     BEFORE it returns early, so the text is shown, not hidden. Only the soak is
     skipped. The rotation is a static print artefact, not a tween: it stays. */
  :root:not(.force-motion) .overprint{transition:none}
}

/* ---- inline "see it" trigger, for use mid-story ---- */
.seeit{display:inline-flex;align-items:center;gap:.34em;vertical-align:baseline;
  font:600 .7rem/1 var(--font-mono,ui-monospace,monospace);letter-spacing:.08em;
  text-transform:uppercase;color:var(--rubric-deep);background:rgba(227,66,52,.08);
  border:1px solid rgba(227,66,52,.36);border-radius:2px;padding:.28em .5em;
  cursor:pointer;margin-left:.35em;transition:background .2s ease,color .2s ease}
/* --rubric-deep, not --rubric: #fff8ec on #e34234 is 3.90:1, under AA for this 11.2px
   text. #fff8ec on --rubric-deep clears 4.5:1 and keeps the same ink-on-red reading. */
.seeit:hover,.seeit:focus-visible{background:var(--rubric-deep,#9c2b1c);color:#fff8ec;border-color:transparent}
.seeit::before{content:"";width:.62em;height:.62em;border:1.5px solid currentColor;border-radius:50%}

/* ---- the lightbox ---- */
.lb{border:0;padding:0;background:transparent;max-width:min(1200px,94vw);max-height:92vh;
  color:var(--ink-900);overflow:visible}
/* .86 ink, no backdrop-filter. The blur was doing no legibility work behind an
   already-opaque scrim, and backdrop-filter is named outright by the grammar. */
.lb::backdrop{background:rgba(22,14,8,.86)}
.lb-fig{margin:0;background:var(--paper-100,#fdf8ee);border:1px solid rgba(74,51,32,.3);
  /* was `0 24px 60px rgba(0,0,0,.5)` — the largest blur on the site, in a pure black
     that exists nowhere in tokens.css. Flat offset in the sheet's own ink instead. */
  border-radius:3px;padding:.6rem .6rem .1rem;box-shadow:8px 8px 0 rgba(28,19,10,.32)}
.lb-fig img{display:block;width:100%;height:auto;max-height:74vh;object-fit:contain;
  background:var(--ink-900);border-radius:2px}
.lb-fig figcaption{font:.92rem/1.5 Georgia,serif;color:var(--ink-700);padding:.7rem .3rem .8rem}
.lb-fig figcaption b{font-family:var(--font-display);color:var(--ink-900);display:block;margin-bottom:.15rem}
.lb-close{position:absolute;top:-.85rem;right:-.85rem;width:2.1rem;height:2.1rem;
  border-radius:50%;border:1px solid rgba(74,51,32,.4);background:var(--paper-100,#fdf8ee);
  color:var(--ink-900);font-size:1.25rem;line-height:1;cursor:pointer;z-index:2}
.lb-close:hover,.lb-close:focus-visible{background:var(--rubric,#e34234);color:#fff8ec}

@media (max-width:760px){
  .lb{max-width:96vw}
  .lb-close{top:-.6rem;right:-.4rem}
}
@media (prefers-reduced-motion: reduce){
  /* The curl still APPEARS for reduced-motion visitors, it just appears instantly.
     This rule used to say transform:none, which -- with the old hover-lift -- left RM
     visitors with no hover or focus feedback on these ten controls at all. Killing the
     transition is the correct RM behaviour; killing the state was not. */
  :root:not(.force-motion) .proof figure img,
  :root:not(.force-motion) .proof figure::after{transition:none!important}
}

/* <dialog> centring: setting max-width/max-height overrides the UA's auto
   margins, so it has to be restated or the modal hugs the top-left. The close
   button also has to sit INSIDE the box, or it clips against the viewport. */
.lb{margin:auto;position:fixed;inset:0}
.lb-close{top:.5rem;right:.5rem;box-shadow:0 2px 8px rgba(0,0,0,.35)}

/* the "why a GM switches" block — the differentiators, mobile-first single column */
.proof-why{max-width:1180px;margin:2.8rem auto 0}
.proof-why h3{font-family:var(--font-display);font-size:1.35rem;color:var(--ink-900);
  margin:0 0 1rem;padding-bottom:.5rem;border-bottom:1px solid rgba(74,51,32,.22)}
.why-grid{display:grid;grid-template-columns:1fr;gap:1.5rem}
.why-grid > div{padding-left:.9rem;border-left:2px solid var(--rubric)}
.why-grid b{display:block;font-family:var(--font-display);font-size:1.06rem;
  color:var(--ink-900);margin-bottom:.35rem;line-height:1.25}
.why-grid p{font:.96rem/1.55 Georgia,serif;color:var(--ink-700);margin:0}
@media (min-width:860px){ .why-grid{grid-template-columns:1fr 1fr;gap:1.7rem 2.4rem} }

/* The camera scales .stage past the viewport (by design — it pushes in), but no
   beat clipped it, so the page carried a horizontal scrollbar on desktop.
   A proscenium SHOULD clip. Excluded: .beat--journey, because overflow:hidden on
   an ancestor kills position:sticky and the whole journey depends on it. */
.beat:not(.beat--journey){overflow:hidden}

/* the line under the final CTA: sets expectations so the button is not a mystery */
.cta-sub{font:italic .92rem/1.5 Georgia,serif;color:var(--ink-soft,#6b5a47);
  max-width:44ch;margin:.7rem auto 0}
.beat--center .cta-sub{margin-left:auto;margin-right:auto}

/* ---- LEGIBILITY: the display face has a size floor -----------------------
   'BN Dopeneck' is a heavy condensed display type. It carries a 2.9rem heading
   and dies at 1rem — which is where the caption labels and sub-headings live,
   so they were effectively unreadable. RULE: --font-display is for >=1.6rem
   only. Everything smaller uses the reading serif, with weight and a little
   letter-spacing to keep it feeling like a heading. */
.proof figcaption b,
.why-grid b{
  font-family:var(--font-body);
  font-weight:700;
  font-size:1.06rem;
  letter-spacing:.005em;
  line-height:1.3;
  color:var(--ink-900);
}
.proof-why h3,
.proof-dial h3,
.proof-deal h3{
  font-family:var(--font-body);
  font-weight:700;
  font-size:1.3rem;
  letter-spacing:.01em;
  color:var(--ink-900);
}
/* the dial list had the same problem: mono at .78rem in caps */
.dial-list b{
  font-family:var(--font-body);
  font-weight:700;
  font-size:.92rem;
  letter-spacing:.01em;
  text-transform:none;
}

/* The CTA is the single most important string on the page — it cannot be set in
   a condensed display face at 17px. */
.seal-label{
  font-family:var(--font-body);
  font-weight:700;
  font-size:1.02rem;
  letter-spacing:.06em;
  text-transform:none;
}

/* The hero kicker used to be decoration ("Curtain up") and is now doing a job:
   it tells the visitor what the product IS. Decorative type at 12px cannot carry
   that. The other kickers stay as they are — they are still just labels. */
.playbill .kicker.intro{
  font-family:var(--font-body);
  font-weight:600;
  font-size:.95rem;
  letter-spacing:.06em;
  text-transform:none;
  color:var(--ink-700);
}

/* ---- PUPPETEERING THE DRAGON -------------------------------------------
   It read as floating because nothing held it up and the only motion was a
   1-2deg sway you had to pause the page to notice. Two changes:

   1. Every segment gets a ROD. The rods cannot be z-indexed behind the hills —
      the dragon lives inside the mid layer, which paints above the far layer —
      so instead each rod FADES OUT along its length. The eye reads that as the
      stick passing behind the scenery, which is the same illusion for none of
      the stacking-context surgery.
   2. Every segment BOBS, and the existing --sdelay ladder (-0.3s per segment,
      already authored for the sway) turns that bob into a wave travelling from
      head to tail. That is what makes it look carried rather than slid. */
.dseg{position:relative;
  animation:dbob var(--bd,2.4s) ease-in-out infinite;
  animation-delay:var(--sdelay,0s)}

/* the keyframes must carry --dy through, or animating transform would wipe out
   the per-segment vertical offset that gives the body its arc */
@keyframes dbob{
  0%,100%{transform:translateY(var(--dy,0))}
  50%    {transform:translateY(calc(var(--dy,0) - 4.6vh))}
}

.dstick{position:absolute;top:88%;left:47%;width:3px;height:66vh;
  transform-origin:50% 0;pointer-events:none;z-index:-1;
  /* The rod stays INSIDE its segment so it can never drift from the body.
     .dragon carries a transform, which makes a stacking context, so this can
     never paint behind the hills — it terminates just short of them instead.
     A parallel row placed below the hills WAS tried: it bought occlusion and
     lost attachment, because a segment's CSS margins are not in the inline
     vars the row mirrored. Attachment matters more. */
  background:rgba(43,32,22,.72)}

@media (prefers-reduced-motion:reduce){
  /* state still applies: the dragon is present, rods and all — it just holds still */
  :root:not(.force-motion) .dseg{animation:none!important;transform:translateY(var(--dy,0))}
}
@media (max-width:760px){ .dstick{height:26vh;width:2px} }

/* ===== THE BATTLE ========================================================
   Mobile-first. The d20 is a real button (tap target >=56px) because on a phone
   this is the one thing the visitor has to do.
   ------------------------------------------------------------------------- */
.playbill--battle{max-width:38ch}
.beat--battle .bat-dragon{z-index:4}
.beat--battle .bat-dragon .dstick{height:30vh;left:48%}

/* initiative — the GM's roll, so it may appear on its own */
.init{margin-top:1rem;text-align:left}
.init-head{font:600 .68rem/1 var(--font-mono,ui-monospace,monospace);letter-spacing:.18em;
  text-transform:uppercase;color:var(--rubric-deep);margin:0 0 .5rem}
.init-list{list-style:none;margin:0;padding:0;display:grid;gap:.32rem}
.init-list li{display:flex;align-items:baseline;gap:.6rem;
  font:.95rem/1.4 var(--font-body);color:var(--ink-700);
  padding:.3rem .5rem;background:rgba(255,252,244,.55);
  border-left:2px solid rgba(74,51,32,.3);
  opacity:0;transform:translateX(-.5rem);
  transition:opacity .45s ease,transform .45s var(--runner,cubic-bezier(.22,1,.36,1))}
.init-list li.is-in{opacity:1;transform:none}
.init-list li.is-you{border-left-color:var(--rubric);background:rgba(227,66,52,.10)}
.init-list b{font-weight:700;color:var(--ink-900);min-width:1.6rem}
.init-list span{margin-left:auto;font:600 .8rem/1 var(--font-mono,ui-monospace,monospace);
  color:var(--ink-soft,#6b5a47)}

/* the roll */
.turn{margin-top:1.2rem}
.turn-line{font:1.05rem/1.45 var(--font-body);font-weight:700;color:var(--ink-900);margin:0 0 .7rem}
.turn-note{font:italic .84rem/1.45 Georgia,serif;color:var(--ink-soft,#6b5a47);
  margin:.7rem auto 0;max-width:30ch}

.d20{position:relative;display:inline-flex;flex-direction:column;align-items:center;
  justify-content:center;width:9rem;height:9rem;padding:0;border:0;background:none;
  cursor:pointer;color:var(--ink-900);-webkit-tap-highlight-color:transparent}
.d20-shape{position:absolute;inset:0;width:100%;height:100%;
  filter:drop-shadow(0 6px 14px rgba(28,19,10,.34));
  transition:transform .3s ease}
.d20-shape path{fill:var(--ink-900)}
.d20-shape .d20-facet{fill:none;stroke:rgba(255,244,220,.24);stroke-width:1.2}
.d20:hover .d20-shape,.d20:focus-visible .d20-shape{transform:scale(1.05) rotate(-4deg)}
.d20-num{position:relative;font-family:var(--font-display);font-size:2.2rem;line-height:1;
  color:var(--paper-100,#fdf8ee);text-shadow:0 2px 6px rgba(0,0,0,.5)}
.d20-hint{position:relative;margin-top:.35rem;
  font:600 .6rem/1 var(--font-mono,ui-monospace,monospace);letter-spacing:.14em;
  text-transform:uppercase;color:rgba(253,248,238,.72)}

.d20.is-rolling{pointer-events:none}
.d20.is-rolling .d20-shape{animation:d20tumble .9s cubic-bezier(.3,.9,.3,1) both}
.d20.is-rolling .d20-num{animation:d20flicker .9s steps(1) both}
@keyframes d20tumble{
  0%{transform:rotate(0) scale(1)}
  35%{transform:rotate(320deg) scale(1.16)}
  70%{transform:rotate(640deg) scale(.96)}
  100%{transform:rotate(720deg) scale(1)}
}
@keyframes d20flicker{
  0%,100%{opacity:1} 10%{opacity:.35} 20%{opacity:1} 30%{opacity:.4}
  45%{opacity:1} 60%{opacity:.5} 75%{opacity:1} 90%{opacity:.6}
}
.d20.is-crit .d20-shape path:first-child{fill:#8d2f16}
.d20.is-fumble .d20-shape path:first-child{fill:#2b2016}
.d20.is-done .d20-num{font-size:2.9rem;color:var(--gold,#c8aa6e)}

/* the result */
.outcome{margin-top:1.1rem}
.outcome-crit{font-family:var(--font-display);font-size:clamp(1.6rem,7vw,2.4rem);
  line-height:1;color:var(--rubric-deep);margin:0 0 .6rem}
.outcome-crit--fail{color:var(--ink-700)}
.outcome-go{display:inline-block;margin-top:.6rem;
  font:600 .74rem/1 var(--font-mono,ui-monospace,monospace);letter-spacing:.12em;
  text-transform:uppercase;color:var(--rubric-deep);text-decoration:none;
  border-bottom:1px solid currentColor;padding-bottom:.2rem}
.outcome-go:hover{color:var(--ink-900)}

@media (max-width:760px){
  .d20{width:7.6rem;height:7.6rem}
  .d20-num{font-size:1.9rem}
  .playbill--battle{max-width:none}
}
@media (prefers-reduced-motion:reduce){
  /* state still applies — the die still resolves, it just does not tumble */
  :root:not(.force-motion) .d20.is-rolling .d20-shape,
  :root:not(.force-motion) .d20.is-rolling .d20-num{animation:none!important}
  :root:not(.force-motion) .init-list li{transition:none!important}
  :root:not(.force-motion) .init-list li.is-in{opacity:1;transform:none}
}



/* the recorded roll, once it exists. Sits over the SVG die and only shows while
   playing; if the file is missing the video never becomes visible and the SVG
   fallback carries the moment instead. */
.d20-vid{position:absolute;inset:-12%;width:124%;height:124%;object-fit:contain;
  opacity:0;pointer-events:none;transition:opacity .18s ease}
.d20.is-filmed .d20-vid{opacity:1}
.d20.is-filmed .d20-shape,
.d20.is-filmed .d20-num,
.d20.is-filmed .d20-hint{opacity:0}
.d20.is-filmed.is-done .d20-num{opacity:1}

/* ---- beat 3: solid hills, so the dragon's rods genuinely disappear ---------
   Everything on this page is deliberately translucent so the paper grid reads
   through it. That is right everywhere EXCEPT here: the dragon is the subject of
   this frame and it has to look like a real puppet, which means its rods must
   vanish behind something, not glow through it.
   So ONLY the hills go opaque, and they are recoloured to the exact tone they
   already rendered at — ink-900 (#1c130a) composited at the layer's 0.34 over
   the warm paper behind it, which lands on rgb(152,139,112). Nothing should look
   different; the hills simply stop being see-through. Grid still shows through
   every other layer. */
#beat-3 .lyr--far{opacity:1}
#beat-3 .lyr--far .puppet{color:rgb(152,139,112)}

/* Rods must terminate INSIDE the hill band, never below it, or the tip reappears
   under the hills and the illusion dies. Segments sit at different heights, so
   rod ends span ~260px; 24vh puts the whole spread between the band's top and
   bottom edges. Re-measure if segment heights or --dy values change. */
#beat-3 .dstick{height:24vh}

/* The wing's rod needs its own placement. At the shared left:47% it started in
   empty paper — the wing is a diagonal membrane, so 47% across / 88% down falls
   well below its lower edge. Its actual root is the bottom-left corner, around
   x=28,y=226 of the 300x240 box. And because the wing rides 9vh higher than the
   body segments, its rod has to be longer to reach the same hill band. */
#beat-3 .dseg--wing .dstick{left:11%;top:92%;height:40vh}
