/* MCNA MAN II — global chrome.
   Screen-specific styling lives with each screen module; this file holds only
   what is genuinely global: the reset, the design tokens, and the keyframes.
   Border-radius is 0 everywhere in this design. Nothing here should add one. */

:root {
  --ink: #08080e;
  --ink-2: #0b0b18;
  --panel: #10102a;
  --panel-2: #23233a;
  --border-hi: #e8e8f0;
  --border-lo: #3a3a5a;
  --text: #e8e8f0;
  --text-dim: #8a8ab8;
  --text-dimmer: #55557a;
  --accent-cyan: #3bd6e8;
  --accent-gold: #f8b800;

  --font-ui: 'Press Start 2P', monospace;
  --font-body: 'VT323', monospace;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--ink);
  overflow: hidden;
}

a { color: var(--accent-cyan); text-decoration: none; }
a:hover { color: var(--accent-gold); }

::selection { background: var(--accent-cyan); color: var(--ink); }

#app {
  position: fixed;
  inset: 0;
  overflow: hidden;
  background: var(--ink);
  font-family: var(--font-ui);
  color: var(--text);
  /* iOS needs all three. Plain user-select alone does NOT stop Safari's
     long-press: holding a control still selects its glyph and raises the
     copy/paste callout with the magnifier, which is fatal for a button you are
     meant to hold down to run. -webkit-touch-callout kills the callout,
     the prefixed user-select kills the selection, and tap-highlight removes
     the grey flash on every tap. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

/* Toggleable via body[data-scanlines="off"]. Sits above everything but the
   rotate prompt, and must never intercept pointer events. */
#scanlines {
  position: fixed;
  inset: 0;
  z-index: 40;
  pointer-events: none;
  background: repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 2px, transparent 2px 4px);
  mix-blend-mode: multiply;
}
body[data-scanlines="off"] #scanlines { display: none; }

@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }

/* Three hard flashes over 500ms when a stage is picked. step-end matters:
   these are on/off, never a fade. */
@keyframes selectflash {
  0%,14%   { opacity: 1 }  15%,29%  { opacity: 0 }
  30%,44%  { opacity: 1 }  45%,59%  { opacity: 0 }
  60%,74%  { opacity: 1 }  75%,100% { opacity: 0 }
}

/* Stage-intro warp field. Each layer travels exactly one tile width, so the
   loop is seamless. Big stars, fast motion — this is a warp field, not the
   title screen's static sky. */
@keyframes starA { from { background-position: 0 0 }       to { background-position: -190px 0 } }
@keyframes starB { from { background-position: 44px 58px } to { background-position: -96px 58px } }
@keyframes starC { from { background-position: 22px 14px } to { background-position: -58px 14px } }

/* Neon press row. `neon` is the tube flicker — two brief dropouts per cycle,
   never a smooth pulse. `chase` runs the bulb dots along the sign border and
   `chasev` does the same vertically for the blade signs. Both travel exactly
   one tile (26px) so the loop is seamless. */
@keyframes neon   { 0%,42% { opacity: 1 } 43% { opacity: .42 } 44% { opacity: 1 }
                    86% { opacity: 1 } 87% { opacity: .5 } 88% { opacity: 1 } 100% { opacity: 1 } }
@keyframes chase  { from { background-position: 0 0 } to { background-position: 26px 0 } }
@keyframes chasev { from { background-position: 0 0 } to { background-position: 0 26px } }
@keyframes neonpop { 0%,100% { opacity: .28 } 50% { opacity: 1 } }

/* Powerup: notes drifting up off the guitar, and the sound-wave rings. */
@keyframes notefly { 0% { transform: translate(0,0) rotate(-8deg) scale(.6); opacity: 0 }
                     12% { opacity: 1 } 70% { opacity: 1 }
                     100% { transform: translate(var(--nx),-41.7vh) rotate(14deg) scale(1.15); opacity: 0 } }
@keyframes ringpush { 0% { transform: translate(-50%,-50%) scale(.05); opacity: 0 }
                      10% { opacity: .9 }
                      100% { transform: translate(-50%,-50%) scale(3); opacity: 0 } }
@keyframes blink  { 0%,49% { opacity: 1 } 50%,100% { opacity: 0 } }
@keyframes bob    { 0%,100% { transform: translateY(0) } 50% { transform: translateY(-12px) } }
@keyframes floaty { 0%,100% { transform: translateY(0) } 50% { transform: translateY(-6px) } }
@keyframes tilt   { 0%,40% { transform: rotate(0) } 60%,100% { transform: rotate(-90deg) } }

/* The press district's marquee. `bulbA` and `bulbB` are the same pattern out of
   phase: two fringe strips offset by a few pixels and running opposite halves
   of the cycle is what makes the bulbs twinkle rather than flash in unison. */
@keyframes winkA     { 0%,88% { opacity: .9 }  90%,100% { opacity: .25 } }
@keyframes bulbA     { 0%,49% { opacity: 1 }   50%,100% { opacity: .22 } }
@keyframes bulbB     { 0%,49% { opacity: .22 } 50%,100% { opacity: 1 } }
@keyframes glowpulse { 0%,100% { opacity: .5 } 50% { opacity: .92 } }
@keyframes spin      { to { transform: rotate(360deg) } }

/* Set dressing: par cans sweeping, pyro tongues rising, and the mirror-ball
   glints, which are step-end so they snap rather than fade. */
@keyframes sweep { 0%,100% { transform: rotate(-9deg) } 50% { transform: rotate(9deg) } }
@keyframes rise  { 0% { transform: translateY(0); opacity: 1 }
                   100% { transform: translateY(-64px); opacity: 0 } }
@keyframes glint { 0%,84% { opacity: 0 } 86%,94% { opacity: 1 } 100% { opacity: 0 } }

/* Stage 2: the pool, the court and the crowd bands.

   SPRITE-SHEET STEPPERS ARE PER SCALE. Desktop and phone render the same sheet
   at different widths, so one shared keyframe steps by the wrong distance on
   one of them and the sprite slides mid-frame instead of snapping to it, which
   reads as a carousel. swim4 is four frames (512 desktop / 256 phone), roll3 is
   three (264 / 132). cheer steps vertically because the crowd sheets stack
   their two frames rather than laying them out in a row. */
@keyframes cheer  { from { background-position: 0 0 } to { background-position: 0 -96px } }
@keyframes cheerS { from { background-position: 0 0 } to { background-position: 0 -64px } }
@keyframes swim4d { from { background-position: 0 0 } to { background-position: -512px 0 } }
@keyframes swim4p { from { background-position: 0 0 } to { background-position: -256px 0 } }
@keyframes roll3d { from { background-position: 0 0 } to { background-position: -264px 0 } }
@keyframes roll3p { from { background-position: 0 0 } to { background-position: -132px 0 } }
@keyframes ripple { from { background-position: 0 0 } to { background-position: 52px 0 } }

/* The courtside ball. It ROCKS, it does not spin: a full 360 read as the ball
   flying in a circle rather than bouncing, and was rejected. The squash on
   floor contact is what sells the bounce. Per scale again, because the travel
   is in px. */
@keyframes dribbleD { 0% { transform: translateY(-76px) rotate(-9deg) }
                      44% { transform: translateY(0) rotate(7deg) scaleY(.84) scaleX(1.14) }
                      52% { transform: translateY(0) rotate(9deg) }
                      100% { transform: translateY(-76px) rotate(-9deg) } }
@keyframes dribbleP { 0% { transform: translateY(-38px) rotate(-9deg) }
                      44% { transform: translateY(0) rotate(7deg) scaleY(.84) scaleX(1.14) }
                      52% { transform: translateY(0) rotate(9deg) }
                      100% { transform: translateY(-38px) rotate(-9deg) } }
/* The shot arcs up, over, and down through the rim; the last 12% fades it out
   as it passes through the net. */
@keyframes shotD { 0% { transform: translate(0,0) rotate(0deg) }
                   18% { transform: translate(30px,-190px) rotate(90deg) }
                   52% { transform: translate(120px,-300px) rotate(230deg) }
                   74% { transform: translate(186px,-238px) rotate(330deg) }
                   88% { transform: translate(186px,-176px) rotate(400deg); opacity: 1 }
                   100% { transform: translate(186px,-96px) rotate(450deg); opacity: 0 } }
@keyframes shotP { 0% { transform: translate(0,0) rotate(0deg) }
                   18% { transform: translate(28px,-112px) rotate(90deg) }
                   52% { transform: translate(112px,-178px) rotate(230deg) }
                   74% { transform: translate(173px,-142px) rotate(330deg) }
                   88% { transform: translate(173px,-104px) rotate(400deg); opacity: 1 }
                   100% { transform: translate(173px,-56px) rotate(450deg); opacity: 0 } }
@keyframes netFlick { 0% { transform: scaleY(1) } 22% { transform: scaleY(1.34) }
                      55% { transform: scaleY(.9) } 100% { transform: scaleY(1) } }
/* The tricolore flies off its mast rather than hanging, so it skews rather
   than swings: transform-origin sits at the mast, 0 50%. */
@keyframes flagfly { 0%,100% { transform: skewY(0deg) scaleY(1) }
                     25% { transform: skewY(-2.4deg) scaleY(.96) }
                     50% { transform: skewY(0deg) scaleY(1) }
                     75% { transform: skewY(2.4deg) scaleY(.96) } }

/* Stage 2's medals. `orbit`/`unorbit` are the shield's two counter-rotating
   rings, `medpop` and `medglow` the medallion at rest, `medrise` the collect.
   `flagdrop`/`flagwave` are the ceremony: three flags falling in, then
   breathing. flagdrop travels 560px because that is further than any of the
   three flags is tall, so each one starts fully off the top of its own box. */
@keyframes orbit    { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
@keyframes unorbit  { from { transform: rotate(0deg) } to { transform: rotate(-360deg) } }
/* THE STAGE-3 FLYPAST. Two biplanes, one in from each side, each flying a
   LOOP-THE-LOOP with its banner curving along behind it — the fireworks are
   fired off those loops.

   ONE SHARED MOTION PATH per assembly, built in dressing3.js: a straight
   lead-in, one full circle of radius R, a straight lead-out. The plane, both tow
   segments, and every CHARACTER of the banner ride that same offset-path, spaced
   by a fixed ARC LENGTH, so the tail travels the plane's exact line and the
   banner BENDS around the loop instead of sticking out tangentially.

   Four earlier passes tried to do it with rotation, each wrong instructively,
   and none of them can work:
     - rotating the plane about its own centre: a pinwheel, 360deg of rotation
       with no translation
     - an offset pivot BELOW the assembly: orbits the wrong way round, dropping
       and swinging rather than climbing
     - one rigid rotation of the whole assembly: the plane's arc is right, but
       the banner REVOLVES around it at a fixed distance and LEADS it over the
       top. No rigid rotation of a tow can produce a trailing tail.
     - each part orbiting its own pivot with a phase lag: four concentric
       circles, not one loop — the parts splay apart instead of following.

   TRAVEL AND LOOP ARE SEPARATE ELEMENTS, and they have to be: the wrapper owns
   transform for the crossing, the parts own offset-distance for the loop, so
   neither overwrites the other.

   Travel moves a ZERO-SIZE wrapper, so translateX(-100%) would be 0 — these
   carry explicit px margins wide enough for the whole assembly, whose tail
   reaches ~355px behind the plane on desktop and ~199px on the phone stage. The
   phone tier gets its own keyframes in px because the phone stage is a fixed
   640-wide scaled box, where vw resolves against the viewport and is then scaled
   a second time. */
@keyframes flyR    { from { transform: translateX(-420px) } to { transform: translateX(calc(100vw + 420px)) } }
@keyframes flyL    { from { transform: translateX(calc(100vw + 420px)) } to { transform: translateX(-420px) } }
@keyframes flyRP   { from { transform: translateX(-260px) } to { transform: translateX(900px) } }
@keyframes flyLP   { from { transform: translateX(900px) } to { transform: translateX(-260px) } }
@property --arc { syntax: '<number>'; inherits: false; initial-value: 0; }
/* The loop itself. A keyframe can only name an ABSOLUTE offset-distance, which
   would converge every part on the same arc — so the shared keyframe animates a
   registered custom property and each part resolves its own position from it:
     offset-distance: calc(var(--start) + var(--arc) * var(--span))
   Every part therefore advances the same arc length from its own start, which is
   what holds the formation and makes the tail trace the plane's line. --span is
   the circumference plus the assembly's own length, so the rearmost character
   completes the circle too. One keyframe serves both directions: arc increases
   along the direction of travel on either path. */
@keyframes flyloop { to { --arc: 1 } }
/* the propeller, read as a disc thinning and filling rather than as blades */
@keyframes propspin     { 0%, 100% { transform: scaleX(1) } 50% { transform: scaleX(.25) } }

@keyframes medpop   { 0% { transform: scale(0); opacity: 0 }
                      60% { transform: scale(1.25); opacity: 1 }
                      100% { transform: scale(1); opacity: 1 } }
@keyframes medglow  { 0%,100% { opacity: .25 } 50% { opacity: .6 } }
@keyframes medrise  { 0% { transform: translateY(14px) scale(.3); opacity: 0 }
                      22% { transform: translateY(0) scale(1.2); opacity: 1 }
                      45% { transform: translateY(-8px) scale(1); opacity: 1 }
                      100% { transform: translateY(-64px) scale(1); opacity: 0 } }
@keyframes flagdrop { from { transform: translateY(-560px) } to { transform: translateY(0) } }
@keyframes flagwave { 0%,100% { transform: skewY(-.7deg) } 50% { transform: skewY(.7deg) } }

/* THE PARA SPRINTERS — a race in the far lane, ONE PASS AND GONE. Deliberately
   not a loop: a looping runner parks in the level and reads as scenery rather
   than a race. They start level with Jack (the 200 / 130 offset puts them at
   the gun), pull away at 537-602 px/s against his 420 — he must never keep up —
   and fade at 97% so none of them is left standing mid-track at the end.

   prun4 / prun4p are the stride: four frames stepped across an 80x26 sheet,
   160px of travel desktop and 100 at the smaller phone draw size. Each runner's
   stride carries the SAME delay as its container, so they stand still on the
   blocks before the start instead of running on the spot. */
@keyframes pararun  { from { transform: translateX(200px); opacity: 1 } 97% { opacity: 1 }
                      to { transform: translateX(5140px); opacity: 0 } }
@keyframes pararunP { from { transform: translateX(130px); opacity: 1 } 97% { opacity: 1 }
                      to { transform: translateX(3220px); opacity: 0 } }
@keyframes prun4    { from { background-position: 0 0 } to { background-position: -160px 0 } }
@keyframes prun4p   { from { background-position: 0 0 } to { background-position: -100px 0 } }

/* Every sprite canvas. Kept global so no screen can forget it. */
canvas.sprite { image-rendering: pixelated; transform-origin: 50% 100%; }

/* Hover colour for interactive chrome. Applied by class rather than inline so
   the screen modules stay markup-only. */
.hot { cursor: pointer; }
.hot:hover { color: var(--accent-gold); }

/* The inverse: gold at rest, white on hover. For a call to action that should
   read as the accent rather than as chrome. */
.hot-gold { cursor: pointer; color: var(--accent-gold); }
.hot-gold:hover { color: #fff; }

/* Keyboard focus. main.js puts every .hot / .hot-gold in the tab order; this
   is the ring they get. focus-visible only, so a mouse click never draws it.
   Square corners, like everything else here. */
:focus-visible { outline: 2px solid var(--accent-gold); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
}

/* THE PRESS LIGHT RIG. Two lamps in the top corners of the SCREEN that sweep
   from aiming at his feet, up to the board, and back down — one rig moving, not
   two lamps toggling.

   The beams are clip-path triangles with the APEX AT THE LAMP, so the narrow
   end is at the source and the wide end lands on what is lit. A triangle with
   its wide end pointed at the subject reads as a polygon, not a light.

   Down state ends AT HIS FEET (calc(100% - 96px) / -56px), not at the floor —
   that is what "focused on the sprite" means. Up state ends at the BOARD'S
   OUTER EDGES (50% - 214px / 50% + 226px desktop), because a triangle whose
   base sits under the board crosses its face, and beams over the animation were
   the single most repeated note of the round. Nothing is ever drawn over the
   board; the glow does that work.

   Rise complete by 9%, held to 88%, all on the same 10.4s clock so the dim, the
   pool fade and the board glow move together.

   The mask stops on the dim layer are PX FROM THE TOP, not percentages: the
   board is px-anchored, and percentage stops moved the clear line with the
   viewport and dimmed the board itself. */
@keyframes sweepL{0%,2%{clip-path:polygon(0 0,16px 0,calc(50% + 26px) calc(100% - 96px),calc(50% - 26px) calc(100% - 96px))}9%,88%{clip-path:polygon(0 0,16px 0,calc(50% - 214px) 330px,calc(50% - 272px) 236px)}100%{clip-path:polygon(0 0,16px 0,calc(50% + 26px) calc(100% - 96px),calc(50% - 26px) calc(100% - 96px))}}
@keyframes sweepR{0%,2%{clip-path:polygon(100% 0,calc(100% - 16px) 0,calc(50% - 26px) calc(100% - 96px),calc(50% + 26px) calc(100% - 96px))}9%,88%{clip-path:polygon(100% 0,calc(100% - 16px) 0,calc(50% + 284px) 236px,calc(50% + 226px) 330px)}100%{clip-path:polygon(100% 0,calc(100% - 16px) 0,calc(50% - 26px) calc(100% - 96px),calc(50% + 26px) calc(100% - 96px))}}
@keyframes sweepLP{0%,2%{clip-path:polygon(0 0,16px 0,calc(50% + 18px) calc(100% - 56px),calc(50% - 18px) calc(100% - 56px))}9%,88%{clip-path:polygon(0 0,16px 0,calc(50% - 139px) 236px,calc(50% - 176px) 172px)}100%{clip-path:polygon(0 0,16px 0,calc(50% + 18px) calc(100% - 56px),calc(50% - 18px) calc(100% - 56px))}}
@keyframes sweepRP{0%,2%{clip-path:polygon(100% 0,calc(100% - 16px) 0,calc(50% - 18px) calc(100% - 56px),calc(50% + 18px) calc(100% - 56px))}9%,88%{clip-path:polygon(100% 0,calc(100% - 16px) 0,calc(50% + 184px) 172px,calc(50% + 143px) 236px)}100%{clip-path:polygon(100% 0,calc(100% - 16px) 0,calc(50% - 18px) calc(100% - 56px),calc(50% + 18px) calc(100% - 56px))}}
@keyframes poolfade{0%,3%{opacity:1}10%,86%{opacity:0}100%{opacity:1}}
@keyframes glowfade{0%,4%{opacity:0}13%,86%{opacity:1}97%,100%{opacity:0}}
@keyframes dimup{0%{opacity:.62}10%,86%{opacity:1}100%{opacity:.62}}
@keyframes spotin{from{opacity:0}to{opacity:1}}

/* THE TITLE SUNRISE — night to full daylight across one pass of the theme.

   Every delay here is measured from the same instant the theme starts, which is
   the only reason plain CSS animation-delay can be trusted as the clock. The
   markup mounts on the boot press and title.js back-dates each delay against the
   boot stamp, so a remount mid-sunrise resumes instead of restarting. If that
   subtree ever mounts earlier than the music, every number below desynchronises
   silently.

   The schedule: dawn wash over the 9.6s intro, then the disc climbs for 21.1s
   through the gallop and tail while the day gradient comes up behind it, stars
   gone by 30.7s.

   sunrise travels in vw/vh ON PURPOSE. The disc starts buried in the ridge and
   has to finish near the upper-right corner at any viewport, and pixel values
   only held at one size.

   sunriseP IS THE EXCEPTION, AND IT HAS TO BE. The phone composition is authored
   at a fixed 640x360 and CSS-scaled to fit, so a vw/vh translate inside it is
   resolved against the viewport and THEN multiplied by the stage's own scale.
   Authored as 59vw/-67vh it measured an end position of x 892, y -94 in an
   844x390 viewport (stage scale 1.083) — the sun left the screen completely.
   378/241px are those same authored fractions (59% and 67%) expressed in the
   stage's own coordinate space, where they are scale-invariant and mean what
   they were meant to mean. Same reason this build uses viewW() rather than
   window.innerWidth everywhere else inside that stage. */
@keyframes sunwash{from{opacity:0}to{opacity:1}}
@keyframes daywash{from{opacity:0}to{opacity:1}}
@keyframes dawnfade{0%{opacity:1}30%{opacity:.3}62%{opacity:0}100%{opacity:0}}
@keyframes sunrise{from{transform:translate(0,0)}to{transform:translate(50vw,-66vh)}}
@keyframes sunriseP{from{transform:translate(0,0)}to{transform:translate(262px,-241px)}}
@keyframes sunhot{from{opacity:0}to{opacity:1}}
@keyframes shaftin{0%{opacity:0}100%{opacity:1}}
@keyframes sunbreathe{0%,100%{opacity:.58;transform:scale(1)}50%{opacity:.8;transform:scale(1.04)}}

/* The wink flash, fired from JS on the transition into heroWink rather than by
   a delay: a single animation-delay can only fire once, and `infinite` would
   repeat every .62s instead of once every 32s pass. */
@keyframes winkstar{0%{opacity:0;transform:translate(-50%,-50%) scale(.15) rotate(-8deg)}14%{opacity:1;transform:translate(-50%,-50%) scale(1.22) rotate(4deg)}36%{opacity:.95;transform:translate(-50%,-50%) scale(.96) rotate(8deg)}100%{opacity:0;transform:translate(-50%,-50%) scale(1.7) rotate(14deg)}}

/* STAGE 3: BEND. Copied VERBATIM from the design prototype (MCNA MAN.dc.html,
   <style> lines 44-56). Only the names the stage 3 dressing and the flight
   cutscene use and this file did not already have; blink, cheer, cheerS,
   ripple and neonpop were already here with identical definitions.

   The dressing: kroo / krooP are the kangaroo's two frames stepped across the
   sheet (224px desktop, 140 phone), laff / laffP the Pit Viper laughers (368 /
   230), per scale for the same reason as swim4 and roll3 above. keyflick and
   keyspill are the KOHD keylight's flicker and spill, steps(1) so they cut
   rather than fade. trakline is the tracking bar rolling down a VHS picture.

   The flight cutscene (cutscene3.js): cutin fades the overlay up, cuttrail
   draws the dashed route out of NYC, cutfly carries the plane along that same
   straight line in one segment, cutcap runs each caption in, holds, and lifts
   it out, and cutland pops TOUCHDOWN at 6.2s. The 6.4s flight and the three
   captions (2.1 + 2.1 + 2.6) share one clock; the stage's auto-end at 7600ms
   leaves TOUCHDOWN on screen for the last stretch. Positions in cutfly are
   percentages of the map box, not the viewport, and 82.72/34.56 and
   16.05/19.54 are NYC and Bend's real projected coordinates in the map's
   1000x500 space (see the markup comment in cutscene3.js). */
@keyframes kroo{from{background-position:0 0}to{background-position:-224px 0}}
@keyframes krooP{from{background-position:0 0}to{background-position:-140px 0}}
@keyframes laff{from{background-position:0 0}to{background-position:-368px 0}}
@keyframes laffP{from{background-position:0 0}to{background-position:-230px 0}}
@keyframes keyflick{0%,42%{opacity:.82;filter:none}44%{opacity:.5;transform:translate(2px,-1px)}46%,68%{opacity:.86}70%{opacity:.62;transform:translate(-2px,1px)}72%,100%{opacity:.82}}
@keyframes keyspill{0%,50%{opacity:.5}55%{opacity:.85}60%,100%{opacity:.5}}
@keyframes trakline{0%{top:8%}45%{top:82%}55%{top:20%}100%{top:8%}}
@keyframes cutin{from{opacity:0}to{opacity:1}}
/* the Sandi exchange in the Blockbuster: each line pops in on its own latch */
@keyframes sayout{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-6px)}}
@keyframes saypop{0%{opacity:0;transform:translateY(7px) scale(.9)}60%{opacity:1;transform:translateY(0) scale(1.05)}100%{opacity:1;transform:translateY(0) scale(1)}}
@keyframes cutfly{0%{left:82.72%;top:34.56%;transform:rotate(6.43deg);opacity:0}8%{opacity:1}100%{left:16.05%;top:19.54%;transform:rotate(6.43deg);opacity:1}}
@keyframes cuttrail{from{width:0}to{width:66.67%}}
/* The SECOND flight (cutscene4.js): Bend -> New York -> LAX -> Sydney, on the
   Pacific-centred map. Each leg is its OWN animation with position declared
   only at 0% and 100%, so every leg interpolates in a single segment — the
   cutfly lesson. A hitch BETWEEN legs is correct here: those are layovers.
   Opacity-only keyframes do not create position waypoints, so the 8%/86% fades
   cost nothing. Trail lengths are the real projected distances as a percentage
   of the 1000-unit map box. */
@keyframes cutfly4a{0%{left:55.93%;top:24%;opacity:0}8%{opacity:1}86%{opacity:1}100%{left:69.89%;top:25.98%;opacity:0}}
@keyframes cutfly4b{0%{left:69.89%;top:25.98%;opacity:0}8%{opacity:1}86%{opacity:1}100%{left:56.79%;top:29.98%;opacity:0}}
@keyframes cutfly4c{0%{left:56.79%;top:29.98%;opacity:0}6%{opacity:1}100%{left:30.11%;top:70%;opacity:1}}
@keyframes cuttrail4a{from{width:0}to{width:13.995%}}
@keyframes cuttrail4b{from{width:0}to{width:13.252%}}
@keyframes cuttrail4c{from{width:0}to{width:33.355%}}
@keyframes cutcap{0%{opacity:0;transform:translateY(6px)}12%{opacity:1;transform:translateY(0)}82%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(-6px)}}
@keyframes cutland{0%{opacity:0;transform:scale(.6)}60%{opacity:1;transform:scale(1.15)}100%{opacity:1;transform:scale(1)}}

/* Stage 3's engine-driven one-shots: the mailbox flag (flagup) and the finish
   tape halves (tapesnapL/R). Set on the refs by stage.js, not in the markup. */
@keyframes flagup{from{transform:rotate(90deg)}to{transform:rotate(0deg)}}
@keyframes tapesnapL{to{transform:rotate(-78deg)}}
@keyframes tapesnapR{to{transform:rotate(78deg)}}

/* =========================================================================
   SIMPLE VERSION (list.js).

   The one screen that is a document rather than a game, so it is the one
   screen styled by class instead of inline: reading sizes, real line lengths,
   selectable text, scrolling. Everything is prefixed sv- and nothing outside
   list.js uses these.

   Two compositions share one card template and differ only in the size
   tokens: .sv-fluid is desktop (and a portrait phone), .sv-stage is a
   landscape phone, authored inside the 640x360 stage. NOTHING here may use
   vw/vh: inside the scaled stage those resolve against the viewport and then
   get multiplied by the stage's own scale. The one media query is scoped to
   .sv-fluid, which is never inside the stage. Border-radius stays 0.
   ========================================================================= */
.sv {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: var(--ink-2);
  color: var(--text);
  -webkit-user-select: text;
  user-select: text;
  -webkit-touch-callout: default;
}
.sv-fluid {
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  --sv-h1: 18px;  --sv-h2: 11px;  --sv-sub: 9px;   --sv-title: 12px; --sv-client: 19px;
  --sv-body: 22px; --sv-link: 9px; --sv-thumb: 224px; --sv-gap: 22px; --sv-pad: 18px;
  --sv-line: 3px;  --sv-target: 44px; --sv-section: 44px;
}
.sv-stage {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 640px;
  height: 360px;
  transform-origin: 50% 50%;
  overflow: hidden;
  --sv-h1: 10px;  --sv-h2: 8px;   --sv-sub: 6px;   --sv-title: 8px;  --sv-client: 13px;
  --sv-body: 15px; --sv-link: 7px; --sv-thumb: 132px; --sv-gap: 10px; --sv-pad: 10px;
  --sv-line: 2px;  --sv-target: 40px; --sv-section: 20px;
}

.sv-col { max-width: 880px; margin: 0 auto; padding: 44px 24px 72px; }
.sv-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: var(--sv-section); }
.sv-h1 { font-family: var(--font-ui); font-size: var(--sv-h1); line-height: 1.6; color: var(--accent-gold); margin: 0 0 4px; }
.sv-sub { font-family: var(--font-ui); font-size: var(--sv-sub); line-height: 2; letter-spacing: 2px; color: var(--text-dim); }
.sv-back { display: inline-flex; align-items: center; min-height: var(--sv-target); font-family: var(--font-ui); font-size: var(--sv-link); line-height: 1.6; color: var(--text-dim); white-space: nowrap; touch-action: manipulation; }
.sv-h2 { font-family: var(--font-ui); font-size: var(--sv-h2); line-height: 2; letter-spacing: 2px; color: var(--accent-cyan); margin: 0 0 8px; }
.sv-section { margin-bottom: var(--sv-section); }
.sv-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sv-gap); }

/* A card: picture on the left, text on the right. minmax(0,1fr) so a long
   unbroken title can never push the column wider than the card. */
.sv-card { display: grid; grid-template-columns: var(--sv-thumb) minmax(0, 1fr); gap: var(--sv-gap); align-items: start; padding: var(--sv-pad); background: var(--panel); border: var(--sv-line) solid var(--panel-2); }
.sv-thumb { display: block; aspect-ratio: 16 / 9; overflow: hidden; background: #000; border: var(--sv-line) solid var(--border-lo); }
.sv-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.sv-thumb-ph { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; text-align: center; padding: 8px; font-family: var(--font-ui); font-size: var(--sv-sub); line-height: 1.9; color: var(--text-dim); background: repeating-linear-gradient(45deg, var(--panel) 0 6px, var(--ink-2) 6px 12px); }
.sv-title { font-family: var(--font-ui); font-size: var(--sv-title); line-height: 1.7; margin: 0; }
.sv-title a { color: var(--text); }
.sv-title a:hover { color: var(--accent-gold); }
.sv-client { font-family: var(--font-body); font-size: var(--sv-client); line-height: 1.2; letter-spacing: 1px; color: var(--text-dim); margin-top: 2px; }
.sv-blurb { font-family: var(--font-body); font-size: var(--sv-body); line-height: 1.3; color: #c9c9dc; max-width: 62ch; margin: calc(var(--sv-gap) / 2) 0 0; }
.sv-more { display: inline-flex; align-items: center; min-height: var(--sv-target); margin-top: 2px; font-family: var(--font-ui); font-size: var(--sv-link); line-height: 1.6; touch-action: manipulation; }
/* SERVICES. A <dl>: term, note, list. The note and the items are body type
   because they are read, the term is UI type because it is a label. */
.sv-note { font-family: var(--font-body); font-size: var(--sv-body); line-height: 1.35; color: #c9c9dc; max-width: 62ch; margin: 0 0 calc(var(--sv-gap) / 2); }
.sv-svc { margin: 0; padding: 0; display: grid; gap: var(--sv-gap); grid-template-columns: minmax(0, 1fr); }
.sv-svc dt { font-family: var(--font-ui); font-size: var(--sv-h2); line-height: 2; letter-spacing: 2px; color: var(--accent-gold); border-top: var(--sv-line) solid var(--panel-2); padding-top: calc(var(--sv-gap) / 2); }
.sv-svc dd { margin: 0 0 calc(var(--sv-gap) / 2); }
.sv-svc-note { font-family: var(--font-body); font-size: var(--sv-body); line-height: 1.3; color: var(--text-dim); max-width: 62ch; }
.sv-svc ul { list-style: none; margin: 6px 0 0; padding: 0; column-gap: var(--sv-gap); font-family: var(--font-body); font-size: var(--sv-body); line-height: 1.45; color: var(--text); }
/* Two columns only where there is room for them: the longest item is ~48
   characters, so below roughly 60ch a second column makes every line wrap. */
@media (min-width: 720px) { .sv-svc ul { columns: 2; } }

.sv-links { display: flex; flex-wrap: wrap; gap: 6px 22px; font-family: var(--font-ui); font-size: var(--sv-link); border-top: var(--sv-line) solid var(--panel-2); padding-top: calc(var(--sv-gap) / 2); margin-bottom: var(--sv-gap); }
.sv-links a,
.sv-links .hot { display: inline-flex; align-items: center; min-height: var(--sv-target); touch-action: manipulation; }
.sv-links .hot { color: var(--accent-cyan); }
.sv-links .hot:focus-visible { color: var(--accent-gold); }
/* An in-app action set inside running text reads as the same link colour as
   the real <a> elements around it. */
.sv-inline { color: var(--accent-cyan); }

/* Stage composition: a fixed header bar with the scroll region under it. */
.sv-stage .sv-head { position: absolute; left: 0; right: 0; top: 0; height: 50px; margin: 0; padding: 0 16px; align-items: center; border-bottom: 2px solid var(--panel-2); background: var(--ink-2); }
.sv-stage .sv-h1 { margin: 0; line-height: 1.2; }
.sv-stage .sv-sub { line-height: 1.6; letter-spacing: 1px; }
.sv-scroll { position: absolute; left: 0; right: 0; top: 50px; bottom: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 12px 16px 28px; }

/* Narrow fluid column (a portrait phone, a small window): the picture moves
   above the text and the header stacks. Scoped to .sv-fluid so it can never
   reach the stage. */
@media (max-width: 640px) {
  .sv-fluid { --sv-h1: 14px; --sv-title: 11px; --sv-body: 21px; --sv-gap: 16px; --sv-pad: 14px; --sv-section: 36px; }
  .sv-fluid .sv-col { padding: 28px 16px 56px; }
  .sv-fluid .sv-card { grid-template-columns: minmax(0, 1fr); }
  .sv-fluid .sv-head { flex-direction: column; }
}

/* ABOUT ME is a button in a row of links, so it is styled to match them rather
   than to look like a button. */
.sv-navbtn { appearance: none; background: none; border: 0; padding: 0; font: inherit; color: var(--accent-cyan); cursor: pointer; display: inline-flex; align-items: center; min-height: var(--sv-target); touch-action: manipulation; }
.sv-navbtn:hover { color: var(--accent-gold); }

/* The popup. FIXED TO THE VIEWPORT, not to the list: on a landscape phone the
   list is a 640x360 stage that gets CSS-scaled, and a dialog inside it would be
   scaled with it and capped at 360px tall. The card inside is the game's own,
   so it brings its own sizing; this only positions it and dims what is behind.
   The card's max-width is already 92vw, and the height cap here lets a long bio
   scroll rather than run off the screen. */
/* THE [hidden] RULE MUST COME WITH THE display RULE. `display: flex` here is an
   author rule on a class, so it beats the browser's built-in [hidden]{display:none}
   and the dialog stayed on screen from the moment the list loaded — covering it,
   with a blank portrait, because the sprite is only painted when it is opened.
   Any element that carries `hidden` AND a display rule needs this pair. */
.sv-modal { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 16px; }
.sv-modal[hidden] { display: none; }
.sv-modal-back { position: absolute; inset: 0; background: rgba(8, 8, 14, .82); }
.sv-modal-card { position: relative; max-height: calc(100vh - 32px); overflow-y: auto; -webkit-overflow-scrolling: touch; }
.sv-modal [data-close-about].hot { cursor: pointer; }
.sv-modal [data-close-about].hot:focus-visible { outline: 2px solid var(--accent-gold); outline-offset: 3px; }
