/* The runner sprite + scene (folded in from the Runner Character Lab).
 *
 * This travels with public/js/runner/*: the renderer's DOM output depends on
 * .sprite-grid / .glyph-cell / .glyph-layer and the --cols/--rows/--glyph-size
 * variables. Colours lean on the Console palette declared in layouts/world.blade.php.
 *
 * DEPTH ORDER is deliberate and was settled by overlap testing. Only falling
 * water crosses in front of the runner:
 *   1 sky (clouds, birds, sun, moon, stars, headlamp glow, wind)
 *   2 far scenery + background trees      3 terrain
 *   4 near scenery                        5 runner
 *   6 rain / storm                        9 telemetry      10 reveal cursor
 */

/* ── the sprite grid ───────────────────────────────────────────────────── */
/* Fixed monospace metrics. Quadrant glyph sizing differs across fonts, so the
 * stack is pinned here rather than inherited, ligatures stay off, and letter
 * spacing stays at zero. Changing any of the three breaks the artwork. */

.sprite-grid {
    --glyph-size: 22px;
    display: grid;
    grid-template-columns: repeat(var(--cols), .6em);
    grid-template-rows: repeat(var(--rows), .96em);
    width: fit-content;
    font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
    font-size: var(--glyph-size);
    font-variant-ligatures: none;
    line-height: .96;
    letter-spacing: 0;
    white-space: pre;
    text-shadow: none;   /* the page glow would blur the quadrants */
}

/* The scene sprite has to leave room for sky, terrain and telemetry around it —
 * roughly two thirds of the box height, as in the lab. */
.sprite-grid.small { --glyph-size: 15px; }
.sprite-grid.medium { --glyph-size: clamp(18px, 5.5vw, 23px); }
.sprite-grid.large { --glyph-size: clamp(15px, 4.4vw, 22px); }

.glyph-cell {
    position: relative;
    width: .6em;
    height: .96em;
}

/* One layer per region in the cell — this is what lets a single terminal cell
 * carry several kit colours at once. */
.glyph-layer {
    position: absolute;
    inset: 0;
    width: .6em;
    height: .96em;
    overflow: visible;
    line-height: .96;
}

/* A bare sprite (no scene chrome): runner sheet, creation preview.
 * The min-height reserves exactly what a medium sprite will occupy (10 rows at
 * .96em) so the page doesn't jump when the renderer mounts. */
.runner-sprite {
    display: flex;
    justify-content: center;
    min-height: calc(9.6 * clamp(18px, 5.5vw, 23px));
    filter: drop-shadow(0 0 8px rgba(155, 229, 100, .12));
}

/* ── the dresser ───────────────────────────────────────────────────────── */
/* Extends the ◀/▶ rows already in layouts/world.blade.php. */

/* Creation preview: both sides of the life, side by side — the still portrait
 * and the run cycle, re-drawn together on every dial. */
.dresser-preview {
    display: flex;
    justify-content: center;
    align-items: flex-end;
    gap: 1.4rem;
    flex-wrap: wrap;
}

.dresser-preview figure { margin: 0; flex: 1 1 10rem; min-width: 0; }

.dresser-preview figcaption {
    text-align: center;
    color: var(--muted);
    font-size: .7rem;
    letter-spacing: .14em;
    text-transform: uppercase;
    margin-top: .3rem;
}

.dresser-group {
    margin: 1.1rem 0 .4rem;
    padding-bottom: .3rem;
    border-bottom: 1px solid var(--rule);
    color: var(--accent);
    font-size: .7rem;
    letter-spacing: .14em;
    text-transform: uppercase;
}

.dresser-value { color: var(--ink); }

/* The colour dial sits under the shape it belongs to. */
.dresser-row.sub {
    margin-top: -.1rem;
    margin-left: 1.1rem;
    opacity: .82;
}

.dresser-row.sub .dresser-label { font-size: .78rem; }

/* ── the scene ─────────────────────────────────────────────────────────── */

.scene {
    position: relative;
    display: grid;
    min-height: 20rem;
    overflow: hidden;
    place-items: center;
    border: 1px solid var(--rule);
    background:
        linear-gradient(180deg, transparent 74%, rgba(155, 229, 100, .035) 74%),
        repeating-linear-gradient(90deg, transparent 0 47px, rgba(155, 229, 100, .022) 48px),
        #080c08;
    isolation: isolate;
    margin: 1rem 0;
}

.scene-sky {
    position: absolute;
    z-index: 9;
    top: .9rem;
    left: 1rem;
    color: #33482f;
    font-size: .62rem;
    line-height: 1.7;
    white-space: pre;
    text-shadow: none;
}

.scene-meta {
    position: absolute;
    z-index: 9;
    top: .9rem;
    right: 1rem;
    color: var(--muted);
    font-size: .62rem;
    text-align: right;
    text-transform: uppercase;
    line-height: 1.7;
    white-space: pre;
    text-shadow: none;
}

/* Nudged down so the feet land near the terrain line rather than floating in
 * the middle of the box. Each scene tunes it further below. */
.sprite-stage {
    position: relative;
    z-index: 5;
    transform: translateY(34px);
    filter: drop-shadow(0 0 8px rgba(155, 229, 100, .1));
}

/* The sprite itself. In flow, so it gives the stage its size — which is what
 * lets the torch beam be positioned as a percentage of the runner. */
.sprite-box {
    position: relative;
    z-index: 2;
}

.terrain {
    position: absolute;
    z-index: 3;
    right: -5%;
    bottom: 10px;
    left: -5%;
    height: 3px;
    background: var(--muted);
    box-shadow: 0 5px 0 #172317, 0 9px 0 #101810;
}

.terrain::before {
    position: absolute;
    top: -5px;
    left: 0;
    width: 200%;
    color: #4b6644;
    content: "·  ˙  ·    ˙ ·   ·  ˙   ·    ˙  ·   ·  ˙  ·    ˙ ·   ·  ˙   ·";
    font-size: 12px;
    white-space: nowrap;
    animation: ground-scroll 2.4s linear infinite;
    animation-play-state: var(--motion-state, running);
}

.tree {
    position: absolute;
    z-index: 2;
    bottom: 14px;
    left: 110%;
    color: #294329;
    font-size: 18px;
    line-height: .8;
    white-space: pre;
    animation: tree-pass 6s linear infinite;
    animation-play-state: var(--motion-state, running);
}

.tree.two { animation-delay: -3.5s; font-size: 13px; opacity: .68; }

/* Road: a sealed surface and no bush. */
.scene.running .tree { display: none; }
.scene.running .terrain { height: 5px; background: #73816f; box-shadow: 0 7px 0 #172317, 0 12px 0 #101810; }
.scene.running .terrain::before {
    top: 8px;
    color: #53614f;
    content: "────      ────      ────      ────      ────      ────      ────";
}

.atmosphere {
    position: absolute;
    inset: 0;
    overflow: hidden;
    pointer-events: none;
}

.atmosphere::before,
.atmosphere::after,
.weather-field { z-index: 1; }

.weather-field {
    position: absolute;
    inset: 0;
    color: rgba(155, 229, 100, .24);
    font-size: 13px;
    line-height: 2.2;
}

/* Rain is the ONLY thing allowed in front of the runner. */
.scene.weather-rain .weather-field { z-index: 6; }

.scene.weather-rain .weather-field::before {
    position: absolute;
    inset: -18% -8%;
    content: "";
    background: repeating-linear-gradient(104deg, transparent 0 20px, rgba(155, 229, 100, .24) 21px 22px, transparent 23px 42px);
    animation: rain-fall .7s steps(4, end) infinite;
    animation-play-state: var(--motion-state, running);
}

.scene.weather-rain .weather-field::after {
    position: absolute;
    inset: -14% -5%;
    content: "";
    background: repeating-linear-gradient(104deg, transparent 0 37px, rgba(155, 229, 100, .16) 38px 39px, transparent 40px 67px);
    animation: rain-fall .82s steps(4, end) infinite;
    animation-play-state: var(--motion-state, running);
}

/* Wind reads as environmental motion, not one big gust symbol. */
.scene.weather-wind .weather-field::before {
    position: absolute;
    inset: 0;
    content: "";
    background:
        repeating-linear-gradient(0deg, transparent 0 47px, rgba(155, 229, 100, .08) 48px 49px, transparent 50px 89px),
        repeating-linear-gradient(90deg, transparent 0 68px, rgba(155, 229, 100, .22) 69px 91px, transparent 92px 153px);
    mask: repeating-linear-gradient(0deg, transparent 0 34px, #000 35px 37px, transparent 38px 78px);
    animation: wind-field 3.8s linear infinite;
    animation-play-state: var(--motion-state, running);
}

/* A storm is not a windier wind or a wetter rain — it is a MetService warning
 * (gusts past 110 km/h, or heavy rain), and an ordinary foul Wellington day is
 * already the wind bucket. So it is drawn as both at once: rain driven at a
 * hard slant and running roughly twice the speed of the rain rules, over the
 * wind crosshatch at roughly twice its speed, the whole field gusting.
 *
 * The z-index exemption above is extended rather than reopened: the water is
 * literally between you and the runner, which is the reason rain has always
 * come forward, and horizontal rain is more so. The crosshatch rides the same
 * layer because it is the other half of the same pseudo-element pair — kept
 * fainter than the wind rule's so it reads as driving spray rather than a mesh
 * laid over the sprite. Nothing else moves depth. */
.scene.weather-storm .weather-field {
    z-index: 6;
    /* Both layers gust together on a slower cycle than either of them runs at.
     * Steady heavy rain is the rain bucket; the thing you actually stand in
     * during a warning is the surge. */
    animation: storm-squall 2.7s ease-in-out infinite;
    animation-play-state: var(--motion-state, running);
}

/* Driven rain: two spacings on one layer so it doesn't read as a single ruled
 * pattern, angled ~40° off the rain rules' 104deg so the streaks lie down. */
.scene.weather-storm .weather-field::before {
    position: absolute;
    inset: -22% -14%;
    content: "";
    background:
        repeating-linear-gradient(142deg, transparent 0 15px, rgba(155, 229, 100, .3) 16px 18px, transparent 19px 34px),
        repeating-linear-gradient(138deg, transparent 0 41px, rgba(155, 229, 100, .18) 42px 43px, transparent 44px 73px);
    animation: storm-drive .34s steps(5, end) infinite;
    animation-play-state: var(--motion-state, running);
}

/* The wind crosshatch, same geometry as the wind rule (so the 153px loop still
 * meets itself) at nearly twice the pace and a little more contrast. */
.scene.weather-storm .weather-field::after {
    position: absolute;
    inset: 0;
    content: "";
    background:
        repeating-linear-gradient(0deg, transparent 0 47px, rgba(155, 229, 100, .1) 48px 49px, transparent 50px 89px),
        repeating-linear-gradient(90deg, transparent 0 68px, rgba(155, 229, 100, .28) 69px 91px, transparent 92px 153px);
    mask: repeating-linear-gradient(0deg, transparent 0 34px, #000 35px 37px, transparent 38px 78px);
    animation: wind-field 1.9s linear infinite;
    animation-play-state: var(--motion-state, running);
}

/* ── light ─────────────────────────────────────────────────────────────── */

.scene.time-day {
    background:
        linear-gradient(180deg, rgba(155, 229, 100, .065), transparent 62%),
        repeating-linear-gradient(90deg, transparent 0 47px, rgba(155, 229, 100, .022) 48px),
        #080d08;
}

.scene.time-day .atmosphere::before {
    position: absolute;
    top: 60px;
    left: 100%;
    color: rgba(207, 230, 189, .32);
    content: "⌁  ⌁                 ˄  ˄";
    font-size: 13px;
    white-space: pre;
    animation: sky-drift 18s linear infinite;
    animation-play-state: var(--motion-state, running);
}

.scene.time-day .atmosphere::after {
    position: absolute;
    top: 52px;
    right: 14%;
    color: rgba(229, 167, 60, .62);
    content: "☼";
    font-size: 28px;
}

.scene.time-sunrise {
    background:
        linear-gradient(180deg, rgba(229, 167, 60, .13), transparent 58%),
        repeating-linear-gradient(90deg, transparent 0 47px, rgba(229, 167, 60, .03) 48px),
        #0b0d08;
}

.scene.time-sunrise .atmosphere::after {
    position: absolute;
    top: 54px;
    right: 17%;
    color: rgba(229, 167, 60, .55);
    content: "\2600";
    font-size: 26px;
}

.scene.time-night {
    background:
        radial-gradient(circle at 72% 18%, rgba(85, 215, 210, .08), transparent 18%),
        repeating-linear-gradient(90deg, transparent 0 47px, rgba(85, 215, 210, .018) 48px),
        #040705;
}

.scene.time-night .atmosphere::before,
.scene.time-headlamp .atmosphere::before {
    position: absolute;
    top: 46px;
    right: 10%;
    left: 14%;
    color: rgba(207, 230, 189, .26);
    content: "·       +          ·     ·        ✦       ·          ·\A     ·       ·     +         ·      ·       +\A  +        ·          ·       ✧        ·       ·";
    font-size: 12px;
    line-height: 3.4;
    white-space: pre;
    animation: star-twinkle 1.8s steps(2, end) infinite;
    animation-play-state: var(--motion-state, running);
}

.scene.time-night .atmosphere::after {
    position: absolute;
    top: 52px;
    right: 13%;
    color: rgba(207, 230, 189, .6);
    content: "◐";
    font-size: 30px;
}

.scene.time-headlamp { background: #030604; }

.scene.time-headlamp .atmosphere::after {
    position: absolute;
    inset: 0;
    content: "";
    background: radial-gradient(ellipse at 57% 58%, rgba(255, 246, 184, .2), rgba(255, 246, 184, .035) 24%, transparent 43%);
}

/* ── the head torch (decision #46) ─────────────────────────────────────── */
/* Gear you own, shown on the scene: a cone thrown forward from the runner's
 * head, behind them in the depth order with the rest of the ambience. Hidden by
 * default and only lit in the dark, outdoors, with a torch that has battery in
 * it — so a dead one visibly goes out. */

/* Out of the lamp, not out of the waist. Positioned against .sprite-stage (the
 * sprite's own box), so these percentages are percentages of the runner: the
 * head torch is drawn at pixel (17, 4) of the 24×20 canvas, which is ~72%
 * across and 20% down. The clip-path's left edge is centred on that point, and
 * the beam fans out and gently down ahead of them. The sway pivots at the lamp
 * for the same reason — a beam that swings about its middle isn't on a head. */
.torch-beam {
    position: absolute;
    z-index: 1;
    display: none;
    top: 0;
    left: 70%;
    width: 150%;
    height: 50%;
    background: linear-gradient(100deg, rgba(255, 246, 184, .3), rgba(255, 246, 184, .05) 46%, transparent 72%);
    /* Left edge centred on the lamp (40% of a box half the sprite tall = 20%),
     * far edge dropped to the ground ahead rather than fanning into the sky. */
    clip-path: polygon(0 34%, 100% 20%, 100% 100%, 0 46%);
    filter: blur(1px);
    transform-origin: 0 50%;
    animation: torch-sway 2.6s ease-in-out infinite;
    animation-play-state: var(--motion-state, running);
}

.scene.has-torch.time-night .torch-beam,
.scene.has-torch.time-headlamp .torch-beam { display: block; }

/* Out in the dark with nothing on your head: the generic ambient glow drops
 * back so the difference between owning a torch and not is visible, not just
 * chargeable. */
.scene.time-headlamp:not(.has-torch) .atmosphere::after { opacity: .32; }

@keyframes torch-sway {
    0%, 100% { transform: rotate(-1.4deg) translateY(0); }
    50% { transform: rotate(1.4deg) translateY(-3px); }
}

/* ── parallax route scenery ────────────────────────────────────────────── */
/* Far artwork is smaller, higher, dimmer and slower; near is larger, lower,
 * brighter and faster. Parallax is more than speed. */

.route-prop {
    position: absolute;
    z-index: 2;
    display: none;
    bottom: 14px;
    left: 110%;
    width: max-content;
    color: #294329;
    line-height: .9;
    white-space: pre;
    animation: tree-pass 6.5s linear infinite;
    animation-play-state: var(--motion-state, running);
}

.route-prop.near-prop { z-index: 4; }

.scene.running .route-prop, .scene.hills .route-prop { display: block; }
.scene.running .far-prop { bottom: 70px; font-size: 15px; opacity: .5; animation-duration: 11s; }
.scene.running .near-prop { bottom: 14px; font-size: 21px; animation-duration: 5.2s; }
.scene.hills .far-prop { bottom: 118px; font-size: 14px; opacity: .48; animation-duration: 11.5s; }
.scene.hills .near-prop { bottom: 58px; font-size: 19px; animation-duration: 5.4s; }

.scene.hills .terrain {
    bottom: 34px;
    height: 74px;
    background: #111c11;
    clip-path: polygon(0 76%, 14% 34%, 26% 61%, 40% 18%, 55% 54%, 70% 7%, 84% 43%, 100% 16%, 100% 100%, 0 100%);
    box-shadow: none;
}

.scene.hills .sprite-stage { transform: translateY(0); }
.scene.hills .tree { bottom: 54px; font-size: 23px; animation-duration: 5s; }
.scene.hills .tree.two { bottom: 112px; font-size: 15px; opacity: .46; animation-duration: 10.5s; }

/* ── going down, and stopping for a cup (dig #443) ─────────────────────── */

/* The descent is the hills read the other way: the same ridgeline, mirrored so
   it falls away in front of the runner instead of rising into them, and the
   props run faster because you are. The pose leans the other way too
   (drawDescentSide). */
.scene.descent .route-prop, .scene.aid .route-prop { display: block; }
.scene.descent .far-prop { bottom: 118px; font-size: 14px; opacity: .48; animation-duration: 9.5s; }
.scene.descent .near-prop { bottom: 58px; font-size: 19px; animation-duration: 4.1s; }

.scene.descent .terrain {
    bottom: 34px;
    height: 74px;
    background: #111c11;
    clip-path: polygon(0 16%, 16% 43%, 30% 7%, 45% 54%, 60% 18%, 74% 61%, 86% 34%, 100% 76%, 100% 100%, 0 100%);
    box-shadow: none;
}

.scene.descent .sprite-stage { transform: translateY(0); }
.scene.descent .tree { bottom: 54px; font-size: 23px; animation-duration: 4.2s; }
.scene.descent .tree.two { bottom: 112px; font-size: 15px; opacity: .46; animation-duration: 9s; }

/* An aid station is flat ground with a trestle table on it — the road scene
   plus one prop, on the same footing as the work desk clock. */
.scene.aid .tree { display: none; }
.scene.aid .terrain { height: 5px; background: #73816f; box-shadow: 0 7px 0 #172317, 0 12px 0 #101810; }
.scene.aid .far-prop { bottom: 70px; font-size: 15px; opacity: .5; animation-duration: 13s; }
.scene.aid .near-prop { bottom: 14px; font-size: 21px; animation-duration: 6.4s; }

/* ── indoor / still scenes ─────────────────────────────────────────────── */

.scene.idle .tree { display: none; }
.scene.idle .terrain::before { animation-play-state: paused; }
.scene.recovery .terrain, .scene.work .terrain,
.scene.recovery .tree, .scene.work .tree { display: none; }
.scene.recovery .sprite-stage { transform: translateY(30px); }
.scene.work .sprite-stage { transform: translate(-18px, 22px); }

/* A shift that happens outside gets its ground back (dig #1162, Tom's call).
   The weather is scene.js's half of the same answer — a ranger stands in
   today's real Wellington rain, and an office does not. */
.scene.work.outdoors .terrain, .scene.work.outdoors .tree { display: block; }
.scene.work.outdoors .terrain::before { animation-play-state: paused; }

/* The two standing poses have their feet on the floor rather than a chair, so
   they sit lower and further left than the seated one — the desk pose's own
   offset is unchanged from #1153. */
.scene.work.pose-counter .sprite-stage,
.scene.work.pose-tool .sprite-stage { transform: translate(-26px, 26px); }
.scene.work.pose-rounds .sprite-stage { transform: translate(-14px, 26px); }

/* Somewhere warm (dig #2047). Tom, on the bookable holiday: "could even change
   the animation to him sunning at a beach."

   ⚠️ The parallax stands STILL here and that is not laziness: `.route-prop` is
   animated in from `left: 110%`, so merely pausing it would park both props off
   the right-hand edge. A runner on a towel is not going past anything, so they
   are placed rather than passed, and the sand's own drift is paused the way
   `.scene.idle` pauses it. The LIGHT is untouched, so a beach after dark is a
   beach after dark — the weather is the only thing this scene overrules
   (scene.js's ELSEWHERE), because they went somewhere warm and not somewhere
   with a roof. */
.scene.holiday .tree { display: none; }
.scene.holiday .route-prop {
    display: block;
    right: 0;
    left: 0;
    width: auto;
    text-align: center;
    animation: none;
}
/* The sea is a HORIZON, well above the runner, and the water's edge is a line
   just off the sand. Both at the heights the road and hill scenes put their
   own far/near at, moved apart so neither lands on the towel. */
.scene.holiday .far-prop { bottom: 196px; color: #3f6f77; font-size: 14px; opacity: .7; }
/* The waterline laps in BEHIND the towel, so the near prop drops out of its
   usual in-front layer: on every other scene the near parallax is something
   you are running past, and here it is the sea at your back. */
.scene.holiday .near-prop { bottom: 22px; z-index: 1; color: #4c6b74; font-size: 16px; opacity: .85; }
.scene.holiday .terrain {
    height: 6px;
    background: #9d8a5c;
    box-shadow: 0 8px 0 #6f6140, 0 14px 0 #4b422c;
}
.scene.holiday .terrain::before {
    top: 8px;
    color: #b9a473;
    content: "·   ˙   ·     ˙  ·   ·    ˙  ·   ·  ˙    ·   ˙  ·    ·   ˙";
    animation-play-state: paused;
}
.scene.holiday .sprite-stage { transform: translateY(42px); }

.context-prop {
    position: absolute;
    z-index: 4;
    display: none;
    color: var(--muted);
    font-size: 14px;
    line-height: 1;
    white-space: pre;
}

/* The desk is a percentage of the RUNNER, not of the stage (dig #1153) — same
 * technique and same reason as the torch beam above. The hands are a fixed
 * pixel of a 24×20 canvas (the near wrist lands at 20, 12), so the surface line
 * is placed off that: the block's last row is the desk, which is why it is
 * anchored by its bottom, and it starts left of the wrists so it passes UNDER
 * them rather than beginning where they end.
 *
 * Since dig #1162 there are four bodies and thirteen props, so this is the
 * shared half — display, and the anchoring rule — and each pose says below
 * where ITS hands are. Percentages throughout, for #1153's reason: the hands
 * are a fixed pixel of the sprite and the sprite scales with the viewport. */
.scene.work .context-prop.work-prop {
    display: block;
    left: 58%;
    bottom: 31%;
    z-index: 1;
}

/* Standing at a surface: the hands are lower and further out than the seated
   pose's, and the prop stands IN FRONT of the runner rather than under their
   forearms — a counter is a thing you are on the other side of. */
.scene.work.pose-counter .context-prop.work-prop { left: 64%; bottom: 30%; z-index: 6; }
/* On the tools, the bench is beside them and behind the swing. */
.scene.work.pose-tool .context-prop.work-prop { left: 72%; bottom: 12%; z-index: 1; }
/* Out on the rounds the prop is scenery they are walking past, so it sits on
   the ground line and stays behind them. */
.scene.work.pose-rounds .context-prop.work-prop { left: 70%; bottom: 4%; z-index: 1; }
/* The one prop that stands on the floor rather than on the surface the pose is
   working at — a whiteboard on a stand is the wall of the room, not a thing on
   the counter. Every other prop shares its pose's anchor. */
.scene.work.prop-whiteboard .context-prop.work-prop { left: 70%; bottom: 0; z-index: 1; }
.scene.aid .context-prop.aid-prop { display: block; right: 18px; bottom: 22px; }

/* ── race day: the panel comes with you (dig #443) ─────────────────────── */

/*
 * Tom, first race: "running animation screen should be fixed so it comes with
 * you as the race progresses". `.scene` is a 20rem stage, so on a race — where
 * the transcript grows past a screen within one beat — it scrolled away and
 * was never seen again. Sticky and compact, scoped to `.race-scene` so /game,
 * /game/ground and the runner sheet keep the full-size stage.
 *
 * The STICKY element has to be the outer host, not `.scene`: scene.js rebuilds
 * `.scene` on every mount and it carries `overflow: hidden`. `top` clears the
 * layout's own sticky `.top` bar, and typewriter.js reads --tw-top-inset so
 * its auto-scroll doesn't park the cursor behind all this.
 */
/* `top` is the header's REAL height, measured by public/js/sticky.js (dig
   #495). The rem values here are only the pre-JS fallback: the header is
   ~76px on a phone, taller on a narrow desktop where the nav wraps to two
   rows, and taller again while decision #69's outing strip is showing — so a
   fixed offset parked the panel under the header and clipped the line of text
   across its top. Tom: "when scroll down the runner scrolls up a little too
   much." */
.race-scene {
    position: sticky;
    top: var(--top-h, 4.2rem);
    z-index: 15;
    margin: .6rem 0 1rem;
}

/* What the sticky chrome costs the top of the viewport. typewriter.js reads
   this so its auto-scroll never parks the cursor behind the panel; sticky.js
   overwrites it with the measured figure, and pages without a race scene
   leave it unset, where 0 is the right answer. */
:root:has(.race-scene) { --tw-top-inset: 190px; }

.race-scene .scene { min-height: 9.5rem; margin: 0; }
.race-scene .sprite-stage { transform: translateY(14px) scale(.8); }
.race-scene .scene.hills .sprite-stage,
.race-scene .scene.descent .sprite-stage { transform: translateY(4px) scale(.8); }
.race-scene .scene-sky, .race-scene .scene-meta { font-size: .55rem; top: .5rem; }

/* The telemetry is drawn OVER the picture, and the compact panel is a third of
   the height with the same sprite in the middle of it — so a long location
   ("+ 1.6 KM · THE ZIGZAG, TE AHUMAIRANGI") ran straight through the runner's
   head at phone width (dig #495). Clipped rather than wrapped: these are
   fixed-position corner readouts, and the full line is in the transcript
   header a few pixels below either way. */
.race-scene .scene-sky { max-width: 56%; overflow: hidden; text-overflow: ellipsis; }
.race-scene .scene-meta { max-width: 34%; overflow: hidden; text-overflow: ellipsis; }

@media (max-width: 34rem) {
    .race-scene { top: var(--top-h, 3.6rem); }
    :root:has(.race-scene) { --tw-top-inset: 160px; }
    .race-scene .scene-sky { max-width: 50%; }
    .race-scene .scene { min-height: 8rem; }
    .race-scene .sprite-stage { transform: translateY(10px) scale(.7); }
    .race-scene .scene.hills .sprite-stage,
    .race-scene .scene.descent .sprite-stage { transform: translateY(2px) scale(.7); }
}

/*
 * Tapping the runner to get back to them (dig #494). Tom: "I should be able to
 * return to it easily from any screen — perhaps from the running animation".
 * The header strip is the answer for *any* screen; this is the answer on the
 * one screen where you sit and watch them, and it wants no new markup inside
 * the node the renderer owns — the overlay is a sibling, so `wire:ignore` and
 * scene.js are both untouched.
 *
 * Only ever rendered when there is something to go back to, so a full-bleed
 * hit area costs nothing the rest of the time.
 */
.scene-holder { position: relative; }

.scene-link {
    position: absolute;
    inset: 0;
    z-index: 12;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    padding-bottom: .35rem;
    text-decoration: none;
    color: var(--amber);
}

.scene-link > span {
    background: rgba(14, 15, 12, .72);
    border: 1px solid currentColor;
    padding: .2rem .55rem;
    font-size: .72rem;
    letter-spacing: .08em;
    text-transform: uppercase;
}

.scene-link > span::before { content: "▸ "; }
.scene-link:hover > span { background: rgba(14, 15, 12, .92); }
.scene-link.waiting { color: var(--alert); }

/* ── terminal reveal (scene transitions only, never per frame) ─────────── */

.scene.revealing .scene-sky,
.scene.revealing .scene-meta,
.scene.revealing .terrain,
.scene.revealing .context-prop,
.scene.revealing .atmosphere,
.scene.revealing .route-prop,
.scene.revealing .tree {
    opacity: 0;
    animation: terminal-element-in 1ms steps(1, end) forwards;
    animation-delay: var(--reveal-delay, 80ms);
}

.scene.revealing .scene-meta { --reveal-delay: 220ms; }
.scene.revealing .tree { --reveal-delay: 360ms; }
.scene.revealing .tree.two { --reveal-delay: 470ms; }
.scene.revealing .terrain,
.scene.revealing .context-prop { --reveal-delay: 580ms; }
.scene.revealing .atmosphere { --reveal-delay: 300ms; }
.scene.revealing .route-prop { --reveal-delay: 470ms; }

.scene.revealing .glyph-cell {
    opacity: 0;
    animation: terminal-glyph-in 1ms steps(1, end) forwards;
    animation-delay: calc(620ms + var(--reveal-index) * 7ms);
}

.scene.revealing::before {
    position: absolute;
    z-index: 10;
    bottom: 14px;
    left: 14px;
    color: var(--accent);
    content: "> RENDERING SCENE_";
    font-size: 10px;
    animation: terminal-cursor 180ms steps(1, end) infinite;
}

@keyframes ground-scroll { to { transform: translateX(-50%); } }

/* Protected offscreen wrap: hidden at both endpoints so nothing flashes at the
 * far-left edge as it recycles. */
@keyframes tree-pass {
    0% { left: 100%; transform: translateX(1px); visibility: hidden; }
    .1% { visibility: visible; }
    99.9% { left: 0; transform: translateX(calc(-100% - 1px)); visibility: visible; }
    100% { left: 0; transform: translateX(calc(-100% - 1px)); visibility: hidden; }
}

@keyframes terminal-element-in { to { opacity: 1; } }
@keyframes terminal-glyph-in { to { opacity: 1; } }
@keyframes terminal-cursor { 50% { opacity: .25; } }
@keyframes rain-fall { from { transform: translate(0, -8%); } to { transform: translate(-18px, 18%); } }
/* rain-fall's own ratio is too upright to reuse here — driven rain has to
 * travel further sideways than it falls, which is the whole read. */
@keyframes storm-drive { from { transform: translate(0, -6%); } to { transform: translate(-58px, 11%); } }
@keyframes storm-squall { 0%, 100% { opacity: .84; } 42% { opacity: 1; } }
@keyframes wind-field { from { background-position: 0 0, 0 0; } to { background-position: 0 0, -153px 0; } }
@keyframes sky-drift { from { transform: translateX(0); } to { transform: translateX(calc(-100vw - 100%)); } }
@keyframes star-twinkle { 0%, 100% { opacity: .38; } 50% { opacity: .82; } }

/* One motion policy. The JS clock stops the sprite; --motion-state stops the
 * scenery, weather and sky together so nothing is left drifting on its own. */
@media (prefers-reduced-motion: reduce) {
    .terrain::before, .tree, .route-prop,
    .atmosphere::before,
    /* The two pseudo selectors were already weather-agnostic, so storm's layers
     * came for free; the element itself only started animating with the storm
     * squall, so it had to be named. */
    .weather-field, .weather-field::before, .weather-field::after { animation-play-state: paused; }

    .scene.revealing .scene-sky, .scene.revealing .scene-meta,
    .scene.revealing .terrain, .scene.revealing .context-prop,
    .scene.revealing .atmosphere, .scene.revealing .route-prop, .scene.revealing .tree,
    .scene.revealing .glyph-cell { opacity: 1; animation: none; }
    .scene.revealing::before { display: none; }
}

@media (max-width: 520px) {
    .scene { min-height: 15.5rem; }
    .scene-sky, .scene-meta { font-size: .56rem; }
    .sprite-stage { transform: translateY(26px); }
}
