/* =================================================================
   THE OVERSEER'S PRESENCE

   The frame around the video the idle engine drives. See
   frontend/game/js/overseerPresence.js — that file explains the
   playback; this one only has to explain the box.

   Everything here is prefixed .op- and every node is created by the
   module, so nothing in this file selects markup that lives in
   index.html. It can be loaded before the presence is wired anywhere
   and change nothing.

   ## Size comes from the container

   Nothing here sets a size. `.op-presence` fills the width it is
   given and squares itself; the caller's container is what decides
   how big the Overseer is, and Lane C owns those containers.

   --op-size is a *ceiling*, not a size. It exists for the one case
   the container does not constrain anything — a presence appended to
   the intro overlay or to document.body — where without it the
   portrait would run the full width of the page. A container narrower
   than the ceiling always wins.

   An earlier version of this file did size itself, with the avatar at
   44px stepping down to 32px on a phone. That was wrong twice over:
   it fought whatever the container wanted, and it was built around
   the old 28px HUD icon. The content in this clip is a 720x720 face
   and a slow push-in, and neither survives being drawn at 32px — at
   that scale the feature is a smudge that flickers. It is designed to
   be read at 96-160px and it is verified at 96, 120 and 160.

   `avatar` and `scene` now differ only in that ceiling and in how
   heavy the ring and the glow are.
   ================================================================= */

.op-presence {
    position: relative;

    /* The container decides. See the header. */
    width: 100%;
    max-width: var(--op-size);

    /* The crop is free, and this is the whole trick.

       The source is 1280x720 but the *content* is a 720x720 square at
       x 280-1000; the rest is black pillarbox, and the Veo watermark
       sits at x 1200-1260 inside the right-hand bar. A 1:1 box with
       object-fit: cover scales the frame to the box's height and
       centres it horizontally, which lands on exactly x 280-1000. The
       watermark and both bars fall outside the box.

       So: no clip-path, no negative margins, no transcode, and no
       magic offsets that would need re-deriving if the asset is ever
       re-rendered at another resolution. `contain` would bring the
       pillarbox back; a 16:9 slot would bring the watermark back. */
    aspect-ratio: 1 / 1;
    overflow: hidden;
    /* The frame is padding, and the square is the OUTER edge — without this
       the padding would be added outside the aspect ratio and the frame
       would make him a rectangle. */
    box-sizing: border-box;

    /* A rounded-corner square, not a circle. The circle read as an avatar
       chip — a contact photo — and he is meant to read as something you are
       looking at THROUGH: a viewport, a monitor, a cell. The radius is a
       proportion of the size rather than a constant so the corner looks the
       same at 96px and at 320px; a fixed radius reads sharp when small and
       soft when large. */
    --op-radius: calc(var(--op-size, 160px) * 0.075);
    border-radius: var(--op-radius);

    /* --- the steel frame -------------------------------------------------
       Brushed metal is a lighting trick, not a texture: a hard bright edge
       where a light source catches the top bevel, a dark edge where the
       bottom one falls away, and a shallow gradient across the face between
       them. Doing it with `border` cannot express that — a border has one
       colour per side — so the frame is padding filled by a background
       gradient, with the bevel drawn as inset shadows.

       Fallback first, then the mixed value: a browser that cannot parse
       color-mix() drops that declaration and keeps this one, rather than
       rendering in the initial (currentColor) colour. Same pattern
       everywhere below. */
    padding: var(--op-frame);
    background: #2a2f38;
    background:
        linear-gradient(145deg,
            #4a515c 0%,
            #767d88 12%,     /* top-left highlight, where the light lands */
            #363c45 38%,
            #2b3038 62%,
            #565d68 88%,     /* a weaker bounce off the bottom-right */
            #333942 100%);

    /* Bevel and seat. The first two inset shadows are the lit and shadowed
       edges of the metal; the third is the recess the media sits in, which
       is what stops the picture looking pasted on top of the frame.

       Held in a custom property because the mood rules below all set
       box-shadow to apply their glow, and box-shadow does not merge — a mood
       that simply assigned its glow would silently flatten the frame. Every
       one of them composes with this instead. */
    --op-bevel:
        inset 0 1px 0 rgba(255, 255, 255, 0.34),
        inset 0 -1px 0 rgba(0, 0, 0, 0.55),
        inset 0 0 0 1px rgba(0, 0, 0, 0.4);
    box-shadow: var(--op-bevel);

    /* It sits in flex rows (the HUD header) next to text that will happily
       squeeze it if allowed. */
    flex-shrink: 0;

    /* The ring, the vignette and the media all overlap; none of them should
       be able to paint over a panel border two pixels away. */
    isolation: isolate;

    transition: border-color var(--transition-base),
                box-shadow var(--transition-base);
}

/* min-width is a floor, not a size. A container that forgets to give this
   a width — a bare div dropped into a flex row, which is exactly what the
   HUD header is — otherwise shrink-to-fits it down to nothing, and the
   failure is silent: a 44px Overseer looks like a deliberate small icon
   rather than like a mistake. The floor is well under any intended size,
   so it never binds in practice; it only stops the silent version. */
.op-avatar {
    --op-size: 160px;
    --op-ring: 2px;
    --op-frame: 7px;
    --op-glow: 18px;
    min-width: 72px;
}

.op-scene {
    /* 72vw keeps it inside a 390px phone with room either side; the fixed
       cap is what it settles to on anything wider. Combined with the
       max-width above, nothing here can cause horizontal overflow at any
       viewport width. */
    --op-size: min(340px, 72vw);
    --op-ring: 2px;
    --op-frame: 10px;
    --op-glow: 34px;
    min-width: 120px;
}

.op-video,
.op-still {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;

    /* The parent's overflow: hidden ought to be enough, and for the still it
       is. It is not reliably enough for the video: a <video> gets promoted to
       its own compositing layer, and a rounded clip on an ancestor is applied
       by the compositor rather than by paint — which it skips often enough to
       matter. Caught in Chromium as square white corners around an otherwise
       circular avatar. Radius on the element itself clips it in paint, where
       nothing can drop it. */
    border-radius: calc(var(--op-radius) - var(--op-frame) * 0.6);

    /* PARALLAX, near layer.
       The clip is flat — one plane, no depth to separate — so the depth is
       manufactured by moving two things at different rates. This is the near
       one: he is scaled slightly past the frame and drifts a fraction of a
       percent, which is only legible as motion RELATIVE to the haze drifting
       the other way behind him. On its own it would read as a wobble.
       The overscale is what gives the drift somewhere to go without exposing
       an edge. */
    transform: scale(1.06);
    animation: opDriftNear 23s ease-in-out infinite alternate;

    transition: filter var(--transition-base);
}

/* The seat: a hard dark line around the picture where it meets the metal,
   so the image reads as set INTO the frame rather than laid on top of it.
   Separate from the frame's own bevel because it follows the smaller
   radius. */
.op-video,
.op-still {
    box-shadow:
        0 0 0 1px rgba(0, 0, 0, 0.85),
        inset 0 1px 3px rgba(0, 0, 0, 0.6);
}

@keyframes opDriftNear {
    from { transform: scale(1.06) translate3d(-0.9%, -0.5%, 0); }
    to   { transform: scale(1.06) translate3d(0.9%, 0.6%, 0); }
}

.op-still {
    /* The fallback is an icon, not footage: `cover` would crop its margins
       off and leave a fragment. It gets `contain` and its own breathing
       room inside the same ring. */
    object-fit: contain;
    padding: 12%;
}

/* The clip is lit at its edges and the circle floats off a dark panel
   without this.

   A radial gradient rather than an inset box-shadow, because the shadow
   needed a blur radius in pixels and the only pixel figure available was
   --op-size, which since it became a ceiling is no longer the element's
   real size. Percentage colour stops track whatever the container gave
   us, so the vignette is identical at 96px and at 320px. */
.op-presence::after {
    content: '';
    position: absolute;
    /* Inset by the frame so the haze sits on the picture, not on the metal —
       a vignette drawn over the bevel would flatten the highlight that makes
       the frame read as steel at all. */
    inset: var(--op-frame);
    border-radius: calc(var(--op-radius) - var(--op-frame) * 0.6);
    pointer-events: none;
    background: radial-gradient(circle at 50% 45%,
                transparent 46%,
                color-mix(in srgb, var(--color-primary-900) 65%, transparent) 100%);

    /* PARALLAX, far layer. Drifts against the face, on a period that does not
       divide evenly into the near layer's — 23s and 31s never come back into
       step, so the pair never resolves into an obvious loop. Slower and
       further than the face, which is the direction real parallax runs. */
    animation: opDriftFar 31s ease-in-out infinite alternate;
}

@keyframes opDriftFar {
    from { transform: translate3d(1.6%, 1.1%, 0) scale(1.04); }
    to   { transform: translate3d(-1.6%, -1.2%, 0) scale(1.10); }
}

/* The pulse ring. Kept on its own layer above the vignette so a pulse
   reads at every mood without the mood having to know about it. */
.op-presence::before {
    content: '';
    position: absolute;
    inset: var(--op-frame);
    z-index: 2;
    border-radius: calc(var(--op-radius) - var(--op-frame) * 0.6);
    border: 2px solid transparent;
    pointer-events: none;
    opacity: 0;
}

/* -----------------------------------------------------------------
   MOODS

   These have to be distinguishable at 44px in the corner of a
   player's eye, so each one moves three things at once — ring
   colour, glow, and how much colour is left in the face. A mood that
   only changed the border would be invisible at avatar size.
   ----------------------------------------------------------------- */

/* Bored: pulled back from full colour. He is present and unimpressed.
   The face is untouched in shape — only its saturation and brightness
   drop — so this reads as disinterest rather than as a dimmed screen. */
.op-mood-bored {
    box-shadow: var(--op-bevel);
}

/* Held deliberately close to neutral. An earlier pass had this at
   brightness(0.82) saturate(0.68), which said "bored" and also said
   "hard to see" — and legibility is the point of the whole presence.
   The mood now lives almost entirely in the ring and the glow; the
   face is only just pulled back from the attentive state. */
.op-mood-bored .op-video,
.op-mood-bored .op-still {
    filter: saturate(0.88) brightness(0.96) contrast(1.04);
}

/* Attentive: full colour, gold ring lit. He is looking at you. */
.op-mood-attentive {
    box-shadow: var(--op-bevel), var(--shadow-glow-gold);
}

.op-mood-attentive .op-video,
.op-mood-attentive .op-still {
    filter: none;
}

/* Hostile: red, hot, and the only mood with a heartbeat of its own.
   The animation is deliberately slower than the video ever moves, so
   the two never beat against each other. */
.op-mood-hostile {
    box-shadow: var(--op-bevel),
                0 0 var(--op-glow) color-mix(in srgb, var(--color-accent-red) 60%, transparent);
    animation: opSeethe 1.9s ease-in-out infinite;
}

.op-mood-hostile .op-video,
.op-mood-hostile .op-still {
    filter: saturate(1.25) brightness(1.06) contrast(1.12);
}

@keyframes opSeethe {
    0%, 100% {
        box-shadow: var(--op-bevel),
                    0 0 var(--op-glow) color-mix(in srgb, var(--color-accent-red) 45%, transparent);
    }
    50% {
        box-shadow: var(--op-bevel),
                    0 0 calc(var(--op-glow) * 1.8) color-mix(in srgb, var(--color-accent-red) 85%, transparent);
    }
}

/* -----------------------------------------------------------------
   PULSE — he has started speaking
   ----------------------------------------------------------------- */

.op-pulse::before {
    border-color: var(--color-accent-gold-light);
    animation: opPulseRing 900ms ease-out 1;
}

.op-pulse .op-video,
.op-pulse .op-still {
    /* Overrides the mood filter for its duration by construction: an
       animated filter beats a declared one. It lands back on the mood's
       own value when the animation is removed. */
    animation: opPulseLift 900ms ease-out 1;
}

@keyframes opPulseRing {
    0%   { opacity: 0; }
    18%  { opacity: 1; }
    100% { opacity: 0; }
}

@keyframes opPulseLift {
    0%   { filter: brightness(1.45) saturate(1.3); }
    100% { filter: none; }
}

/* -----------------------------------------------------------------
   REDUCED MOTION — docs/HUD-LAYOUT.md §7

   The engine already holds a single video frame. What is left here is
   the CSS motion, and the pulse, which still has to be *visible*
   without moving: the ring simply lights and fades on opacity instead
   of flashing, and the face brightens without an animated ramp.
   ----------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
    .op-mood-hostile,
    .op-pulse::before,
    .op-pulse .op-video,
    .op-pulse .op-still,
    /* The parallax is the one effect here that never stops on its own, so it
       is the one that matters most to switch off. Both layers keep their
       overscale and simply hold — dropping the scale as well would let an
       edge show where the drift used to cover it. */
    .op-presence::after,
    .op-video,
    .op-still {
        animation: none;
    }

    .op-mood-hostile {
        box-shadow: var(--op-bevel),
                    0 0 var(--op-glow) color-mix(in srgb, var(--color-accent-red) 70%, transparent);
    }

    .op-pulse::before {
        opacity: 1;
        border-color: var(--color-accent-gold-light);
        transition: opacity var(--transition-slow);
    }
}

/* -----------------------------------------------------------------
   RESPONSIVE

   There is deliberately no breakpoint that shrinks the avatar. The
   container is responsive — this is not, and it should not be, or the
   two would fight and the loser would be a face too small to read.
   .op-scene's ceiling is viewport-relative purely so an unconstrained
   mount cannot overflow a phone; a container narrower than that still
   wins.
   ----------------------------------------------------------------- */

/* =================================================================
   THE FRAME TAKES HIS DEMEANOUR

   The badge says UNIMPRESSED in a word. The steel says it in a colour, so the
   verdict survives the dormant state — where the badge, the name and the
   message are all gone and the frame is the only thing left of him.

   Tinting a metal gradient by rewriting its stops would mean five copies of
   the ramp, each drifting from the others. Instead the tint is a flat layer
   composited over the untouched metal with `background-blend-mode: color`,
   which takes the HUE from the tint and the LUMINANCE from the steel — so the
   highlight still reads as a highlight and the bevel still reads as a bevel.
   One ramp, five colours.

   Set from app.js alongside the life bar's own state class, off the same
   lifeState() call, so the frame and the badge can never disagree.
   ================================================================= */

.overseer-portrait[class*="state-"] .op-presence {
    background-blend-mode: color, normal;
}

.overseer-portrait.state-red .op-presence {
    background-image:
        linear-gradient(var(--color-accent-red), var(--color-accent-red)),
        linear-gradient(145deg, #4a515c 0%, #767d88 12%, #363c45 38%,
                        #2b3038 62%, #565d68 88%, #333942 100%);
}

.overseer-portrait.state-orange .op-presence {
    background-image:
        linear-gradient(var(--color-accent-orange), var(--color-accent-orange)),
        linear-gradient(145deg, #4a515c 0%, #767d88 12%, #363c45 38%,
                        #2b3038 62%, #565d68 88%, #333942 100%);
}

.overseer-portrait.state-yellow .op-presence {
    background-image:
        linear-gradient(var(--color-accent-gold), var(--color-accent-gold)),
        linear-gradient(145deg, #4a515c 0%, #767d88 12%, #363c45 38%,
                        #2b3038 62%, #565d68 88%, #333942 100%);
}

.overseer-portrait.state-lime .op-presence {
    background-image:
        linear-gradient(var(--color-accent-green-light), var(--color-accent-green-light)),
        linear-gradient(145deg, #4a515c 0%, #767d88 12%, #363c45 38%,
                        #2b3038 62%, #565d68 88%, #333942 100%);
}

.overseer-portrait.state-green .op-presence {
    background-image:
        linear-gradient(var(--color-accent-green), var(--color-accent-green)),
        linear-gradient(145deg, #4a515c 0%, #767d88 12%, #363c45 38%,
                        #2b3038 62%, #565d68 88%, #333942 100%);
}
