/**
 * War 2050 - Intro Story Styles
 * Cinematic typewriter effect with fade transitions
 * Now functions as OVERLAY on top of running game
 */

/* =================================================================
   INTRO OVERLAY (Layer 3 - z-index: --z-intro, 2000)
   Sits on top of game canvas and HUD, with mask hole for life bar.
   See docs/HUD-LAYOUT.md §2 - the intro sequence is otherwise out of
   scope for this contract, but its z-index has to slot into the ladder.
   ================================================================= */

.intro-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: #000;
    background-image: url('/assets/images/theFall.webp');
    /* Height-driven, not width-driven. `100% auto` fitted the image to the
       viewport WIDTH, which on a phone (about 0.46:1) left a landscape still
       occupying a shallow band with black above and below it. `cover` scales
       by whichever axis needs it, so on a portrait screen the image fills the
       height and overflows sideways — which is what gives the pan below room
       to move.
       It also degrades safely the other way: on a viewport wider than the
       image's own aspect, cover scales by width instead, there is no
       horizontal overflow, and background-position-x simply has nothing to
       slide. That is why this is `cover` and not `auto 100%` — the latter
       would leave a gap down one side of an ultrawide monitor and the pan
       would drag the image across it. */
    background-size: cover;
    background-position: 0% top;
    background-repeat: no-repeat;
    z-index: var(--z-intro);
    display: flex;
    align-items: center;
    justify-content: center;
    /* Reserve the strip the fixed continue prompt, progress dots and Skip
       button sit in. Put here rather than on .scene because this is the flex
       container: shrinking it means the scene is centred within the space
       that is actually free, so its text can never be laid out underneath
       them however long the scene is. */
    padding-bottom: 9.5rem;
    box-sizing: border-box;

    /* =============================================================
       HOW BIG THE OVERSEER IS — one number, and everything derives.

       It lives on the overlay rather than on .intro-ai-image because
       three separate things need it: his own box, the LIFE bar that
       sits under him, and the top reserve below. When it was a
       constant on .intro-ai-image the other two were hand-copied
       constants that had to be kept in step by hand, which is exactly
       how the LIFE bar came to be sitting on his caption.

       Bounds, all measured rather than picked:
       - 120px floor. That is OverseerPresence's own `min-width` and
         its documented readable minimum; below it the slow push-in
         that makes him look awake stops being legible, which is the
         whole feature. Asking for less does not shrink him, it just
         makes him overflow this container.
       - 44vw so he stays clear of the frame edges on a phone.
       - 260px ceiling; past that he stops reading as a presence in the
         frame and starts reading as a portrait the frame is built
         around. (The component's own ceiling is 340px.)
       - and, between those, `50vh - 280px`, which is the one that
         actually decides it most of the time.

       That last term is not a shape someone would pick by eye. The
       binding constraint is viewport HEIGHT, because he and the scene
       text share one column: whatever he takes at the top, the text
       does not get. THE STAKES and THE GIFT are five paragraphs each
       and are ~600px tall at the desktop type scale, so on a 800px
       window there is almost nothing left over, while a 1080px window
       has room to spare. Measured at 1280 wide, the largest he can be
       while every scene still lands clear of both the text and the
       bottom furniture is ~120px at 800px tall and ~260px at 1080px
       tall — and `50vh - 280px` is the line through those two points.
       Below 800px tall the 120px floor takes over and the tallest two
       scenes are the ones that give (see the note on .scene).
       ============================================================= */
    --overseer-size: clamp(120px, min(calc(50vh - 280px), 44vw), 260px);

    /* =============================================================
       THE SEQUENCE'S INK — three tokens, measured against the art.

       docs/TYPE-LEGIBILITY.md is explicit that text-shadow does not
       count toward contrast, so the halo below can no longer be the
       reason a colour stays. That left the reds indefensible: the
       scrim is 0.18 (see ::before), and sampled across all seven
       paintings at both ends of the pan, the brightest background any
       narration crosses composites to L=0.037 — THE FALL blows to
       near-white behind its opening paragraph. Against that:

           #dc2626  command red      2.8:1   the old .warning-text
           #ef4444  red-500          3.6:1   the old .ai-speaks
           #f87171  red-400          4.9:1
           #ff8585  (this)           5.6:1

       So the reds are lifted two rungs. They are still unmistakably
       red — this is not a hue change, it is an exposure change, and
       it is the same move the scrim makes.

       The neutrals are the palette's own: #94a3b8 for the subordinate
       chrome (scene card titles, the loader count) at 5.3:1, and
       #cbd5e1 for the two things the player has to be able to act on
       (Skip, and the continue prompt) at 8.6:1. Both replace #64748b,
       which measured 2.2:1 over the art and 4.41:1 even against flat
       black — failing on both readings. */
    --intro-red: #ff8585;        /* the Overseer's voice, and warnings */
    --intro-red-em: #ffb1b1;     /* his emphasis, and the drained LIFE bar */
    --intro-chrome: #cbd5e1;     /* Skip, "tap to continue" — actionable */
    --intro-chrome-dim: #94a3b8; /* scene titles, loader count — subordinate */

    /* A white halo, sat under every piece of red type in the sequence.
       Reused rather than hand-tuned per rule so the reds cannot drift apart.

       Red on these paintings is the worst contrast pairing in the intro: the
       art is dominated by deep blues and near-blacks, and #dc2626 against
       that is a hue difference far more than a luminance one — which reads as
       "coloured" rather than as "legible", and disappears entirely wherever a
       painting has warm lamplight behind the text. The existing glows were
       red on red, which added bloom without adding contrast.

       Three stops, tight to wide: the 1px core sharpens the glyph edge (this
       is the "outline" — a real stroke would thicken 0.7rem type into mush),
       the 4px carries most of the separation, and the 10px lifts the letter
       off whatever is behind it. The coloured glow then goes OUTSIDE this in
       each rule, so the red still bleeds but no longer has to do the
       legibility work. */
    --red-halo:
        0 0 1px rgba(255, 255, 255, 0.95),
        0 0 4px rgba(255, 255, 255, 0.8),
        0 0 10px rgba(255, 255, 255, 0.4);

    /* Everything above the scene text: his 2rem offset, his box, the 8px gap
       and the OVERSEER caption, plus breathing room. Derived so it can never
       drift from --overseer-size.

       The mock LIFE bar's room is NOT in here. It used to be, unconditionally,
       and only two of the seven scenes ever show that widget — so the other
       five held a 112px void between his caption and their title for
       something that never arrived. Measured identically at 448x920, 412x850
       and 412x780: a flat 145px gap. On the short end that void was the
       difference between the last paragraph clearing "Tap to continue" and
       sitting on top of it. See .has-lifebar below. */
    --overseer-reserve: calc(2rem + var(--overseer-size) + 40px);
}

/* When he is on screen the scene text is laid out BELOW him rather than
   centred in the whole overlay.

   Centring was the bug. .intro-content carried a flat `margin-top: 120px`
   whose comment read "Always reserve space for Overseer/life bar", but a
   flex item centred with align-items: center overflows its line
   symmetrically once it is taller than the line — so on the two
   five-paragraph scenes the block grew UPWARDS as fast as the reserve
   pushed it down. Measured at 1280x800 before this change: THE GIFT put
   its title at y105 and its first paragraph at y159, both underneath an
   Overseer occupying y32-160 at z-index 2010. The scene silently lost its
   heading and its opening line, and no amount of padding could fix it
   while the block stayed centred.

   Reserving on the container and top-aligning within it is the same
   pattern padding-bottom above already uses, applied to the other end. */
.intro-overlay.has-overseer {
    align-items: flex-start;
    padding-top: var(--overseer-reserve);
}

/* Only the scenes that actually show the mock LIFE bar pay for it. Set from
   showScene() alongside has-overseer, so it lands during the same cross-fade
   and the reflow is never visible. */
.intro-overlay.has-overseer.has-lifebar {
    /* The bar itself is ~28px plus its gap. 72px here was generous padding
       around a widget that does not need it, and these are the two tallest
       scenes in the intro — the only two that show it. */
    padding-top: calc(var(--overseer-reserve) + 44px);
}


/* Dark overlay for text contrast.
 *
 * 0.82, up from 0.75, and the number is measured rather than picked.
 *
 * The contrast audit resolves a background from `background-color`, so for
 * this overlay it sees #000 and reports every scene as text on black. It is
 * not: a full-bleed painting is showing through this scrim, and the paintings
 * are not uniformly dark — THE FALL blows out to near-white behind its
 * opening paragraph, and THE SIMULATION carries a cold highlight straight
 * through the scene title. Sampling the real composited pixels under every
 * text rect, in all seven scenes, at both ends of the background pan, the
 * brightest 2% of what the narration crosses came to rgb(63,64,64) — i.e.
 * the scrim was passing a blown highlight at full strength / 4.
 *
 * Each 0.01 here buys about 0.7% of contrast on every piece of type in the
 * sequence at once, which is far cheaper than pushing seven separate colours
 * up to compensate. 0.82 is where the lifted reds clear 4.5:1 over the worst
 * of the art while still reading as red; past ~0.85 the paintings stop being
 * paintings and start being texture. */
.intro-overlay::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.82);
    pointer-events: none;
}

.intro-overlay.hidden {
    display: none !important;
}

.intro-overlay.fade-out {
    animation: introFadeOut 1.5s ease forwards;
}

@keyframes introFadeOut {
    from { opacity: 1; }
    to { opacity: 0; }
}

/* Slow drift across the still: left edge as the scene opens, right edge as it
   ends. Only visible where the image actually overflows sideways, which on
   `cover` means portrait screens — exactly where the old width-fit wasted the
   most of the frame.
   `forwards` matters: scenes wait for a tap once their paragraphs have shown,
   so the pan has to hold at the right edge rather than snap back while the
   player is still reading. The duration is set per scene from JS, because a
   two-paragraph scene and a four-paragraph one are not on screen for
   remotely the same time. */
.intro-overlay.bg-pan {
    animation: introBgPan var(--bg-pan-duration, 18s) linear forwards;
}

@keyframes introBgPan {
    from { background-position-x: 0%; }
    to   { background-position-x: 100%; }
}

/* Both classes are present on the last scene. The `animation` shorthand does
   not merge, so without this the pan would silently replace the fade the
   sequencer relies on to finish the intro. Three classes outrank two. */
.intro-overlay.bg-pan.fade-out {
    animation: introFadeOut 1.5s ease forwards;
}

/* Reveal game underneath - semi-transparent background for final scene */
.intro-overlay.reveal-game {
    background-color: rgba(0, 0, 0, 0.5);
    transition: background-color 1.5s ease;
}

/* =================================================================
   INTRO MOCK LIFE BAR
   Displayed below Overseer during THE STAKES scene
   ================================================================= */

.intro-life-bar {
    position: fixed;
    /* Directly below his caption, derived rather than hand-set: 2rem
       offset + his box + 8px gap + the caption + a small gap. This
       used to be three hand-picked constants (180/140/120px) that had to
       be kept in step with three hand-picked sizes, and were not — at
       92px on a tablet the caption and the LIFE readout overlapped by
       43px. One number now drives both.
       38px, not 32px: the OVERSEER caption below is 12.8px now rather
       than 9.6px (12px is the legibility floor and it was the worst
       offender in the product at 0.3em tracking), so the term that
       stands for "the caption" has to grow with it or the gap this
       leaves closes from 12px to 9px. */
    top: calc(2rem + var(--overseer-size) + 38px);
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.75rem;
    background: rgba(220, 38, 38, 0.15);
    border: 1px solid #dc2626;
    border-radius: 6px;
    z-index: var(--z-intro-top);
    /* Hidden by default, shown during THE STAKES scene */
    opacity: 0;
    transition: opacity 0.5s ease;
}

.intro-life-bar.visible {
    opacity: 1;
}

.intro-life-bar.highlight {
    animation: introLifeBarPulse 1.5s ease-in-out infinite;
}

@keyframes introLifeBarPulse {
    0%, 100% {
        box-shadow: 0 0 20px rgba(220, 38, 38, 0.4);
    }
    50% {
        box-shadow: 0 0 40px rgba(220, 38, 38, 0.8),
                    0 0 60px rgba(220, 38, 38, 0.4);
    }
}

/* 0.8rem, up from 0.7rem: 11.2px on a phone, under the 12px floor, and it
   was one of the two genuine contrast failures in the intro as well (3.62:1
   measured, and 2.8:1 over the art). Size and colour both. */
.intro-life-bar-label {
    font-family: 'Orbitron', monospace;
    font-size: 0.8rem;
    font-weight: 700;
    color: var(--intro-red);
    letter-spacing: 0.1em;
    /* Sits on the lit bar itself, so it needs the halo more than most. */
    text-shadow: var(--red-halo);
}

.intro-life-bar-track {
    width: 120px;
    /* 18px, up from 16px, purely to carry the readout below at 12px. */
    height: 18px;
    background: #0a0f1a;
    border-radius: 4px;
    position: relative;
    overflow: hidden;
}

/* The light end was #ef4444, which is 3.8:1 under white — and the readout is
   white and sits centred over this fill, so as the bar animates out to 48%
   the "48%" crosses it. #dc2626 is 4.8:1 and the bar still reads red. */
.intro-life-bar-fill {
    height: 100%;
    background: linear-gradient(90deg, #b91c1c, #dc2626);
    border-radius: 4px;
}

/* The readout is centred over the TRACK, so what is behind it depends on how
   full the bar is: at 1% it sits on the near-black track, at 48% the fill's
   edge runs straight through the middle of it. White is 4.8:1 on the red fill
   but 2.8:1 on the orange one — and colouring it per state does not work
   either, because at 48% the two halves of "48%" are on different colours and
   whichever colour is chosen, one glyph disappears. (Tried it: the "%" went.)
 *
 * So it gets its own backing and stops depending on the bar at all. 12.5:1
 * over the orange fill, more over everything else, identical in all three
 * states, and it reads as a digital readout rather than as text lying on a
 * gauge. */
.intro-life-bar-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    padding: 0 5px;
    border-radius: 3px;
    background: rgba(6, 10, 18, 0.78);
    font-family: 'Orbitron', monospace;
    /* 0.65rem was 10.4px. */
    font-size: 0.75rem;
    line-height: 1.25;
    font-weight: 700;
    color: #fff;
}

.intro-life-bar-rate {
    font-family: monospace;
    font-size: 0.8rem;
    color: var(--intro-red);
    text-shadow: var(--red-halo);
}

/* Life Bar State: Critical (1%, dark red, fast flash) */
.intro-life-bar.critical {
    border-color: #991b1b;
    background: rgba(153, 27, 27, 0.2);
    animation: introLifeBarCriticalFlash 0.5s ease-in-out infinite;
}

.intro-life-bar.critical .intro-life-bar-fill {
    background: linear-gradient(90deg, #991b1b, #b91c1c);
}

/* Was #991b1b — 1.6:1 over the art, the single worst-contrast type in the
   sequence, and on the one state that is supposed to alarm. The state now
   reads by going PALE rather than by going dark: a drained red, which is
   both legible (8:1) and the better metaphor. The bar, its border and the
   flash still carry the deep red. */
.intro-life-bar.critical .intro-life-bar-label,
.intro-life-bar.critical .intro-life-bar-rate {
    color: var(--intro-red-em);
}

@keyframes introLifeBarCriticalFlash {
    0%, 100% {
        opacity: 1;
        box-shadow: 0 0 20px rgba(153, 27, 27, 0.6);
    }
    50% {
        opacity: 0.6;
        box-shadow: 0 0 40px rgba(153, 27, 27, 0.9);
    }
}

/* Life Bar State: Orange (48%, orange, light pulse) */
.intro-life-bar.orange {
    border-color: #f97316;
    background: rgba(249, 115, 22, 0.15);
    animation: introLifeBarOrangePulse 2s ease-in-out infinite;
}

.intro-life-bar.orange .intro-life-bar-fill {
    background: linear-gradient(90deg, #ea580c, #f97316);
    transition: width 2s ease-out;
}

.intro-life-bar.orange .intro-life-bar-label,
.intro-life-bar.orange .intro-life-bar-rate {
    color: #f97316;
}

@keyframes introLifeBarOrangePulse {
    0%, 100% {
        box-shadow: 0 0 15px rgba(249, 115, 22, 0.3);
    }
    50% {
        box-shadow: 0 0 25px rgba(249, 115, 22, 0.5);
    }
}

/* Life Bar Fill Animation */
.intro-life-bar-fill.animating {
    transition: width 2s ease-out;
}

/* =================================================================
   THE OVERSEER (first seen during THE HUNGER, resident from then on)

   Two occupants share this one fixed slot, and the CSS has to dress
   either of them identically:

     .overseer-stage  the live video, inserted by intro.js and filled by
                      window.OverseerPresence. Only ever present when
                      that component mounted successfully.
     .ai-icon         the still SVG that index.html ships. This is the
                      fallback and it is load-bearing: if
                      overseerPresence.js is absent, unsupported, or
                      throws, no stage is ever created and this is what
                      the player sees — the intro plays exactly as it
                      always has. `.is-live` is the single switch that
                      retires it, and intro.js only sets that once the
                      component has demonstrably put something in the box.

   The entrance below is written against BOTH, on the same keyframes.
   ================================================================= */

.intro-ai-image {
    position: fixed;
    top: 2rem;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-2);
    z-index: var(--z-intro-top);
    /* Hidden by default */
    opacity: 0;
    pointer-events: none;
    transition: opacity 1s ease-in-out;

    /* --overseer-size is inherited from .intro-overlay — see the block at
       the top of this file for how it is bounded. It used to be declared
       here with three per-breakpoint overrides; the LIFE bar and the
       layout reserve both need it too, so it moved up to the common
       ancestor. */

    /* How far the stage drops to sit in the upper-middle of the frame
       during beat 2 of the entrance. Derived rather than picked so it
       lands in the same place on a phone and a monitor: the element's
       own centre currently sits at (top + size/2), and we want it at
       34vh. Above centre on purpose — the scene text lives below.
       Wrapped in max() so a very short viewport cannot produce a
       negative drop and slide him off the top edge. */
    --overseer-drop: max(0px, calc(34vh - 2rem - var(--overseer-size) / 2));

    /* Belt and braces for the 390px case: he is centred with
       translateX(-50%), so anything wider than the viewport would hang
       off both sides. Nothing here should ever reach this, and if
       something does it is clamped rather than scrolling the page. */
    max-width: calc(100vw - var(--space-8));
}

.intro-ai-image.visible {
    opacity: 1;
}

/* The container itself must not spend a second fading while the entrance
   is trying to cut in — the animation owns the reveal from here. */
.intro-ai-image.is-arriving {
    transition: opacity 120ms linear;
}

/* --- the live video's frame ------------------------------------- */

/* A positioning wrapper and nothing else. It deliberately has no frame,
   no crop and no glow of its own: OverseerPresence owns the square, the
   ring, the vignette and the per-mood colour, and an earlier version of
   this rule that carried its own border and overflow:hidden clipped the
   component's circular ring into a stray arc. The lane boundary is also
   the right visual boundary — the entrance is staging, his look is his. */
.overseer-stage {
    position: relative;
    display: block;
    line-height: 0;
    width: var(--overseer-size);
    max-width: 100%;
}

/* The component sizes itself to whatever width its container gives it and
   treats --op-size only as a ceiling, so the wrapper above is what decides
   how big he is. Restating the ceiling here keeps the two from ever
   disagreeing: without it .op-scene's own min(340px, 72vw) would silently
   become the cap, and on a wide-but-short window that is larger than the
   reserve this layout is holding open for him.

   Targets the element rather than the wrapper on purpose: .op-scene
   declares --op-size on .op-presence itself, so a value inherited from the
   parent would never win. */
.overseer-stage .op-presence {
    --op-size: var(--overseer-size);
}

.intro-ai-image.is-live .ai-icon {
    display: none;
}

/* --- the still fallback ----------------------------------------- */

.intro-ai-image .ai-icon {
    width: var(--overseer-size);
    height: auto;
    filter: drop-shadow(0 0 30px color-mix(in srgb, var(--color-accent-red) 70%, transparent));
    animation: aiPulse 2s ease-in-out infinite;
}

/* His name carries a heavier halo than the body reds. It sits at the widest
   tracking in the sequence (0.3em), directly under a lit video panel — the two
   things that most cost a small glyph its edge. It is also the one label that
   must never be missed, because it is what tells the player who has been
   narrating.

   It used to be 0.7rem, and 0.6rem below 768px — 9.6px at 0.3em tracking,
   which docs/TYPE-LEGIBILITY.md §5 names as the worst possible combination:
   caps remove word-shape cues and tracking removes word grouping, so the
   thing that identifies the antagonist was eight isolated 9.6px glyphs. Now
   0.8rem everywhere (12.8px), with no phone override — a wide-tracked cap
   label is the last thing that should be getting a size CUT on the narrow
   screen. See the 768px block, which no longer shrinks it. */
.intro-ai-image .ai-label {
    font-family: var(--font-display);
    font-size: 0.8rem;
    font-weight: var(--font-bold);
    color: var(--intro-red);
    letter-spacing: 0.3em;
    white-space: nowrap;
    text-shadow:
        0 0 2px #fff,
        0 0 5px rgba(255, 255, 255, 0.95),
        0 0 11px rgba(255, 255, 255, 0.65),
        0 0 20px rgba(255, 255, 255, 0.3),
        0 0 30px color-mix(in srgb, var(--color-accent-red) 65%, transparent);
}

/* The still's resting glow. There is no counterpart for the live path:
   the component pulses its own ring per mood, and a second glow layered
   under it just muddied the colour it was trying to signal with. */
@keyframes aiPulse {
    0%, 100% {
        filter: drop-shadow(0 0 30px color-mix(in srgb, var(--color-accent-red) 70%, transparent));
    }
    50% {
        filter: drop-shadow(0 0 50px var(--color-accent-red));
    }
}

/* =================================================================
   THE ENTRANCE

   One 2000ms composition across four elements, driven by two classes
   that intro.js adds together and removes together:

     .intro-ai-image.is-arriving       him, and his ident
     .intro-overlay.overseer-takeover  the narration yielding to him

   Beat 1 (0-620ms)     .overseer-signal, a stepped interference cut
   Beat 2 (120-1160ms)  he locks in as a scan line, snaps open to peak
                        over the middle of the frame, and HOLDS while the
                        narration dims almost out under him
   Beat 3 (1160-2000ms) he withdraws to the slot at the top of the frame
                        and the OVERSEER ident stamps in beneath him

   Everything moves via transform/filter/opacity on position:fixed
   elements, so none of it can reflow the scene text or widen the page.
   The JS constant ARRIVAL_MS is the other half of this; change one,
   change both.

   THE MULTIPLIER is 2x, down from 2.2x when he settled at 128px.

   It is a constant and not a derived ratio, and that is deliberate:
   the safety argument lives entirely in --overseer-size's clamp, which
   is already bounded by 44vw and by 50vh - 280px. Doubling those:
     width   2 x 44vw = 88vw, so 6vw of margin either side at any width
     height  centred at 34vh, so the peak spans 280px - 16vh to
             84vh - 280px, which is inside the viewport for every
             height at which the intro is watchable
   So 2x cannot overflow either axis at any viewport, and nothing here
   needs to measure anything.

   (The first attempt DID derive it, as clamp(1, min(calc((100vw -
   3rem) / var(--overseer-size)), ...), 2). That is invalid CSS: calc()
   may divide by a number but not by a length. Custom properties do not
   validate at declaration time, so it parsed happily and only failed
   when substituted into scale() — where an invalid value drops the
   whole transform and takes the entrance with it.)
   ================================================================= */

.intro-ai-image.is-arriving .overseer-stage,
.intro-ai-image.is-arriving .ai-icon {
    animation: overseerArrive 2000ms linear forwards;
    /* Scoped to the entrance only — he sits in this slot for four more
       scenes afterwards and does not need a permanent layer. */
    will-change: transform, filter;
}

.intro-ai-image.is-arriving .ai-label {
    animation: overseerIdent 2000ms linear forwards;
}

.intro-overlay.overseer-takeover .intro-content {
    animation: overseerYield 2000ms ease forwards;
}

@keyframes overseerArrive {
    /* Nothing, then a single blown-out horizontal line at full width -
       a channel locking on rather than an element fading in. */
    0% {
        opacity: 0;
        transform: translate(var(--overseer-shift, 0px), var(--overseer-drop)) scale(2) scaleY(0.02);
        filter: brightness(8) saturate(0);
    }
    4% {
        opacity: 1;
        transform: translate(var(--overseer-shift, 0px), var(--overseer-drop)) scale(2) scaleY(0.02);
        filter: brightness(8) saturate(0);
    }
    11% {
        opacity: 1;
        transform: translate(var(--overseer-shift, 0px), var(--overseer-drop)) scale(2) scaleY(0.02);
        filter: brightness(8) saturate(0);
    }
    /* Snaps open vertically, overshoots, settles: a CRT finding its
       picture. The overshoot is what stops it reading as a wipe. */
    18% {
        transform: translate(var(--overseer-shift, 0px), var(--overseer-drop)) scale(2) scaleY(1.1);
        filter: brightness(3) saturate(0.3);
    }
    24% {
        transform: translate(var(--overseer-shift, 0px), var(--overseer-drop)) scale(2) scaleY(0.9);
        filter: brightness(1.3) saturate(1.3);
    }
    30% {
        transform: translate(var(--overseer-shift, 0px), var(--overseer-drop)) scale(2);
        filter: none;
    }
    /* THE HOLD. He is large, centred, and nobody is saying anything.
       This is the beat the whole opening has been building to; the rest
       of this keyframe list is staging for it. */
    58% {
        transform: translate(var(--overseer-shift, 0px), var(--overseer-drop)) scale(2);
        filter: none;
        animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
    }
    /* And he takes up his post, where he stays for the rest of the intro. */
    100% {
        opacity: 1;
        transform: translateY(0) scale(1);
        filter: none;
    }
}

@keyframes overseerIdent {
    /* The caption is the punctuation on the entrance, not part of it, and it
       is held back until 86% for a concrete reason: the stage is scaled from
       its own centre, so while he is still oversized his bottom edge reaches
       down through the row the caption occupies. Anything earlier than this
       is stamped across his chin — measured, not guessed. That leaves ~280ms,
       which suits it: a stamp, not a fade. The dip at 94% is the second
       strike of it. */
    0%, 86% {
        opacity: 0;
        letter-spacing: 0.9em;
        transform: translateY(4px);
    }
    95% {
        opacity: 1;
        letter-spacing: 0.3em;
        transform: translateY(0);
    }
    97% {
        opacity: 0.25;
    }
    100% {
        opacity: 1;
        letter-spacing: 0.3em;
        transform: translateY(0);
    }
}

@keyframes overseerYield {
    /* The narration does not fade out and back for decoration: at peak
       scale he overlaps where the scene text sits, and dimming it is both
       the fix for that and the better reading of the moment. */
    0%   { opacity: 1; }
    14%  { opacity: 0.1; }
    62%  { opacity: 0.1; }
    100% { opacity: 1; }
}

/* Beat 1: the interference cut. Created and removed by intro.js, and
   appended last inside the overlay so it paints over the Overseer too -
   the channel is being taken, and he is on the far side of it. */
.overseer-signal {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: var(--z-intro-top);
    background-color: var(--color-text-primary);
    background-image: repeating-linear-gradient(
        0deg,
        color-mix(in srgb, var(--color-accent-red) 60%, transparent) 0px,
        color-mix(in srgb, var(--color-accent-red) 60%, transparent) 2px,
        transparent 2px,
        transparent 5px
    );
    mix-blend-mode: screen;
    opacity: 0;
    /* steps(1, end), not ease: every level change is a hard cut. An eased
       flash reads as a transition between two screens; a cut reads as
       something seizing the one you were watching. */
    animation: overseerSignal 620ms steps(1, end) forwards,
               overseerSignalRoll 620ms linear forwards;
}

/* ONE rise, then a decay. Not a stylistic preference — a safety limit.
 *
 * This used to peak at 6%, 22% and 44% of 620ms: three rises inside 273ms,
 * about 11 Hz, at opacity 0.85 of near-white with red scanlines under
 * mix-blend-mode: screen, across `inset: 0` — the entire viewport, which is
 * the worst case for area. WCAG 2.3.1 (Level A) allows at most three flashes
 * in any one-second period, and imposes a separate, stricter limit on
 * saturated red. This failed both, on the largest surface available.
 *
 * prefers-reduced-motion already skipped it, and that is not a defence:
 * reduced-motion is a vestibular preference, and someone photosensitive has
 * no particular reason to have set it. The guard has to be in the effect.
 *
 * One rise at 1.6 flashes/second is unambiguously inside the limit, and the
 * decay reads as a signal degrading rather than a strobe — which is closer to
 * what the beat wanted anyway. Do not add a second rise back: two would put
 * this at the threshold, and the whole point is not to sit near it. */
@keyframes overseerSignal {
    0%   { opacity: 0; }
    7%   { opacity: 0.72; }
    34%  { opacity: 0.16; }
    64%  { opacity: 0.06; }
    100% { opacity: 0; }
}

@keyframes overseerSignalRoll {
    from { background-position-y: 0; }
    to   { background-position-y: 45px; }
}

/* =================================================================
   SCENE DISPLAY
   ================================================================= */

.scene {
    max-width: 800px;
    padding: 2rem;
    text-align: center;
    opacity: 0;
    transition: opacity 1s ease;

    /* The overlay centres this block with no height bound, so a long scene
       (scene one is four paragraphs) simply overflowed — running underneath
       the continue prompt, the progress dots and the Skip button, all of
       which are position:fixed near the bottom edge. On a phone that put
       body text and "Tap to continue" on top of each other.

       Cap the height to the viewport and reserve the strip those fixed
       controls occupy, so the text scrolls within itself instead of growing
       under them. Desktop is unaffected: the text is nowhere near tall
       enough there for either rule to bite. */
    max-height: 100%;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    /* No scrollbar gutter — this is a cinematic, not a document. */
    scrollbar-width: none;
}

.scene::-webkit-scrollbar { display: none; }

/* dvh is recent; fall back to the classic unit where it is unknown. */


.scene.active {
    opacity: 1;
}

.scene.fade-out {
    opacity: 0;
}

/* =================================================================
   TYPOGRAPHY
   ================================================================= */

/* Uppercase at 0.3em tracking again, so it gets the same treatment as the
   ident: it stays subordinate to the narration, but it does that by being a
   dimmer neutral rather than by being an illegible one. #64748b measured
   4.41:1 against flat black and 2.5:1 over the art. */
.scene-title {
    font-family: 'Orbitron', monospace;
    font-size: 0.875rem;
    font-weight: 600;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    color: var(--intro-chrome-dim);
    margin-bottom: 2rem;
    opacity: 0;
    animation: fadeIn 0.5s ease forwards;
}

.scene-text {
    font-size: 1.5rem;
    font-weight: 400;
    /* Was 2 — literal double-spacing, 48px of line box around 24px type. That
       is the whole reason THE STAKES and THE GIFT ran off a phone: five
       paragraphs at roughly twelve lines cost ~576px of height before a single
       margin was counted, against ~640px of usable room. Nothing else in the
       scene was big enough to be worth cutting, and the copy was not the
       problem.
       1.65 is still open for display type at this size and gives back ~140px
       on the tallest scene, which is what the overflow needed. */
    line-height: 1.65;
    /* Worth knowing before changing this: it does NOT colour the narration.
       base.css carries a bare `p { color: var(--color-text-secondary) }`, and
       an element rule beats inheritance however weak it is — so every
       paragraph renders #94a3b8 and this gold only ever reaches the em /
       strong / .ai-speaks children below, each of which sets its own colour
       anyway. Measured, not assumed: the body copy is 5.3:1 over the worst
       part of the paintings, which passes, so this is documented rather than
       "fixed" — making it bite would turn the whole intro gold, which is a
       design decision and not a legibility one. */
    color: #d4af37;
    text-shadow: 0 0 20px rgba(212, 175, 55, 0.3);
}

.scene-text p {
    /* Paired with the tighter line-height above: at 1.5rem the gap between
       paragraphs has to stay clearly larger than the gap between lines, or
       the block reads as one paragraph. 1.15rem against a 1.65 line keeps
       that separation while giving the long scenes back another ~55px. */
    margin-bottom: 1.15rem;
    opacity: 0;
}

/* The mask lives on the paragraph itself rather than in the keyframes so it is
   present before the animation starts — declaring it only in `from` would let
   the browser paint one unmasked frame first, which is a flash of the whole
   line arriving at once, exactly what the wipe exists to avoid.
   300% wide so the gradient has room to travel the full width of the line. */
.scene-text p.visible {
    -webkit-mask-image: linear-gradient(95deg,
        #000 0%, #000 26%, rgba(0, 0, 0, 0.55) 38%, transparent 52%, transparent 100%);
    mask-image: linear-gradient(95deg,
        #000 0%, #000 26%, rgba(0, 0, 0, 0.55) 38%, transparent 52%, transparent 100%);
    -webkit-mask-size: 300% 100%;
    mask-size: 300% 100%;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;

    /* Longer than the old 0.8s because the wipe has to be watchable — the
       reveal is now part of the pacing, and dwellFor() in intro.js budgets
       620ms for it before it starts counting reading time. */
    animation: typewriterFade 0.92s cubic-bezier(0.16, 0.84, 0.34, 1) forwards;
}

/* Emphasis text */
.scene-text em {
    font-style: normal;
    color: #f4d03f;
    text-shadow: 0 0 30px rgba(244, 208, 63, 0.5);
}

/* Strong emphasis */
.scene-text strong {
    font-weight: 700;
    color: #fff;
    text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
}

/* Final warning text. It appears both on its own line and NESTED inside
   .ai-speaks ("...no further use for you"), so it has to out-read his normal
   voice from inside it. That used to be done by going darker, which is the
   one direction that cannot work over a painting — #dc2626 measured 2.8:1.
   It now separates by WEIGHT plus a hotter red, both of which read as "more"
   rather than as "further away". */
.warning-text {
    font-weight: 700;
    color: #ff9d9d;
    text-shadow: var(--red-halo), 0 0 22px rgba(220, 38, 38, 0.55);
}

/* Orange text (for 48% life allocation) */
.orange-text {
    color: #f97316;
    text-shadow: 0 0 20px rgba(249, 115, 22, 0.5);
}

/* AI Speech styling - when the AI is speaking directly */
/* The Overseer's own lines. The most-read red in the sequence — five of the
   seven scenes are largely his voice — so this is where the halo earns most. */
.scene-text .ai-speaks {
    color: var(--intro-red);
    text-shadow: var(--red-halo), 0 0 20px rgba(239, 68, 68, 0.45);
}

.scene-text .ai-speaks em {
    color: var(--intro-red-em);
    text-shadow: var(--red-halo), 0 0 22px rgba(248, 113, 113, 0.5);
}

.scene-text .ai-speaks strong {
    color: #fff;
    text-shadow: 0 0 15px rgba(255, 255, 255, 0.6);
}

/* =================================================================
   TYPEWRITER CURSOR
   ================================================================= */

.intro-overlay .typewriter-cursor {
    display: inline-block;
    width: 3px;
    height: 1.2em;
    background-color: #d4af37;
    margin-left: 4px;
    vertical-align: text-bottom;
    animation: blink 0.8s infinite;
}

@keyframes blink {
    0%, 50% { opacity: 1; }
    51%, 100% { opacity: 0; }
}

/* =================================================================
   TERMINAL FLICKER EFFECT
   ================================================================= */

.flicker {
    animation: flicker 0.15s linear;
}

@keyframes flicker {
    0% { opacity: 1; }
    25% { opacity: 0.8; }
    50% { opacity: 1; }
    75% { opacity: 0.6; }
    100% { opacity: 1; }
}

/* CRT scanline overlay */
.scanlines {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    background: repeating-linear-gradient(
        0deg,
        rgba(0, 0, 0, 0.1) 0px,
        rgba(0, 0, 0, 0.1) 1px,
        transparent 1px,
        transparent 2px
    );
    z-index: var(--z-intro-top);
    opacity: 0.3;
}

/* =================================================================
   ANIMATIONS
   ================================================================= */

@keyframes fadeIn {
    from {
        opacity: 0;
        transform: translateY(-10px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

/* The line arrives by being UNCOVERED, not by fading up.
 *
 * A fade is what an element does when nobody decided anything: every panel in
 * every app fades. A wipe has a direction and an edge, so it reads as authored
 * — it is the vocabulary of title design rather than of UI, and it is the one
 * change that most separates "a screen appeared" from "a sequence is playing".
 *
 * The mask is a gradient three times the width of the line, slid across it.
 * The soft middle band is what makes the edge a sweep rather than a shutter:
 * each word emerges over ~180ms instead of switching on. Paired with a small
 * rise so the line settles rather than simply arriving, and a matching blur
 * that clears as it lands — the blur is what stops the rise reading as a
 * slide, which is the thing that would make this look like a web page.
 *
 * The mask is the reason this cannot use `opacity` alone: a masked element can
 * be fully opaque and still be revealed progressively, so the text never spends
 * any of its life at a low contrast that would make it hard to read. */
@keyframes typewriterFade {
    from {
        opacity: 0;
        transform: translateY(0.42em);
        filter: blur(5px);
        -webkit-mask-position: 100% 0;
        mask-position: 100% 0;
    }
    35% {
        opacity: 1;
    }
    to {
        opacity: 1;
        transform: translateY(0);
        filter: blur(0);
        -webkit-mask-position: 0 0;
        mask-position: 0 0;
    }
}

@keyframes fadeToBlack {
    from { opacity: 1; }
    to { opacity: 0; }
}

@keyframes screenOn {
    0% {
        opacity: 0;
        filter: brightness(0);
    }
    10% {
        opacity: 1;
        filter: brightness(2);
    }
    20% {
        filter: brightness(0.5);
    }
    30% {
        filter: brightness(1.5);
    }
    100% {
        opacity: 1;
        filter: brightness(1);
    }
}

/* =================================================================
   PROGRESS INDICATOR
   ================================================================= */

.progress-dots {
    position: fixed;
    bottom: 2rem;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    gap: 0.75rem;
    z-index: var(--z-intro-top);
}

.progress-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background-color: #1a2744;
    border: 1px solid #d4af37;
    transition: all 0.3s ease;
}

.progress-dot.active {
    background-color: #d4af37;
    box-shadow: 0 0 10px rgba(212, 175, 55, 0.5);
}

.progress-dot.completed {
    background-color: #d4af37;
    opacity: 0.5;
}

/* =================================================================
   SKIP BUTTON
   ================================================================= */

.skip-btn {
    position: fixed;
    bottom: 2rem;
    right: 2rem;
    padding: 0.5rem 1rem;
    font-family: 'Rajdhani', sans-serif;
    font-size: 0.875rem;
    font-weight: 600;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    /* The brightest of the three neutrals, because this is a control and not
       a caption — and it is unbacked (background: transparent), so whatever
       the painting is doing in the bottom-right corner is what it sits on.
       The ring moves with it: at #64748b it was 1.2:1 against the art, which
       fails 1.4.11's 3:1 for the boundary of a control as squarely as the
       label failed 1.4.3. */
    color: var(--intro-chrome);
    background: transparent;
    border: 1px solid var(--intro-chrome);
    border-radius: 4px;
    cursor: pointer;
    transition: all 0.3s ease;
    z-index: var(--z-intro-top);
}

.skip-btn:hover {
    color: #d4af37;
    border-color: #d4af37;
}

/* =================================================================
   CONTINUE PROMPT
   ================================================================= */

.continue-prompt {
    position: fixed;
    bottom: 6rem;
    left: 0;
    right: 0;
    /* Centred as a full-width line rather than a centred box: sized to its
       own text it wrapped on a phone, and the two lines then sat
       left-aligned inside a box that was itself centred, which read as
       neither. nowrap keeps it to one line; the clamp keeps that line
       inside a 360px screen. */
    text-align: center;
    white-space: nowrap;
    font-family: 'Rajdhani', sans-serif;
    /* Floor raised from 0.7rem to 0.75rem. The clamp only reaches its floor
       below 375px wide, so this changes nothing at 390 or 412 — but a 360px
       phone was getting 11.2px, and 12px is 12px on every screen. */
    font-size: clamp(0.75rem, 3.2vw, 0.875rem);
    color: var(--intro-chrome);
    letter-spacing: 0.1em;
    opacity: 0;
    z-index: var(--z-intro-top);
    pointer-events: none;
}

/* The pulse belongs to the visible state, not to the element.
   `animation: pulse` was on the base rule, and because that keyframe sets
   opacity (0.5 -> 1) an always-running animation beat the `opacity: 0`
   two lines above it — so "Press SPACE or click to continue" was legible
   through every scene, including the ones that had not finished writing
   themselves yet. It reads as a faint watermark rather than an offer, so
   it went unnoticed; it becomes obvious during the Overseer's entrance,
   where the whole point of the hold is that nothing is being asked of the
   player. Scoping it to .visible makes the base opacity mean what it
   says. */
.continue-prompt.visible {
    opacity: 1;
    animation: pulse 2s ease-in-out infinite;
}

/* The trough was 0.5, and an opacity trough is a contrast trough: half of
   #cbd5e1 composited onto the art is 3.5:1, so for half of every two-second
   cycle the one instruction in the sequence dropped below the threshold. The
   audit cannot see this — it samples a single frame and only discards
   elements under opacity 0.15 — but it is the same failure as a bad colour.
   0.72 keeps the breathing (it is still a visible rise and fall) and keeps
   the worst frame at 5.9:1. */
@keyframes pulse {
    0%, 100% { opacity: 0.72; }
    50% { opacity: 1; }
}

/* =================================================================
   INTRO CONTENT CONTAINER
   Centers the story text within the overlay
   ================================================================= */

.intro-content {
    position: relative;
    z-index: 1; /* stacking within .intro-overlay only (above its ::before dark mask), not the region ladder - exempt per §2 */
    max-width: 800px;
    width: 100%;
    padding: 2rem;
    /* The Overseer's headroom is reserved by .intro-overlay.has-overseer
       (padding-top + flex-start), not by a margin here. See that rule for
       why a margin on a centred flex item could not do the job. */
}

/* =================================================================
   FINAL TRANSITION
   ================================================================= */

.final-fade {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: #000;
    opacity: 0;
    pointer-events: none;
    /* --z-intro-top, not the base --z-intro: this needs to render above
       .scanlines/.intro-ai-image/etc during the completion transition,
       not tie with the base overlay layer. See docs/HUD-LAYOUT.md §2. */
    z-index: var(--z-intro-top);
    transition: opacity 2s ease;
}

.final-fade.active {
    opacity: 1;
    pointer-events: all;
}

/* =================================================================
   RESPONSIVE
   ================================================================= */

@media (max-width: 768px) {
    .scene {
        /* Bottom clearance repeated deliberately: this shorthand comes after
           the base rule and would otherwise reset the padding that keeps the
           text off the fixed continue prompt and Skip button. */
        padding: 1.5rem;
    }

    /* line-height was 1.8 here, which is where the phone overflow actually
       came from — the base rule's value never reached a phone at all. A
       narrow screen wraps far more lines than a desktop, so it is the one
       place a loose line costs the most, and it was the loosest. */
    .scene-text {
        font-size: 1.25rem;
        line-height: 1.5;
    }

    /* THE GIFT is the tallest scene in the intro and the only one that still
       did not fit after the line-height came down. Tightening the paragraph
       gap on phones only is the cheapest remaining height: it is invisible on
       any scene short enough not to need it. */
    .scene-text p {
        margin-bottom: 0.7rem;
    }

    /* 0.75rem was exactly 12px — legal, but only just, for eight caps at
       0.3em tracking. 0.8125rem buys a rung of legibility, and the 2rem
       margin below it pays for the extra height four times over: it was
       generous for a phone, and THE GIFT has the least room in the intro.
       Net effect on that scene is MORE clearance below, not less — see the
       collision numbers in the report. */
    .scene-title {
        font-size: 0.8125rem;
        margin-bottom: 1.5rem;
    }

    .skip-btn {
        bottom: 1rem;
        right: 1rem;
    }

    .progress-dots {
        bottom: 1rem;
    }

    /* No --overseer-size override here any more, and no .intro-life-bar
       `top` either: the clamp at the top of this file is already
       viewport-relative (44vw / 28vh), and the LIFE bar derives its
       position from it. A breakpoint constant would only be able to
       disagree with them.

       There is no .ai-label override here any more either. It shrank the
       ident to 0.6rem — 9.6px — on exactly the screens where a wide-tracked
       cap label is hardest to read, and it was not buying any space: the
       label is one short word in a fixed slot whose height is already
       reserved by --overseer-reserve. It now stays 0.8rem everywhere. */
}

@media (max-width: 480px) {
    .scene-text {
        font-size: 1.1rem;
    }

    /* Same as the 768px block: size and LIFE bar are both derived now.
       The 72px this used to set was below OverseerPresence's 120px floor,
       so the component overflowed its container — a 120px circle inside a
       72px box, which pushed his caption down onto the LIFE readout. */
}

/* =================================================================
   REDUCED MOTION
   docs/HUD-LAYOUT.md §7: disable the scanline/flicker effect and the
   scene-transition reveal animations. .scene-title and .scene-text
   p.visible reach their visible state ONLY via their keyframe (there
   is no separate class-based opacity:1 rule for them), so disabling
   the animation alone would leave the text permanently at opacity:0 -
   each gets an explicit opacity:1 fallback here. .intro-overlay's own
   fade-out is left untouched: it is the one animation the JS intro
   sequencer plausibly gates scene-completion on, and disabling it
   without visibility into tutorial.js/app.js risked leaving the intro
   stuck rather than just less animated.
   ================================================================= */

@media (prefers-reduced-motion: reduce) {
    .flicker {
        animation: none;
    }

    /* Left edge held still. Unlike the text animations below there is no
       opacity to restore — the background is already painted. */
    .intro-overlay.bg-pan {
        animation: none;
        background-position-x: 0%;
    }

    .scene-title,
    .scene-text p.visible {
        animation: none;
        opacity: 1;
        transform: none;
        /* The mask has to be lifted explicitly. `animation: none` leaves the
           element at its unanimated state, which for a masked paragraph is the
           gradient's start position — the line would sit permanently wiped
           out. The same trap this file already documents for .scene-title:
           cancelling an animation does not restore what the animation was
           going to do. */
        filter: none;
        -webkit-mask-image: none;
        mask-image: none;
    }

    .intro-ai-image .ai-icon,
    .intro-life-bar.highlight,
    .intro-life-bar.critical,
    .intro-life-bar.orange,
    .intro-overlay .typewriter-cursor {
        animation: none;
    }

    /* Matches the .visible selector the pulse now lives on — a bare
       `.continue-prompt { animation: none }` here would be one class short
       and lose to it. */
    .continue-prompt,
    .continue-prompt.visible {
        animation: none;
    }

    .continue-prompt.visible {
        opacity: 1;
    }

    /* The entrance, collapsed back to the plain 1s fade the intro has
       always used. Every one of these reaches its resting state through
       its keyframes' 100% stop, so switching the animation off is not
       enough on its own — each needs the end state restored explicitly or
       it would be left at opacity 0 (him, and his ident) or at 0.1 (the
       narration he dims). Same trap as .scene-title above.

       intro.js skips the full-screen interference flash entirely under
       this setting, so there is nothing to disable for beat 1. */
    .intro-ai-image.is-arriving {
        transition: opacity 1s ease-in-out;
    }

    .intro-ai-image.is-arriving .overseer-stage,
    .intro-ai-image.is-arriving .ai-icon {
        animation: none;
        opacity: 1;
        transform: none;
        filter: none;
        will-change: auto;
    }

    .intro-ai-image.is-arriving .ai-label {
        animation: none;
        opacity: 1;
        letter-spacing: 0.3em;
        transform: none;
    }

    .intro-overlay.overseer-takeover .intro-content {
        animation: none;
        opacity: 1;
    }

    .overseer-signal {
        animation: none;
        opacity: 0;
    }
}


/* =================================================================
   INTRO PRELOADER

   The scene backgrounds are large illustrations set one at a time as a
   CSS background, so without this the player watched each one arrive
   mid-scene. Held behind a single explained wait instead.
   ================================================================= */

.intro-loader {
    position: fixed;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.85rem;
    background: #05070d;
    z-index: calc(var(--z-intro-top) + 10);
    transition: opacity 0.35s ease;
}

.intro-loader.is-done {
    opacity: 0;
}

/* Was clamp(0.6rem, 2.6vw, 0.75rem) — 10.7px on a 412px phone, and 9.6px at
   the floor, for uppercase at 0.28em tracking. The loader is a full-screen
   panel with nothing else on it; there was never a density argument for it. */
.intro-loader-label {
    font-family: 'Orbitron', sans-serif;
    font-size: clamp(0.8rem, 3.4vw, 0.95rem);
    letter-spacing: 0.28em;
    color: #d4af37;
    text-align: center;
    padding: 0 1.5rem;
}

.intro-loader-track {
    width: min(62vw, 320px);
    height: 4px;
    background: rgba(212, 175, 55, 0.16);
    border-radius: 2px;
    overflow: hidden;
}

.intro-loader-fill {
    width: 0%;
    height: 100%;
    background: #d4af37;
    border-radius: 2px;
    transition: width 0.25s ease;
}

/* 11.5px at 4.2:1 on the loader's own #05070d. Both under. */
.intro-loader-count {
    font-family: 'Rajdhani', sans-serif;
    font-size: 0.8rem;
    letter-spacing: 0.1em;
    color: var(--intro-chrome-dim);
}

/* =================================================================
   WIDE VIEWPORTS: he leaves the text column

   Stacked above the narration, his size is governed by viewport HEIGHT,
   because whatever he takes at the top the text does not get. THE STAKES
   and THE GIFT are five paragraphs — ~600px at the desktop type scale —
   so on a 1280x800 laptop the arithmetic bottoms out at the 120px floor,
   which is smaller than the thumbnail this replaced. Height is the wrong
   axis to be spending: a 1280x800 window has 1280px of width and almost
   none of it in use.

   So above 1024px he moves out of the column and into a gutter beside it.
   His size is then set by WIDTH, which is abundant, and the text gets the
   full height back. He is 200-320px here at any window height, and the
   two five-paragraph scenes stop fighting him for the same pixels.

   Below 1024px there is no width to spare and the stacked layout above
   is still correct — this is a wide-screen affordance, not a redesign.
   ================================================================= */
@media (min-width: 1024px) {
    .intro-overlay {
        --overseer-size: clamp(200px, 20vw, 320px);
        /* His column: the portrait plus breathing room either side. The
           text is pushed clear of exactly this much. */
        --overseer-gutter: calc(var(--overseer-size) + 7vw);
        /* He is vertically centred here rather than dropping from the
           top, so the entrance's vertical travel is spent. */
        --overseer-drop: 0px;
        /* Beat 2 still belongs at the centre of the screen — he takes
           the frame before he takes his post. This carries him from the
           gutter to the middle and the yield brings him back. */
        --overseer-shift: calc(50vw - var(--overseer-gutter) / 2);
    }

    .intro-overlay.has-overseer {
        align-items: center;
        padding-top: 0;
        padding-left: var(--overseer-gutter);
    }

    .intro-ai-image {
        top: 50%;
        left: calc(var(--overseer-gutter) / 2);
        transform: translate(-50%, -50%);
    }

    /* Follows him out of the column: still directly beneath his caption,
       still derived from his size so the two cannot drift apart. */
    .intro-life-bar {
        top: calc(50% + var(--overseer-size) / 2 + 44px);
        left: calc(var(--overseer-gutter) / 2);
        transform: translateX(-50%);
    }
}
