/* Helvetica Neue, the reference face. One @font-face per REAL cut, so the
   browser never has to synthesise a weight — a synthetic bold smears badly next
   to a genuine one, and a synthetic light is worse, because the rasteriser
   thins strokes it was never given outlines for and the result is the ragged,
   pixelated look that makes people blame the screen.
     Thin   100/200 - the lightest installed cut
     Roman  400     - zone bar, body copy
     Medium 500     - labels wanting a touch more presence
     Bold   700     - vehicle make/model, money, honor
   Italics are still deliberately absent: nothing asks for them, and every file
   in the manifest's files{} is downloaded by each player who connects.
   Add a cut here AND to html/fonts/ AND to fxmanifest's files{} if another
   weight is ever needed — miss any of the three and it silently synthesises. */
/* NOT BUNDLED -- resolved from the player's own machine.
   Helvetica Neue is a commercial Monotype/Linotype face and redistributing the
   .otf files in a public repository is not ours to do, so the four cuts are
   looked up with local() instead of shipped. The family name is unchanged, so
   every font stack further down this file still reads 'HelveticaNeueHUD' and
   nothing else had to move.

   The chain degrades on purpose: the real face on machines that have it
   (macOS ships it; many Windows installs do too), then Helvetica, then Arial
   -- which is metrically compatible with Helvetica, so line lengths and the
   shrink-to-fit measurements hold -- then Liberation Sans, its metric clone on
   Linux. One of those exists everywhere, so the family always resolves.

   The one real loss is the Thin cut: Arial has no 100/200, so a thin weight
   falls back to regular on machines without Helvetica Neue. That affects the
   nav bar and wanted box, both of which are tuned to thin in the shipped
   layout. Bundle the .otf files again locally if you own a licence for them
   and want the exact cut back -- drop them in html/fonts/ and restore the
   url() sources; fxmanifest already globs html/fonts/*.otf. */
@font-face {
    font-family: 'HelveticaNeueHUD';
    src: local('Helvetica Neue Thin'), local('HelveticaNeue-Thin'),
         local('Helvetica Neue Light'), local('HelveticaNeue-Light'),
         local('Arial'), local('Liberation Sans');
    /* Claimed for both 100 and 200 so "Thin" is a real cut at either, rather
       than 100 being real and 200 being a synthesised in-between. */
    font-weight: 100 200;
    font-style: normal;
}
@font-face {
    font-family: 'HelveticaNeueHUD';
    src: local('Helvetica Neue'), local('HelveticaNeue'), local('Helvetica'),
         local('Arial'), local('Liberation Sans');
    font-weight: 400;
    font-style: normal;
}
@font-face {
    font-family: 'HelveticaNeueHUD';
    src: local('Helvetica Neue Medium'), local('HelveticaNeue-Medium'),
         local('Helvetica'), local('Arial'), local('Liberation Sans');
    font-weight: 500;
    font-style: normal;
}
@font-face {
    font-family: 'HelveticaNeueHUD';
    src: local('Helvetica Neue Bold'), local('HelveticaNeue-Bold'),
         local('Helvetica Bold'), local('Arial Bold'), local('Arial'),
         local('Liberation Sans Bold');
    font-weight: 700;
    font-style: normal;
}

/* =============================================================================
   qbcore_vi_vice_hud — GTA6-style HUD
   -----------------------------------------------------------------------------
   Everything lives inside .stage, which spans the FULL viewport — the reference
   anchors its HUD to the physical screen edges, and so does GTA's own minimap,
   so a centred 16:9 stage would displace every panel on an ultrawide.

   All sizes use cqw/cqh (container query units) against .stage. The composition
   is authored against 1920x1080, so at 16:9 every value below is a direct
   percentage of the reference frame. Type is sized in --w (see below) rather
   than raw cqw so it tracks viewport HEIGHT and stays proportionate on wider
   monitors.
   ========================================================================== */

@font-face {
    font-family: 'Pricedown';
    src: url('fonts/GTAArtDecoBold.ttf') format('truetype');
    font-display: block;
}

/* GTA Art Deco. Three real cuts, so Regular/Medium/Bold are separate faces
   rather than one face the browser thickens by hand -- a synthetic bold on
   this typeface smears the same way it would on Helvetica Neue above.
   TrueType, not OpenType — the format() hint has to match or the browser
   rejects the file and falls back without telling anyone.

   Medium used to be declared `font-weight: 500 700`, standing in for a
   proper Bold that had not been sourced yet: any request in that range
   (500 default zone bar, 700 default vehicle-panel make) matched the SAME
   file. Now that a real Bold cut exists, Medium is pinned to its own single
   weight so 700 stops being an alias for Medium and actually resolves to
   Bold instead -- which is also what makes the vehicle panel's make/model
   distinction from #veh-model below actually visible; 700 and Medium had
   too little contrast against each other to read as different weights. */
@font-face {
    font-family: 'GTAArtDeco';
    src: url('fonts/GTAArtDecoRegular.ttf') format('truetype');
    font-weight: 400;
    font-style: normal;
    font-display: block;
}
@font-face {
    font-family: 'GTAArtDeco';
    src: url('fonts/GTAArtDecoMedium.ttf') format('truetype');
    font-weight: 500;
    font-style: normal;
    font-display: block;
}
@font-face {
    font-family: 'GTAArtDeco';
    src: url('fonts/GTAArtDecoBold.ttf') format('truetype');
    font-weight: 700;
    font-style: normal;
    font-display: block;
}

/* Nobody has identified what GTA6 actually sets its scrollable item-select
   lists (the fridge, presumably others) in. Previously stood in with a
   commercial font (Belle Sans Extra Condensed Bold) that turned out to have
   no redistributable tier -- swapped for Plus Jakarta Sans (variable font,
   SIL OFL -- see fonts/PlusJakartaSans-OFL.txt), same face osm-target uses
   for its own UI text, at its heaviest weight for a condensed-ish look at a
   glance. Used with a black outline (see .item-select-outline below),
   because that is what the reference actually draws: white fill, a hard
   black stroke round it, no drop shadow doing the separation instead. */
@font-face {
    font-family: 'PlusJakartaSans';
    src: url('fonts/PlusJakartaSans-Variable.ttf') format('truetype-variations');
    font-weight: 200 800;
    font-style: normal;
    font-display: block;
}

/* The black-outline text treatment itself, kept separate from the @font-face
   above so any element can opt in once the fridge-style list this was
   measured against exists. paint-order swaps stroke-then-fill, or the 2px
   stroke would sit on TOP of the fill and eat into the glyph instead of
   ringing it. */
.item-select-outline {
    font-family: 'PlusJakartaSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    font-weight: 800;
    color: #f2f2ef;
    -webkit-text-stroke: 0.045em #0a0a0a;
    paint-order: stroke fill;
}

/* Design tokens. The reference footage is low-bitrate, so exact radii and
   shadow values cannot be read off it — these are chosen to be consistent and
   clean rather than to replicate compression artefacts. Proportions and colour
   still come from measurement. */
:root {
    --r-sm: calc(0.18 * var(--w));   /* pills, small plates */
    --r-md: calc(0.30 * var(--w));   /* zone bar, wanted box */
    --r-lg: calc(0.62 * var(--w));   /* minimap frame, vehicle panel */

    /* Plate sampled across 113 frames: median #3c393d over typical scenery,
       #6e6b6f over bright. Slightly purple-grey — my old value was too blue.
       These composite to the measured median at normal backdrop brightness. */
    --plate:      rgba(57, 53, 59, 0.88);
    --plate-soft: rgba(50, 47, 53, 0.76);

    /* The MAP PANEL surface -- zone bar, vehicle panel, nav bar.
       Deliberately NOT --plate. Those three sit together in one stack and read
       as one object, so they share a surface; --plate is the warm, slightly
       purple, semi-transparent material the rest of the HUD (wanted box,
       prompts) is made of, and against the reference the three map panels are
       a cooler, flatter, more opaque charcoal with no lit edge. Having them on
       --plate is what made them "not match" the nav bar once that was rebuilt
       to the reference. Keep these three on --panel and everything else on
       --plate; do not merge the two. */
    --panel:      rgba(35, 37, 46, 0.95);
    --r-panel:    calc(0.55 * var(--w));

    --shadow-sm: 0 0.12cqh 0.34cqh rgba(0, 0, 0, 0.40);
    --shadow-md: 0 0.25cqh 0.70cqh rgba(0, 0, 0, 0.46);

    /* The edge every panel surface carries.
       MATCHED TO #map-frame, deliberately: the minimap's border is the one
       edge in this HUD that always read correctly, so it is the reference the
       rest copy -- same near-white, same 0.30 alpha, same 0.085cqw width. An
       earlier pass used rgba(255,255,255,0.10) at 0.055cqw (roughly a third
       the opacity, and thinner), which is why panels next to the map looked
       edgeless by comparison.

       Drawn as an INSET ring rather than a border so it costs no layout width
       -- a panel gains an edge without changing size or shifting its
       neighbours. #map-frame itself keeps a real border, because it frames the
       game's own minimap render rather than a background of its own.

       It is a variable rather than a repeated literal because the ring used to
       be written out inside .plate only: every surface that wasn't .plate
       quietly went without one, and #nav-turn -- which IS .plate -- lost it
       again by setting its own box-shadow over the top. */
    --hairline: inset 0 0 0 calc(0.085 * var(--w)) rgba(236, 236, 240, 0.30);

    --text:      #f4f4f2;
    --text-dim:  #c2c2c6;
    --ink-shadow: 0 0.10cqh 0.22cqh rgba(0, 0, 0, 0.88);

    /* ---- glass -------------------------------------------------------------
       Every panel this HUD puts ON TOP of itself — the editor, the skills
       screen, the level-up card — is the same material, and it is defined once
       here so it stays that way.

       There is no backdrop-filter anywhere in it, and that is not an oversight:
       on a transparent NUI page CEF has no backdrop to sample and rasterises
       the region as opaque black. The whole look is therefore carried by things
       that do not need one — a translucent tint, a bright rim, a specular cap
       and a top-lit sheen. Use .glass rather than reaching for these directly;
       the recipe matters as much as the colours.

       These are deliberately NOT the HUD's own palette. That one is measured
       off the reference footage and describes the game; this describes the
       chrome sitting over it, and the two should not drift into each other. */
    --g-tint:    rgba(20, 20, 26, 0.82);
    --g-rim:     rgba(255, 255, 255, 0.20);
    --g-spec:    rgba(255, 255, 255, 0.42);
    --g-fill:    rgba(255, 255, 255, 0.07);
    --g-fill-hi: rgba(255, 255, 255, 0.13);
    --g-line:    rgba(255, 255, 255, 0.10);
    --g-accent:  #5fd8c8;
    /* The one warm note in the set, for the moment something is EARNED. Kept
       here so it cannot be reinvented slightly differently next time. */
    --g-gold:    #e8c56a;
}

/* -----------------------------------------------------------------------------
   .glass — SUPERSEDED, and nothing uses it
   -----------------------------------------------------------------------------
   Kept as a working recipe rather than deleted, because it is the thing .plate
   below is defined against and the contrast is most of what explains .plate.
   Every surface moved to .plate; if you are restyling, that is the one that
   renders. Do not add a .glass to new markup expecting it to match anything.
   -----------------------------------------------------------------------------
   Two fills (a top-lit sheen over the tint), a 1px rim, and a wide low shadow
   with an inset specular highlight along the top edge. The ::before is a
   specular CAP: a real edge catches light unevenly along its top curve, and a
   uniform border cannot say that on its own. That cap is most of the difference
   between "a pane of glass" and "a grey box with rounded corners".

   Do not flatten the layered fills to one colour to simplify. The flat version
   IS the grey box.
   -------------------------------------------------------------------------- */
.glass {
    position: relative;
    background:
        linear-gradient(176deg,
            rgba(255, 255, 255, 0.13) 0%,
            rgba(255, 255, 255, 0.03) 34%,
            rgba(255, 255, 255, 0.015) 66%,
            rgba(255, 255, 255, 0.06) 100%),
        var(--g-tint);
    border: 1px solid var(--g-rim);
    box-shadow:
        0 2.6cqh 7cqh rgba(0, 0, 0, 0.55),
        0 0.5cqh 1.6cqh rgba(0, 0, 0, 0.34),
        inset 0 1px 0 var(--g-spec),
        inset 0 -1px 0 rgba(255, 255, 255, 0.07);
}
.glass::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    background: radial-gradient(125% 62% at 50% -12%,
        rgba(255, 255, 255, 0.17) 0%, rgba(255, 255, 255, 0) 62%);
}
/* Anything the cap must not sit on top of has to be raised above it. */
.glass > * { position: relative; z-index: 1; }

/* -----------------------------------------------------------------------------
   .plate — the MAP PANEL surface, as a shared class
   -----------------------------------------------------------------------------
   The second of this HUD's two materials, and the counterpart to .glass above.
   Same relationship as the two variable blocks in :root: .glass is the chrome
   the HUD lays over itself, .plate is what the HUD's own panels are made of.

   Lifted out of .slot unchanged so the two cannot drift. The differences from
   .glass are all deliberate and all load-bearing:

     · the measured plate colour, not the near-black tint
     · a hairline INSET ring rather than a 1px border, so it costs no layout
       width — which is what lets a panel keep an exact width while gaining an
       edge
     · #vehicle::after's two-gradient sheen (a top-down wash plus a wide
       diagonal highlight) baked in as background layers
     · NO specular cap. .glass has a radial ::before and this deliberately does
       not; adding one here is the single change that would stop a .plate
       surface reading as the same thing as the zone bar.

   The shadow is --shadow-md rather than .slot's --shadow-sm: a panel floating
   over the whole screen has to sit further off it than one lying on the map.

   Used by the /movehud editor, and matched byte for byte in the three
   own-ui_page resources (ox_target, qb-menu, qb-input) that draw their menus
   next to the map. Change the recipe here and those four have to move together.
   -------------------------------------------------------------------------- */
.plate {
    position: relative;
    background:
        linear-gradient(180deg,
            rgba(255, 255, 255, 0.075) 0%,
            rgba(255, 255, 255, 0.018) 42%,
            rgba(0, 0, 0, 0.10) 100%),
        linear-gradient(118deg,
            rgba(255, 255, 255, 0.00) 34%,
            rgba(255, 255, 255, 0.055) 50%,
            rgba(255, 255, 255, 0.00) 66%),
        var(--plate);
    box-shadow:
        var(--shadow-md),
        var(--hairline),
        inset 0 0.09cqh 0 rgba(255, 255, 255, 0.055);
}
/* Same reason as .glass's: the sheen is a background layer here rather than a
   pseudo-element, but children still need their own stacking to sit cleanly
   over an inset ring that is drawn on top of the background. */
.plate > * { position: relative; z-index: 1; }

* { box-sizing: border-box; margin: 0; padding: 0; }

html, body {
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: transparent;
    /* Humanist grotesque, closest widely-available match to the reference UI. */
    font-family: 'HelveticaNeueHUD', 'Helvetica Neue',
                 'Segoe UI Variable Display', 'Segoe UI', Inter, system-ui,
                 Roboto, Arial, sans-serif;
    /* The source is blurry; ours should not be. */
    -webkit-font-smoothing: antialiased;
    /* geometricPrecision, not optimizeLegibility: every movable element carries
       a scale transform from the editor, and this asks the rasteriser to keep
       glyph outlines exact under it rather than snapping them to a hinted grid
       sized for the untransformed text. optimizeLegibility is a kerning and
       ligature hint that brings its own rasterisation quirks and buys this HUD
       nothing — there is no ligature anywhere in it. */
    text-rendering: geometricPrecision;
    font-variant-numeric: tabular-nums;
    user-select: none;
}

/* --w: one "reference cqw".
   The reference footage is 16:9, where 1cqw == 1.7778cqh. Sizing type in cqw
   means it tracks viewport WIDTH, so on a 21:9 or 32:9 monitor every label grows
   ~35% relative to the 16:9 layout it was measured against — which is why the
   zone bar and vehicle panel read as "way too big" on an ultrawide while looking
   correct in the reference.
   Expressing type in this unit instead makes it track viewport HEIGHT: identical
   to the old values at 16:9, correctly proportioned everywhere else.
   Set --w to 1cqw on .stage to go back to width-relative sizing. */
:root { --w: 1.7778cqh; }

.stage {
    position: fixed;
    inset: 0;
    width: 100vw;
    /* The reference anchors HUD elements to the physical screen edges. Keeping
       this at a centred 16:9 width displaced every NUI element on ultrawide
       displays while GTA's native minimap remained left-anchored. */
    transform: translateX(var(--hud-offset-x, 0px));
    container-type: size;
    pointer-events: none;
}

.hidden { display: none !important; }

/* Shared fade. Elements animate opacity only — never display — so layout
   never jumps as things come and go. */
.fade { transition: opacity 200ms ease; opacity: 1; }
.fade.out { opacity: 0; }

/* -----------------------------------------------------------------------------
   Status bars (top-left)
   Reference: left 1.75%, top 2.05%. Health track is visibly longer than the
   others in the source frames, so the widths differ on purpose.
   -------------------------------------------------------------------------- */
/* Measured off native 1920x1080 video frames:
     icon circle  centre x 2.34%, y 4.12%, diameter 1.17% of width
     bar          left 3.26%, top 3.52%, height 1.30% of height
   The bars are RECTANGLES with a thin darker border and only a hair of corner
   rounding — not pills — and the fills are desaturated and translucent rather
   than saturated. Rows are spaced ~2.9% of height apart. */
#status {
    position: absolute;
    left: 1.755cqw;   /* icon centre 2.34% minus half the icon */
    top: 3.08cqh;     /* icon centre 4.12% minus half the icon (1.17cqw ~= 2.08cqh) */
    display: flex;
    flex-direction: column;
    gap: 1.6cqh;
}

/* A small but real gap -- the actual GTA VI reference (GTAVIHUD.png) has the
   badge sitting clearly separate from the bar, not overlapping it. An
   earlier pass here tried a negative-margin overlap off a much blurrier
   crop and overcorrected. */
.srow { display: flex; align-items: center; gap: calc(0.22 * var(--w)); }

/* Icon: a dark disc with a glyph TINTED to match its bar (pink / teal /
   purple), per the reference stills. */
.sic {
    width: calc((calc(1.17 * var(--w))) * var(--ic-status, 1));
    height: calc((calc(1.17 * var(--w))) * var(--ic-status, 1));
    border-radius: 50%;
    background: rgba(28, 26, 32, 0.72);
    /* The thin ring is what reads as a "badge" rather than a flat disc --
       the reference outlines every icon circle with a faint light edge. */
    border: calc(0.045 * var(--w)) solid rgba(255, 255, 255, 0.22);
    color: #fff;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: calc((calc(0.62 * var(--w))) * var(--fs-status, 1));
    line-height: 1;
    flex-shrink: 0;
}

/* health/stamina/focus now use the actual reference badge art (dark disc +
   glyph already baked in) instead of a tinted emoji glyph over .sic's own
   disc -- so the disc/border/font-size above are switched off here rather
   than doubled up. */
.sic.has-badge { background: none; border: none; }
.sic.has-badge img { width: 100%; height: 100%; border-radius: 50%; display: block; }

/* The stamina bolt in red. While the bar is going down the reference's bolt
   flashes: read frame by frame, it is full red for the first frame or two
   (about 40 ms), then crosses back to teal through pink over the next 170,
   and nothing else about the badge changes -- the disc reads the same before,
   during and after. So this is the same art with only the bolt repainted
   (icons/badge_stamina_red.png, a strong red: rgb(225,36,50), taken from the
   first frame of each flash), laid over the teal one and faded out, which is
   that cross from red to teal. app.js sets --bolt-ms and puts .bolt-flash on
   the row for each flash. .bolt-red is the other setting: red, steadily. */
.sic.has-badge { position: relative; }
.sic.has-badge .sic-alt { position: absolute; left: 0; top: 0; visibility: hidden; }
@keyframes bolt-flash { 0%, 19% { opacity: 1; } 100% { opacity: 0; } }
#s-stamina.bolt-flash .sic-alt { visibility: visible; animation: bolt-flash var(--bolt-ms, 210ms) linear both; }
#s-stamina.bolt-red .sic-alt { visibility: visible; animation: none; opacity: 1; }

/* O2 badge: text glyph, but otherwise .sic's OWN disc-plus-ring (not
   overridden away like a prior version of this did) -- that ring is what
   makes it read as the same badge FAMILY as the has-badge icons above
   rather than a flatter, different-looking disc next to them. Only the
   glyph colour/weight are this badge's own. */
.o2-badge {
    color: #a3c6c4;
    font-weight: 700;
}
.o2-badge sub { font-size: 0.6em; bottom: -0.05em; }

/* Colours sampled across 166 reference frames, taking the BRIGHTEST readings
   per element so a dark backdrop doesn't drag them down (these bars are
   translucent, so every sample is a composite):
     health   #fdccd2   pale pink, NOT a saturated rose
     stamina  #95b8b7   muted teal-grey, NOT a vivid cyan
   Pulled a little back toward saturation from the raw brightest values, which
   sit over bright scenery and read slightly washed. The track IS the "empty"
   colour — no dark trough, no outline. */
/* 68 samples across the reference set: bar height median 16.5px = 1.53% of
   height, left edge 3.50% of width. Both were slightly under. */
/* The reference bar has a clear light OUTLINE around the whole rectangle —
   that is what keeps it readable over bright ground. Without it these pale
   fills wash out completely against grass or concrete, which is why they read
   as "not working". */
.strack {
    /* Height is expressed against the bar's own WIDTH unit, not in cqh, so the
       bar keeps the reference's proportions at every aspect ratio. Measured off
       the reference crop: bar 220px long x 15px tall = 6.82% -- 0.0682 * 9.6cqw
       = 0.655cqw. (An earlier pass measured the same thing as "1.53% of height"
       and it was later overridden with a flat 2.0cqh "chunkier" value, which is
       what made this bar read as a fat strip next to the real one. Mixing cqw
       for length with cqh for height also meant the proportion drifted with the
       display's aspect; it no longer does.) */
    /* 0.626w = 17.5px on the 162px reference bar (image 1 of the training
       request, dark border included): 0.108 of the bar length. Was 0.655. */
    height: calc(0.626 * var(--w));
    /* Squared-off corners -- this used to switch between this and a fully
       rounded pill via /hudbars and a Config.StatusBarShape toggle. Pill was
       removed as a choice; square is now the only shape. Confirmed square in
       the reference crop: the fill starts at the same x on the bar's top row
       as on its middle row, so there is no corner inset to reproduce. */
    border-radius: calc((0.06 * var(--w)) * var(--rad-status, 1));
    overflow: hidden;
    position: relative;
    /* A DARK stroke, not a light one. The reference outlines the bar in a
       near-black rgb(29,26,31) that measures the same over a bright background
       as over a dark one -- i.e. an opaque stroke, not a translucent white
       edge. That outline is most of why the reference bar reads as a solid
       object and this one used to read as a pale smear. Thickness: ~1.5px on a
       15px-tall bar = 10% of bar height = 0.065cqw. */
    border: calc(0.065 * var(--w)) solid rgba(29, 26, 31, 0.95);
    box-shadow: 0 0.08cqh 0.20cqh rgba(0, 0, 0, 0.45);
}

/* NO width transition. Animating width here left the fill stuck at whichever
   value it first received — the inline style kept updating but the rendered
   box never moved. Transform is cheap and reliable, so the bar scales from its
   left edge instead. */

.sfill {
    height: 100%;
    width: 100%;
    /* Deliberately square: .strack has overflow:hidden, so its own corner
       radius clips this already. A radius here would be stretched into an
       ellipse by the scaleX that drives the bar, giving a warped cap at low
       percentages. */
    transform-origin: left center;
    transform: scaleX(1);
    transition: transform 200ms ease;
}

/* NOT repeating ticks — the reference shows a single divider where the fill
   meets the empty portion, so it rides the fill's right edge instead. */
.sfill { position: relative; }
.sfill::after {
    content: '';
    position: absolute;
    top: 0;
    bottom: 0;
    right: 0;
    width: calc(0.09 * var(--w));
    background: rgba(28, 30, 32, 0.70);
}

/* Bar lengths, in --w. FOCUS is the unit: 5.8.
     health   = focus at the stock 200 hp, then LONGER as the Health skill raises
              max health. --hp-len is (maxHp - 100) / 100, set by onStatus():
              1.0 at 200 hp, 1.6 at 260 hp. Only health grows -- stamina and focus
              never change length.
     stamina  = 15% longer than focus (5.8 * 1.15).
     oxygen   = the length of a fully trained health bar, so a diver's air reads
              at the same scale as the longest health bar. --hp-max-len is
              1 + healthMaxBonus / 100 (1.6), sent with every status payload. */
#s-health  .strack { width: calc(5.8 * var(--w) * var(--hp-len, 1)); background: rgba(122, 86, 96, 0.62); }
#s-stamina .strack { width: calc(6.67 * var(--w)); background: rgba(62, 92, 94, 0.62); }
#s-focus   .strack { width: calc(5.8 * var(--w)); background: rgba(78, 70, 116, 0.62); }
#s-oxygen  .strack { width: calc(5.8 * var(--w) * var(--hp-max-len, 1.6)); background: rgba(62, 92, 94, 0.62); }

/* Engaged focus: the eye glows rather than sitting flat, so the one moment
   the world visibly slows down is also the one moment this row visibly says
   why. Pulses instead of holding steady so it reads as "active", not just
   "a different colour". */
#s-focus.active .sic {
    color: #d6c8ff;
    text-shadow: 0 0 calc(0.5 * var(--w)) rgba(189, 170, 230, 0.85);
    animation: focus-pulse 1.1s ease-in-out infinite;
}
/* text-shadow above does nothing on an <img> -- drop-shadow is the
   img-compatible equivalent, so the badge still glows rather than just
   pulsing opacity with the colour cue gone. */
#s-focus.active .sic.has-badge img {
    filter: drop-shadow(0 0 calc(0.5 * var(--w)) rgba(189, 170, 230, 0.85)) brightness(1.15);
}
@keyframes focus-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.55; }
}

/* A fixed marker at the exact middle of the health bar, matching the GTA VI
   reference pixel-for-pixel: a plain neutral two-tone line (dark line, white
   hairline beside it via box-shadow), always shown, not tied to the actual
   hunger/thirst cap value -- by explicit request, this no longer reflects
   game state, just the reference's fixed placement. The hunger/thirst
   mechanic itself (readNeeds/enforceCap in client.lua) is untouched and
   still blocks passive regen; it just no longer has an on-screen marker.

   A THIN fixed-width box, not the old scaleX-driven full-width one -- that
   version's box-shadow duplicated the whole wide box instead of a hairline,
   which is why the white side never showed, only the border-left did. */
.scap {
    position: absolute;
    top: 0;
    bottom: 0;
    /* Half of the STOCK bar (5.8 * --w), from the track's inner edge -- NOT 50%.
       The bar grows to the right as max health rises, and the marker stays where
       it was: it marks the stock bar's midpoint, so it reads as a fixed landmark
       the extra length extends beyond. (Minus the track's own 0.065 border, since
       `left` is measured from the padding edge.) */
    left: calc(2.9 * var(--w) - 0.065 * var(--w));
    /* WHITE core with a dark line either side -- measured straight off the
       reference crop, which runs dark / white / dark left-to-right across a
       220px bar: ~2px each, i.e. 0.9% of bar length = 0.087cqw per line. This
       used to be a dark core with white on its LEFT only, and at 0.16cqw each
       (nearly double), which is why it read as a fat two-tone slab rather than
       the reference's fine three-line tick.

       Both flanks come from one box-shadow with two offsets rather than
       borders, so the marker stays exactly centred on the 50% mark and the
       flanks cannot change its layout width. */
    width: calc(0.087 * var(--w));
    margin-left: calc(-0.0435 * var(--w));
    background: rgb(255, 255, 255);
    box-shadow: calc(-0.087 * var(--w)) 0 0 0 rgb(29, 26, 31),
                 calc(0.087 * var(--w)) 0 0 0 rgb(29, 26, 31);
    pointer-events: none;
}

/* Measured across 1053 fill pixels of the reference crop: rgb(245,172,198).
   The old #f6bcc6 was the same hue but 16 points lighter in green, which is
   exactly the "washed out" difference -- the reference pink is more saturated,
   not a different colour. The heart glyph samples identical to the fill in the
   reference, so .sic above carries the same value. */
#s-health  .sfill { background: #f5acc6; }
#s-stamina .sfill { background: #a3c6c4; }
#s-focus   .sfill { background: #bdaae6; }
/* Same teal as stamina, by explicit request -- not the mint from the
   reference crop. */
#s-oxygen  .sfill { background: #a3c6c4; }

/* -----------------------------------------------------------------------------
   Top-right: wanted stars, the three "tells", money
   -------------------------------------------------------------------------- */
/* Measured: star centres at y 3.98%, 1.9% apart; rightmost star's right edge
   1.87% from the screen edge; each star ~1.1% of width. The equipped-weapon
   icon sits below them at top 6.5%, right 1.77%, ~4.7% wide. */
/* Medians across 124 reference frames (every video, one frame per 4s), not a
   single hand-picked frame — an outlier frame had the tells reading 1.09%
   right, where the median is 1.82%.
     stars  w 1.15%  spacing 1.88%  right 1.93%  top 2.96%
     tells  w 1.11%  spacing 1.76%  right 1.82%  top 5.14%
   Star bottom lands at ~5.0%, so the rows sit almost flush — the old 1.55cqh
   gap pushed the tell row far below where it belongs. */
/* Re-measured at 1080p on the clips where the whole column is up at once
   (two stars, three tells, ammo, the weapon, one money row), and set as the
   BASE here rather than reached through editor multipliers:
     stars   24.4px, 36.9px apart, centre 43.6px down
     tells   25px discs, 34px apart, centre 80px down
     ammo    digits 21.3px tall, 17.8px between clip and reserve, centre 117px
     weapon  63.5 x 41px, centre 165px
     money   31.5px disc, digits 22.5px tall, 11.6px between them, rows 39px
             apart; centre 214px under the weapon, 52px when alone
   and one right edge for all of it, 33px in. */
#topright {
    position: absolute;
    right: 1.72cqw;
    top: 2.91cqh;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 0.14cqh;
}

/* Measured off the reference by pixel analysis, not by eye:
     star     24.5px wide  = 1.28% of width
     spacing  35.5px       = 1.85% centre-to-centre  => gap 0.57%
     right    36px         = 1.88%
     top      32px         = 2.96% */
#stars { display: flex; gap: calc(0.6525 * var(--w)); }

.star {
    width: calc((calc(1.271 * var(--w))) * var(--ic-topright, 1));
    height: calc((calc(1.271 * var(--w))) * var(--ic-topright, 1));
    display: block;
    overflow: visible;
}
/* Unearned stars are hollow outlines; earned ones fill red. */
/* stroke-width 9 with paint-order:stroke painted the outline OUTSIDE the
   viewBox, so a 1.1cqw star rendered nearer 1.6%. A thinner, inside-painted
   stroke matches the reference's fine hollow outline. */
.star path {
    fill: none;
    stroke: #e8e8e6;
    stroke-width: 6;
    stroke-linejoin: round;
}
/* Four earned-star states, straight off the reference frames, see
   renderStars()'s comment in app.js for what each actually means. Literal
   colours, not the pink/teal character accent (that's the waypoint/nav bar's
   own thing, not the stars'): white for contact, red for red. */
.star.contact path { fill: #f2f2f0; stroke: #f2f2f0; }
.star.red path { fill: #d4565c; stroke: #f2f2f0; }
/* Brighter, heavier stroke than a plain unearned star, still no fill, but
   distinguishable up close, for stars the wanted level has earned while
   police still don't know who did it. */
.star.hollow path { stroke: #f2f2f0; stroke-width: 8; }
/* No state: an earned star, solid white and still, for a player with HUD
   OPTIONS' VI WANTED STARS off. The box's star takes the same class. */
.star.plain path { fill: #f2f2f0; stroke: #f2f2f0; }

/* Measured: plate 18.5px = 0.98% wide, ~24px = 2.2% tall, 31.5px = 1.64%
   centre-to-centre => gap 0.66%. Right margin is 1.09%, NOT the 1.87% the
   stars use — the tell row sits further right, so it pulls itself out of the
   shared column. */
#tells {
    display: flex;
    gap: calc(0.4725 * var(--w));
    /* The stars and the tell row are separate groups in the reference, with a
       clear break between them — measured at ~0.45x a star's height across two
       reference frames. #topright's shared 0.14cqh row gap alone left them
       almost touching. */
    margin-top: 0.93cqh;
    /* Tell row sits a hair further right than the stars (1.82% vs 1.93%).
       Left as raw cqw, NOT converted to --w: unlike the gap/margin-top above,
       this is a horizontal NUDGE of the row's position relative to the shared
       #topright column, not the row's own size -- the same "edge-anchored
       position, leave alone" reasoning the left/right/top offsets elsewhere
       get, even though it is spelled as a margin rather than an inset. */
    margin-right: 0;   /* re-measured: one right edge for the whole column */
}

/* Measured off the reference: ~0.88% of width by ~1.94% of height, so a
   PORTRAIT rounded square, not a circle. Red plate, white line-art. */
.tell {
    /* Circles, per the GTA6 footage. Both axes use the same unit — the previous
       1.11cqw x 2.31cqh could only be round at one specific aspect ratio. */
    width: calc((calc(1.3 * var(--w))) * var(--ic-tells, 1));
    height: calc((calc(1.3 * var(--w))) * var(--ic-tells, 1));
    border-radius: 50%;
    /* Matched to the plate red baked into the cropped reference PNGs
       (weapon/person/people/vehicle, see TELL_IMG in app.js) so their
       transparent edges blend with no fringing. Was #c0393f. */
    background: #682425;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
/* All six are cropped straight out of the actual reference screenshots (see
   TELL_IMG in app.js) -- object-fit: contain since their source crops aren't
   square. */
.tell img {
    width: 74%;
    height: 74%;
    object-fit: contain;
    display: block;
}

/* Money. Cash and bank on their own rows, amounts right-aligned against the
   icon so the digits line up as the value changes. */
#money {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 0.45cqh;
    /* Alone in the corner the first disc is centred 52px down, which the
       column's own row gaps already give it. */
    margin-top: 0;
}
.money-row { display: flex; align-items: center; gap: calc(0.6 * var(--w)); }

.money-amt {
    /* Pinned, not tunable. Money is Pricedown — GTA's own display face — and
       the editor deliberately offers no Font row for this element, so reading a
       variable here would be a hook nothing can ever set. */
    font-family: 'Pricedown', 'Segoe UI', Arial, sans-serif;
    /* 1.62cqw rendered noticeably larger than the reference, which measures
       ~1.2% of screen width for the amount text. */
    font-size: calc((3.23cqh) * var(--fs-money, 1));
    color: var(--text);
    text-shadow: 0 0.12cqh 0.26cqh rgba(0, 0, 0, 0.92);
    letter-spacing: var(--ls-money, 0.025em);
    line-height: 1;
    /* Digits are tabular so the string doesn't jitter as the amount changes. */
    font-variant-numeric: tabular-nums;
    /* Pricedown's digits sit below the middle of its line box; this puts
       their centre on the disc's, as the reference has them. */
    transform: translateY(-0.075em);
}

.money-ic {
    width: calc((2.9cqh) * var(--ic-money, 1));
    height: calc((2.9cqh) * var(--ic-money, 1));
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8));
}
.money-ic svg, .money-ic img { width: 100%; height: 100%; display: block; object-fit: contain; }
.money-ic rect, .money-ic circle { fill: currentColor; }
.money-ic .hole { fill: rgba(0, 0, 0, 0.38); }
.money-ic .stripe { fill: rgba(0, 0, 0, 0.42); }

/* money-ic-cash/-bank have no color rule -- both are PNGs, not currentColor shapes. */
.money-ic-duffle { color: #3ddbe0; }   /* cyan duffle bag */
.money-ic-chips  { color: #c9a13b; }   /* gold chip stack */

/* Cash and BuckMe's rings are layered over their flat PNGs rather than baked
   in, so they can be real meters -- see onCash() in app.js, which fills both
   off cash / Config.CashCap. They're the actual reference ring art: a
   33-frame white mask sequence (icons/buckme_ring/radial_ring_00..32.png,
   one per ~3% step, shared by both) cut out of a solid colour tile via CSS
   mask, rather than a hand-drawn arc -- onCash() just swaps which frame is
   the mask. Frame 00 is empty, so nothing needs to render before the first
   payload arrives. */
.money-ic-cash, .money-ic-bank { position: relative; }
#cash-ring-fill, #buckme-ring-fill {
    position: absolute;
    inset: 0;
    background-size: cover;
    -webkit-mask-image: url('icons/buckme_ring/radial_ring_00.png');
    mask-image: url('icons/buckme_ring/radial_ring_00.png');
    -webkit-mask-size: contain;
    mask-size: contain;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: center;
    mask-position: center;
}
#cash-ring-fill  { background-image: url('icons/fill_green.png'); }
#buckme-ring-fill { background-image: url('icons/fill_purple.png'); }

/* Ammo readout, now its own /movehud element (#ammo-wrap) rather than sharing
   #topright's position and Font/Icon size rows with the wanted stars, so
   --fs-ammo/--ic-ammo/--ff-ammo below, not the old --fs-topright/--ic-topright
   the stars still use. #ammo-wrap carries the position/scale/opacity that
   used to come from #topright alone; this rule only lays its two children out.
   Measured: text spans 4.3% of width, cap height ~1.76% of height, clip and
   reserve separated by ~0.78%. Pricedown is GTA's own display face and
   matches the blocky condensed numerals in the frames. */
#ammo-wrap {
    display: flex;
    flex-direction: column;
    gap: 0.14cqh;
}
#ammo {
    display: flex;
    align-items: baseline;
    /* Puts the digits' own centre 117px down at 1080p (Pricedown's digits sit
       low in its line box). */
    margin-top: 0.41cqh;
    gap: calc(0.93 * var(--w));
    font-family: var(--ff-ammo, 'Pricedown', 'Segoe UI', Arial, sans-serif);
    line-height: 1;
    color: #d8d8d4;
    text-shadow: 0 0.15cqh 0.2cqh rgba(0, 0, 0, 0.9), 0 0 0.15cqh rgba(0, 0, 0, 0.8);
}
#ammo-clip { font-size: calc((3.03cqh) * var(--fs-ammo, 1)); color: #f0f0ec; }
#ammo-reserve { font-size: calc((3.03cqh) * var(--fs-ammo, 1)); color: #cfcfca; }

/* Measured 63x43px at 1920x1080 => 3.28% wide, 3.98% tall. */
#weapon-icon {
    width: calc((calc(3.28 * var(--w))) * var(--ic-ammo, 1));
    height: auto;
    max-height: calc((3.98cqh) * var(--ic-ammo, 1));
    object-fit: contain;
    filter: drop-shadow(0 0.15cqh 0.25cqh rgba(0, 0, 0, 0.85));
    /* Centre 165px down under the ammo, and the money row 49px below that. */
    margin-top: 1.14cqh;
    margin-bottom: 0.84cqh;
}

/* -----------------------------------------------------------------------------
   Minimap slot — the strip directly above the native minimap.
   Occupants stack UPWARD from the minimap's top edge.
   -----------------------------------------------------------------------------
   THE PANEL STACK NO LONGER TRACKS THE MAP.

   It used to read --map-left / --map-width / --map-bottom directly, so the two
   panels were the minimap's left edge and width by construction. That is a fine
   DEFAULT and a bad rule: scaling the map in /movehud dragged the panels along
   with it and resized them, and there was no way to hold them still short of
   overriding the whole published map rect (which also moves the frame).

   So the stack now reads its OWN three variables, and falls back to the map's
   only when they are unset. app.js writes them once, from the first map rect of
   the session — a SNAPSHOT, not a subscription — so an untouched layout still
   lands exactly on the map's edge and then stays there while you resize.
   Setting any Panel row in the editor, or turning Follow map on, replaces the
   snapshot; see PANEL_PROPS in app.js.

   The frame (#map-frame) and the centre cross deliberately still track the map
   live. They are drawn ON the map and are wrong the instant they do not. */
#slots {
    position: absolute;
    left: var(--panel-left, var(--map-left, 1.75cqw));
    width: var(--panel-width, var(--map-width, 15.8cqw));
    bottom: var(--panel-bottom, var(--map-bottom, 17.9cqh));
    display: flex;
    flex-direction: column-reverse; /* first child sits closest to the map */
    /* 8 to 9 px at 1080 between every item in the reference stack: turn bar to
       map, street label to turn bar, icon strip to street label. */
    gap: 0.8cqh;
}

/* Whenever the native radar isn't drawn -- on foot with the map hidden
   (`/hudminimap`), dead, the pause menu, a full inventory, the character
   switcher -- app.js's applyMapChrome() adds this. #slots is normally
   anchored to a SNAPSHOT of the map's rect (the comment above explains why),
   which is exactly the wrong place to be once there is no map there to sit
   beside: the zone bar/vehicle panel just floated wherever that rect
   happened to fall, often nowhere near a screen edge at all. Docked to the
   bottom centre instead, so a live "you just entered a new zone" / vehicle
   status notification always has a sane, predictable home even with the map
   off. `left` stays at 50% rather than following --panel-left, but width and
   the editor's own offset/scale nudge (below, in the layout block) still
   apply on top -- someone who tuned Map panels in /movehud keeps that tuning
   relative to this dock instead of losing it. */
#slots.map-hidden {
    left: 50%;
    bottom: 2.4cqh;
}

.slot {
    width: 100%;
    background: var(--panel);
    border-radius: calc((var(--r-panel)) * var(--rad-slots, 1));
    overflow: hidden;
    /* A plain drop shadow and NO inset ring. There used to be a fine lit edge
       here, read off compressed reference frames as a highlight; against the
       cleaner reference shots it is not there, and it was the second thing
       (after the warm --plate fill) making these panels look like a different
       component from the nav bar they stack with. */
    box-shadow: var(--shadow-md), var(--hairline);
}
/* BOTH nav elements are .slot ONLY so they inherit the fade behaviour. Neither
   is a panel: #nav-popup is a transparent flex wrapper whose child #nav-turn
   carries the surface, and #nav-compass is a bare text label sitting on the
   map. Without this reset .slot's `width: 100%` + panel fill turn #nav-compass
   into a dark band spanning the whole screen across the minimap, and give
   #nav-popup a second plate one radius step larger than the bar in front of
   it. If any other non-panel element is ever given .slot for the fade, it
   needs to be added here too. */
#nav-popup, #nav-compass {
    background: none;
    box-shadow: none;
    overflow: visible;
}
#nav-compass { width: auto; }

/* -----------------------------------------------------------------------------
   Map panel arrival
   -----------------------------------------------------------------------------
   The two map panels arrive the way a phone announces a message: a springy rise
   into place rather than a hard cut. Only the ARRIVAL is animated — going away
   stays the instant hide it always was, which is what the panels' own timers
   already do.

   The overshoot is in the easing, not in the keyframes. A cubic-bezier whose
   second control point sits above 1 carries the value past its target and lets
   it settle back, which is the whole character of the motion; expressing the
   same thing as extra keyframe stops would double-count it the moment anyone
   adjusted the curve.

   transform-origin is the BOTTOM edge, because the panels stack upward off the
   minimap — growing from the bottom reads as rising out of it, growing from the
   centre reads as a box inflating in mid-air.
   -------------------------------------------------------------------------- */
@keyframes slotIn {
    from { opacity: 0; transform: translateY(115%) scale(0.94); }
    to   { opacity: 1; transform: translateY(0)    scale(1); }
}
/* Leaving is a plain fade — no travel, deliberately. The panel has said what it
   had to say and should stop competing for attention; sliding it away is a
   second piece of motion for an event that no longer matters. Slower than the
   arrival and eased so most of the opacity goes late, which is what reads as
   graceful rather than as a light being switched off.

   Keep the duration in step with SLOT_OUT_MS in app.js: that timer is what
   swaps in `display: none` afterwards, and if it fires early it cuts the fade
   off part-way. */
@keyframes slotOut {
    from { opacity: 1; }
    to   { opacity: 0; }
}
.slot { transform-origin: 50% 100%; }
.slot.slot-in {
    animation: slotIn 520ms cubic-bezier(0.16, 1.28, 0.38, 1) both;
}
.slot.slot-out {
    animation: slotOut 420ms cubic-bezier(0.4, 0.0, 0.7, 0.2) both;
    /* Nothing should be clickable while it is on its way out. The stage is
       already pointer-events:none, so this is only insurance against a future
       panel that is not. */
    pointer-events: none;
}
/* Motion is the entire point of these, so there is nothing to soften — the
   reduced-motion answer is to arrive and leave with no travel and no fade. */
@media (prefers-reduced-motion: reduce) {
    .slot.slot-in, .slot.slot-out { animation: none; }
}

/* Zone bar */
#zone {
    /* Measured: 40 px tall at 1080, fill #1e1a25 with a thin #37303a border,
       corners about 6 px (that is the stack's own radius; see the layout). */
    height: 3.7cqh;
    display: flex;
    align-items: center;
    padding: 0 calc(0.85 * var(--w));
    background: rgba(30, 26, 37, 0.96);
    box-shadow: var(--shadow-md), inset 0 0 0 0.12cqh #37303a;
}
#zone.water { background: rgba(46, 116, 178, 0.88); }
#zone-text {
    /* Every one of these falls back to the PANEL PAIR's value, so a layout
       saved while these were one element still renders identically. Set the
       Zone bar row and it takes over for this panel alone. */
    font-family: var(--ff-zone, var(--ff-slots, inherit));
    /* Kept in step with .vname's 0.75. These two panels sit one on top of the
       other and are read as one object, so a zone name set half again as
       large as the vehicle name above it reads as a mistake even when each
       is defensible on its own. At 1.15 the label also filled two thirds of
       the bar's own height, which is what made the bar look stuffed. */
    /* 16 px caps at 1080 in the reference, mixed case, regular weight. */
    font-size: calc((calc(1.19 * var(--w))) * var(--fs-zone, var(--fs-slots, 1)) * var(--fit, 1));
    font-weight: var(--fw-zone, var(--fw-slots, 400));
    letter-spacing: var(--ls-zone, var(--ls-slots, 0.004em));
    color: #e1d6f8;
    text-shadow: var(--ink-shadow);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Vehicle make/model panel */
#vehicle {
    /* Measured off the reference: a SQUARE plate with no border, fill #181722,
       73 px of names over a 40 px icon strip at 1080. (It used to share the
       stack's rounded corner and hairline; the street label and the turn bar
       are rounded in the reference, the name plate is not.) */
    border-radius: 0;
    background: rgba(24, 23, 34, 0.96);
    box-shadow: var(--shadow-md);
    /* The plate's texture and its manufacturer tint are both drawn as
       pseudo-elements rather than as this element's background-image, so they
       can be stacked in a fixed order — see the manufacturer tint block below
       for why that order is the whole point. `position` is what the two of them
       are positioned against. */
    position: relative;
    /* min-height, not height.
       The panel's height is in cqh (viewport height) while .vname's font-size is
       in cqw (viewport WIDTH), so the wider the monitor the larger the text gets
       relative to this box. At 2.4:1 the make/model plus the pip row no longer
       fit, and the top of the make was being clipped. A minimum lets the panel
       grow with its content; at 16:9 it is unchanged. */
    min-height: 10.46cqh;
    display: flex;
    flex-direction: column;
    /* No padding and no gap here any more: the plate is now two BANDS that meet
       at the barrier, and either of those would put a strip of plate between
       them that the barrier could not span. The head carries the padding
       instead. */
    padding: 0;
}
.vname {
    /* The reference sets the make and model in a bold CONDENSED face, the same
       condensed DIN as the race UI, not in the stack's display face. Bahnschrift
       at its narrowest is the nearest thing Windows ships. */
    font-family: var(--ff-vehicle, 'Bahnschrift', 'DIN Condensed', 'Oswald', 'Arial Narrow', sans-serif);
    font-stretch: 75%;
    /* Even at its narrowest Bahnschrift is wider than the reference's cut:
       measured against the footage at the same cap height, "ANNIS" and
       "YAGO'S '90 ELEGY" come out 19% and 12% too wide. 0.85 across splits
       the two without touching the height. */
    transform: scaleX(0.85);
    /* --fit is the shrink-to-fit factor, written by app.js when the text is
       wider than the panel. It multiplies rather than replaces, so it composes
       with whatever font size was tuned here. */
    /* 0.75, down from 1.18. Measured against the reference rather than
       chosen: there the make spans about 37% of the panel's width, and at
       1.18 it spans 59% -- which is why the panel was also coming out too
       TALL, the name block simply not fitting inside its min-height and
       pushing the plate open. Smaller type fixes the proportion and the
       height together, and gives --fit more room before a long name has to
       be shrunk at all. */
    /* 18 px caps at 1080 in the reference: 1.34 of the unit in this face. */
    font-size: calc((calc(1.34 * var(--w))) * var(--fs-vehicle, 1) * var(--fit, 1));
    color: var(--text);
    letter-spacing: var(--ls-vehicle, 0.02em);
    text-shadow: var(--ink-shadow);
    text-transform: uppercase;
    line-height: 1.1;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 100%;
    /* NOTE: no font-weight here on purpose -- see #veh-make/#veh-model
       below. It used to read `var(--fw-vehicle, var(--fw-slots, 700))`, the
       same shared variable the Vehicle panel's Weight row in /movehud
       writes -- so a server that had ever tuned that row (this one had:
       published layout.json carries `vehicle.fw: 400` from before the make
       and model were split apart) pulled BOTH lines down to whatever was
       last saved, and the two rules below asking for two different weights
       made no visible difference because they were reading a variable that
       was no longer 700 to begin with. Make and model are deliberately
       fixed weights now, decoupled from that variable and from each other,
       so a stale --fw-vehicle can't quietly re-match them again. */
}
/* Bold vs Regular, not "whatever --fw-vehicle happens to be this week" --
   see the note on `.vname` above for why reading that shared variable
   stopped working the moment either line needed a DIFFERENT weight from
   the other. GTAArtDeco's three real cuts are declared in the @font-face
   block up top; these ask for two of them by name via a fixed weight, not
   a synthetic in-between the rasteriser would have to fake. */
/* Same face, same size, same weight for both lines in the reference; what tells
   them apart is colour: the make near white, the model grey. */
#veh-make  { font-weight: var(--fw-vehicle-make, 700); color: #f6e8fb; }
#veh-model { font-weight: var(--fw-vehicle-model, 700); color: #aba2b1; }

/* The tracker / engine / fuel / nitrous pips are their own editor element ("Vehicle icons"),
   because scaling them from the panel alone looked wrong: the discs grew and
   the GAP between them did not, so three round icons turned into three round
   icons crowded against each other. Their own spacing, position and scale fix
   that; --sp- is the gap multiplier. */
#veh-pips {
    display: flex;
    gap: calc(0.29 * var(--w) * var(--sp-vehpips, 1));
    /* No margin: the foot band's own padding is the separation now, and a
       margin here would push the discs off centre inside it. */
    margin-top: 0;
}
.pip {
    /* Falls back to the PANEL's icon size, so a layout saved before these had
       their own row keeps rendering exactly as it did. Once the Vehicle icons
       row is touched it takes over, rather than multiplying with the panel and
       giving one visual size two places to come from. */
    width: calc((calc(1.667 * var(--w))) * var(--ic-vehpips, var(--ic-slots, 1)));
    height: calc((calc(1.667 * var(--w))) * var(--ic-vehpips, var(--ic-slots, 1)));
    border-radius: calc(50% * var(--rad-vehpips, 1));
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    /* Still a font-size even though the glyphs are SVG, because the SVG is
       sized in `em` below. That keeps the editor's Font size row meaning "how
       big is the glyph" and its Icon size row meaning "how big is the disc",
       which is the split those two rows had when these were emoji. */
    font-size: calc((calc(0.68 * var(--w))) * var(--fs-vehpips, var(--fs-slots, 1)));
    line-height: 1;
    /* DARK disc, coloured ink. The state used to be the disc's fill with a dark
       glyph on top, which cannot carry a gauge ring: a coloured ring on a
       coloured disc has nothing to read against. Moving the colour into the ink
       and the ring leaves the disc as a constant dark backing for both. */
    background: rgba(16, 15, 18, 0.62);
    color: var(--tone);
    --tone: rgba(206, 206, 212, 0.82);
}
/* Sampled off the reference: a muted green, an amber, and a pink-red rather
   than a pure one. They were brighter and more saturated than the footage. */
.pip.good    { --tone: #6a9a6c; }
.pip.warn    { --tone: #d9a038; }
.pip.bad     { --tone: #b85a68; }
.pip.unknown { --tone: rgba(206, 206, 212, 0.82); }

/* -----------------------------------------------------------------------------
   Pip art: one SVG per pip
   -----------------------------------------------------------------------------
   The ring and the glyph of a pip are drawn inside ONE svg that fills the
   disc, 36 units across with its centre at (18, 18).

   They used to be two elements: the ring an SVG laid over the disc, the glyph
   a second box that flexbox centred inside it. That is centred as far as the
   layout is concerned and not on screen, because the browser paints every
   element on whole pixels and rounds each one on its own. A disc is under
   thirty pixels across, so a glyph landing a pixel to one side of its ring is
   three percent of the icon, and which side it lands on changes with the
   resolution. One coordinate system has no second box to round.

   Not emoji, either. An emoji is drawn by the platform's own font, and
   Windows renders the padlock orange and the pump red whatever `color` says.
   Everything here reads `currentColor`, so the tone classes above are
   the only thing deciding what colour a pip is.
   -------------------------------------------------------------------------- */
.pip-art {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
    fill: none;
    overflow: visible;
}

/* The glyph. Its place and base size are in the markup, as a transform that
   puts the middle of the art on the centre of the disc (see index.html).
   This rule is only the editor's handle on it.

   The Vehicle icons element has two size rows: Icon size scales the disc and
   Font size scales the glyph inside it. The disc is the SVG's box, so growing
   the disc grows the glyph with it; dividing one by the other here is what
   keeps the Font size row meaning "how big is the glyph" on its own. Both
   unset is a scale of 1. */
.pip-glyph {
    transform-origin: 18px 18px;
    transform: scale(calc(
        var(--fs-vehpips, var(--fs-slots, 1)) / var(--ic-vehpips, var(--ic-slots, 1))));
}

/* The tracker is the reference's mark: a SOLID pin standing on a small ring.
   The hole in the pin's head is cut out of the path itself (fill-rule
   evenodd), so it shows whatever is behind the pip rather than a colour the
   icon would have to be told; the ring it stands on is a stroked ellipse in
   the markup. */
#pip-tracker .pip-glyph { fill: currentColor; }

/* On the plate the strip is the backing. The reference draws the icons
   straight onto it, a glyph inside an arc with no disc of its own; the dark
   discs belong to the collapsed row, where there is nothing else behind them. */
#vehicle:not(.collapsed) .pip { background: none; }

/* Engine and fuel are the reference art itself (see README credit), stripped
   of its own baked-in disc and used as a mask over a currentColor rect inside
   the SVG, so the shape ships exactly as drawn rather than re-traced and
   still takes the tone. Nothing to style: the mask and the fill are both in
   the markup. Nitrous is plain filled paths. */
#pip-nitrous .pip-glyph { fill: currentColor; }

/* -----------------------------------------------------------------------------
   The gauge rings
   -----------------------------------------------------------------------------
   Engine, fuel and nitrous fill a ring around the disc to their real reading,
   so the panel answers "how much" rather than only "is it bad yet". The
   tracker has no quantity and deliberately has no ring.

   --pct is written by app.js, 0..100. pathLength="100" on the circle relabels
   its own length as 100 units, so the dash offset IS the percentage and nothing
   here has to know the radius. The ring group is turned a quarter back in the
   markup so the fill starts at the top and runs clockwise, like every gauge
   anyone has ever read; a circle starts at three on the clock face.
   -------------------------------------------------------------------------- */
/* The reference draws NO track: an 80% reading is an 80% arc with nothing in
   the gap. The element stays (a transparent stroke) so the ring keeps one
   geometry whether or not a track is ever wanted back. */
.pip-track {
    stroke: transparent;
    stroke-width: 2;
}
.pip-fill {
    stroke: var(--tone);
    /* 2 of the disc's 36 units: the reference ring is under 2 px on a 32 px
       disc. It was 3.1, which read as a border rather than a gauge. */
    stroke-width: 2;
    stroke-linecap: round;
    stroke-dasharray: 100;
    stroke-dashoffset: calc(100 - var(--pct, 0));
    /* Fuel drains and damage accumulates over seconds, not frames, and the poll
       that feeds this runs four times a second -- without the transition the
       ring visibly steps. */
    transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
    .pip-fill { transition: none; }
}
/* At zero, `round` linecaps still paint a dot at twelve o'clock -- an empty
   tank would show a pip of fuel. */
.pip-fill[data-empty="1"] { stroke-linecap: butt; }

/* -----------------------------------------------------------------------------
   Nitrous
   -----------------------------------------------------------------------------
   The fourth pip, and the only one that is a CONTROL rather than a warning: the
   ring is the tank, and it is on screen for as long as the car has a kit, full
   or not, because a full tank is exactly the reading you want before you press
   the button. So it has a tone of its own rather than borrowing good/warn/bad
   -- an ice blue that is not on the traffic-light scale the other three share,
   and therefore cannot be misread as "the nitrous is healthy".

     .nitro     kit fitted -- armed or burning, the pip looks the same and the
                ring is what tells you which
     .unknown   locked out after running dry: the ring still shows the tank
                refilling, in the same grey a parked car's gauges use

   THE RING IS THE ONLY THING THAT MOVES. No pulse, no glow, no surge on the
   glyph while boosting: in the reference the mark sits perfectly still and the
   ring drains around it, and a second moving thing next to a gauge just makes
   the gauge harder to read. The ring emptying IS the boost indicator.
   -------------------------------------------------------------------------- */
.pip.nitro { --tone: #c4e0f0; }

/* The tank moves in a couple of seconds, not the minutes fuel takes, and is fed
   faster than the status poll -- 260ms of easing would have the ring visibly
   trailing the boost it is supposed to be showing. Dash offset ONLY: the
   base rule also eases the stroke colour, and here a tone change (armed to
   locked out) is a state, not a motion, so it cuts. */
#pip-nitrous .pip-fill { transition: stroke-dashoffset 140ms linear; }
@media (prefers-reduced-motion: reduce) {
    #pip-nitrous .pip-fill { transition: none; }
}

/* -----------------------------------------------------------------------------
   Quiet pips
   -----------------------------------------------------------------------------
   On a car with nitrous the row is the reference's row: engine, fuel, nitrous,
   all three, with the two that have nothing to report sitting dim beside the
   one that does. A dim disc, a grey glyph and NO ring -- a ring is a reading,
   and a full green one on each would put three gauges where the reference has
   one. They pick their ring and colour back up the moment they have a warning
   to give (see setPips in app.js). A car with no kit is unaffected: its
   healthy pips are still simply not drawn.
   -------------------------------------------------------------------------- */
.pip.quiet {
    --tone: rgba(168, 164, 174, 0.62);
    background: rgba(58, 54, 64, 0.46);
}
.pip.quiet .pip-ring { display: none; }
#vehicle.collapsed .pip.quiet { box-shadow: none; }

/* -----------------------------------------------------------------------------
   Nitrous hint
   -----------------------------------------------------------------------------
   "Hold [key] to activate nitrous (icon)". One line in a dark plate, top left,
   its left edge on the minimap's left edge -- which is where the reference
   puts it, so the tip and the pip it is talking about share a column. The key
   cap is the action prompts' own .glyph at the tip's scale; the icon is the
   pip's mark on a small disc of its own, in the pip's armed tone.

   A plain cut in and out. The ring is the only thing in the nitrous UI that
   animates, and a tip sliding or fading about would be the one exception.
   -------------------------------------------------------------------------- */
#nitro-hint {
    position: absolute;
    left: var(--map-left, 1.75cqw);
    /* The reference has it at about 7% down, on a screen with nothing else in
       that corner. Here the status bars live there and reach to about 9% with
       all three rows up, so the tip sits just under them instead of on them. */
    top: 10.5cqh;
    display: flex;
    align-items: center;
    gap: calc(0.5 * var(--w));
    padding: calc(0.62 * var(--w)) calc(0.85 * var(--w));
    border-radius: calc(0.16 * var(--w));
    background: rgba(18, 18, 24, 0.62);
    box-shadow: inset 0 0 0 calc(0.06 * var(--w)) rgba(236, 236, 240, 0.10);
    color: #e6e6ea;
    font-size: calc(1.28 * var(--w));
    font-weight: 400;
    letter-spacing: 0.01em;
    line-height: 1;
    white-space: nowrap;
    pointer-events: none;
}
#nitro-hint.hidden { display: none !important; }
/* The tip's button is the same drawing as any prompt's, at the tip's scale:
   the pad's own button on a controller, a key cap on a keyboard. With no bind
   to show (an unbound command, or a caller that sent none) setGlyph marks it
   .none and it goes, rather than sitting there as an empty key. */
#nitro-hint .glyph {
    --g-h: calc(1.52 * var(--w));
    filter: none;
}
/* Disc and mark are one SVG (see index.html), so this only sizes it. */
#nitro-hint-icon {
    width: calc(1.36 * var(--w));
    height: calc(1.36 * var(--w));
    margin-left: calc(0.12 * var(--w));
    display: block;
    flex: none;
}
.nitro-hint-disc { fill: rgba(40, 62, 74, 0.90); }
.nitro-hint-mark { fill: #a9dbe8; }

/* -----------------------------------------------------------------------------
   Collapsed: just the icons
   -----------------------------------------------------------------------------
   The full panel is an ANNOUNCEMENT -- you got into a car, here is what it is.
   Once it has said that, the make, the model and the badge have no more news in
   them, but the fuel and the engine do. So the plate goes and the three icons
   stay, floating over the map with nothing behind them, for as long as you are
   in the vehicle.

   The panel is not rebuilt for this: it keeps its place in the slot stack and
   simply sheds everything that was the announcement. That is what lets it
   collapse in place rather than vanishing and something else appearing.

   Shedding it is animated, unlike slotVanish's hard cut. Cutting instantly
   here was worse than the vanish case: it left the plate as one flat colour
   right up to the frame it disappeared, AND dropped the icons straight down
   by the head's whole height in that same frame -- the map showing through
   and the icons relocating happened simultaneously with nothing easing
   either one, which read as the panel breaking rather than tidying itself
   up. Transitioning #veh-head's own box down to nothing lets the layout
   reflow ride along with the fade instead of snapping to it, so the icons
   drift down into their resting spot rather than jumping there. */
#vehicle {
    transition: background 320ms ease, box-shadow 320ms ease, min-height 320ms ease;
}
#vehicle.collapsed {
    background: none;
    box-shadow: none;
    min-height: 0;
    /* .slot sets overflow: hidden so a child's square corners cannot poke out
       past the plate's rounded ones. Collapsed there IS no plate -- background
       and shadow are both off above, and #veh-foot's band is off too -- so the
       clip has nothing left to protect and only does harm: #veh-pips is scaled
       with a TRANSFORM (see the --sc-vehpips rule), and a transform does not
       change the layout box, so the parent never grows to fit the bigger discs
       and the clip cuts their tops and bottoms off.
       That is the "icons get cut off when I scale them" report.
       #veh-head keeps its OWN overflow:hidden below, so switching this to
       visible up front does not let the head's shrinking content spill out
       mid-transition. */
    overflow: visible;
}
#vehicle::after {
    transition: opacity 260ms ease;
}
#vehicle.collapsed::after { opacity: 0; }   /* the plate's sheen */
/* max-height, not height: the head's real height is content-driven (the
   make/model text and --fit), and max-height is the one property that can
   transition FROM "however tall that turns out to be" TO zero without a
   fixed number baked in. Set comfortably above anything two lines of vname
   plus its padding can reach, so the expanded state is never the one that
   gets clipped. overflow:hidden is what makes the shrink read as the plate
   folding away rather than as text sliding out from under a lid. */
#veh-head {
    max-height: 10cqh;
    opacity: 1;
    overflow: hidden;
    transition: max-height 320ms ease, opacity 220ms ease, padding 320ms ease;
}
#vehicle.collapsed #veh-head {
    max-height: 0;
    opacity: 0;
    padding-top: 0;
    padding-bottom: 0;
}
#veh-foot {
    transition: background 320ms ease, border-color 320ms ease;
}
/* Padding is deliberately NOT part of the collapsed state below. The icons
   are centred inside #veh-foot's padding box, so shrinking that padding to 0
   pulls their centre down toward the band's new, tighter bottom edge -- a
   second source of vertical drift on top of the head folding away, and the
   one still visible after the head's own motion was fixed. #veh-foot's
   height, and therefore the icons' position, has to stay IDENTICAL between
   the two states for them to look like they never moved at all. */
#vehicle.collapsed #veh-foot {
    background: none;
    /* border-color, not border-top:none -- removing the border outright is
       another instant cut, and a border-STYLE cannot transition. Fading its
       colour to transparent while keeping the same width gets the barrier
       to dissolve instead of vanishing, and costs no layout shift since the
       width never changes. */
    border-top-color: transparent;
}
/* The discs are the only thing left, so they have to carry their own separation
   from the map. On the plate they had one; here there is nothing behind them. */
#vehicle.collapsed .pip {
    box-shadow: 0 0.12cqh 0.34cqh rgba(0, 0, 0, 0.55);
}
@media (prefers-reduced-motion: reduce) {
    #vehicle, #vehicle::after, #veh-head, #veh-foot { transition: none; }
}

/* -----------------------------------------------------------------------------
   Manufacturer mark
   -----------------------------------------------------------------------------
   The vehicle panel carries the badge of whatever you are sitting in, stamped
   into the plate behind the make and model. app.js points --veh-logo at a file
   in html/logos/ and adds .badged; html/makes.js decides which file.

   THREE LAYERS, IN THIS ORDER, AND THE ORDER IS THE POINT
     .slot's own background   the grey plate, unchanged
     #veh-logo                the mark
     ::after                  the panel's sheen and diagonal highlight
     the two names, the pips  on top of all of it

   The sheen used to be this element's background-image, which would have put
   it UNDER the mark. A badge sitting on top of the highlight reads as a sticker
   stuck to the panel; a badge underneath it reads as part of the panel, because
   the highlight then lies across the badge the way it lies across the plate.
   Same two gradients as before, moved a layer up.

   The mark is an <img>, not a background-image, because the roster runs from
   Ocelot's near-square roundel to Western Company's 5:1 wordmark and CSS has no
   background-size that means "contain, but bigger". max-width and max-height on
   a real element are exactly that, and they cost nothing.
   -------------------------------------------------------------------------- */
#vehicle::after {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: 1;
    /* Textured, not a flat fill: a soft top-down sheen plus a wide diagonal
       highlight sweeping across the panel, which is what reads as "material" in
       the reference stills rather than a solid swatch. Pure CSS gradients, so
       there is no image to stream and it scales with the panel. */
    background-image:
        linear-gradient(180deg,
            rgba(255, 255, 255, 0.075) 0%,
            rgba(255, 255, 255, 0.018) 42%,
            rgba(0, 0, 0, 0.10) 100%),
        linear-gradient(118deg,
            rgba(255, 255, 255, 0.00) 34%,
            rgba(255, 255, 255, 0.055) 50%,
            rgba(255, 255, 255, 0.00) 66%);
}

/* The upper band: the names, and the badge behind them. */
#veh-head {
    flex: 1 1 auto;
    align-self: stretch;
    display: flex;
    flex-direction: column;
    align-items: var(--ai-slots, center);
    justify-content: center;
    gap: 0.06cqh;
    padding: 0.55cqh calc(0.5 * var(--w));
    /* Deliberately NOT a stacking context -- position without a z-index. The
       badge inside it has to stay UNDER #vehicle::after, and a stacking context
       here would trap it above the sheen along with everything else in the
       band. */
    position: relative;
}

/* The lower band: a full-width strip in a slightly lighter grey, so the status
   icons read as their own section rather than as a third line of the name.
   The barrier is this element's border-top -- edge to edge by construction,
   which is the thing a centred rule of its own could never quite promise. */
#veh-foot {
    flex: none;
    align-self: stretch;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0.37cqh 0;
    /* Both of these are deliberately faint. The band and its barrier used to
       be strong enough to read as a second panel welded under the first; in
       the reference the pips sit in a barely-there compartment on the same
       surface, and the separation is a hint rather than a rule. */
    background: rgba(255, 255, 255, 0.028);
    border-top: 0.09cqh solid rgba(255, 255, 255, 0.07);
    position: relative;
    z-index: 2;
}

/* The badge's window. The mark is oversized on purpose and runs off the top and
   bottom of the plate, but it must stop at the barrier: a mark bleeding through
   into the status strip is what stops the strip looking like a separate band.
   Clipping it here rather than on the image means the image can still be moved
   and scaled by the editor without the clip following it around. */
#veh-logo-clip {
    position: absolute;
    /* Long-hand AND the shorthand. `inset` is Chromium 87+, and a client on an
       older CEF would resolve it to nothing, collapse the window to zero and
       show no badge at all -- indistinguishable from the file being missing.
       Four extra declarations is a cheap way to never have to tell those two
       apart again. */
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    inset: 0;
    overflow: hidden;
    z-index: 0;
    pointer-events: none;
}

#veh-logo {
    position: absolute;
    /* Centred by the offsets block further down, which pairs these with a
       translate(-50%, -50%). Splitting it that way is what lets the editor's
       Position rows move the mark off centre without this rule and that one
       having to agree about where "centre" was. */
    left: 50%;
    top: 50%;
    /* EVERY mark is the same square canvas (tools/normalize_logos.py), with the
       ink scaled to a constant area and centred in it. That is what makes one
       rule here correct for all fifty-one: before it, each file was cropped
       hard to its own ink, so a wide slab like Truffade was bounded by width
       and a narrow upright like Albany by height, and the two landed on the
       panel at completely different sizes -- Truffade about three times the
       visual mass of its neighbours.

       Because the canvas is SQUARE and uniform, height is the only binding
       constraint in practice and max-width is just a backstop. The ink is a
       known fraction of the canvas (INK in that script, ~0.47), so the figure
       below is roughly twice the ink height you actually want: 220% of the
       name band puts the ink itself at just over 100% of it, which keeps the
       mark running a little past the band and cropping to it -- the "stamped
       into the panel" look this always had -- rather than sitting inside it
       like a picture.

       Retune the SIZE here; retune the evenness in normalize_logos.py. Making
       one mark bigger on its own is not a thing this rule can do, and that is
       deliberate. */
    max-width: 96%;
    max-height: 220%;
    width: auto;
    height: auto;
    /* The marks are greyscale by the time they ship (tools/fetch_logos.py), so
       there is no colour here to fight the white type in front of it. */
    pointer-events: none;
    /* Hidden until app.js has a mark to show. Nothing else sets display on it,
       so this doubles as the empty state. */
    display: none;
}
/* .badged is app.js saying "the make resolved AND that manufacturer has a
   mark". Two of the sixty-six have none, and neither does an addon vehicle with
   no resolvable make; all of those keep the plain plate. */
#vehicle.badged #veh-logo { display: block; }

/* Above both layers. Without this the names and the pips are painted into the
   same stacking context as the sheen and land under it. */
#veh-head > .vname { position: relative; z-index: 2; }

/* -----------------------------------------------------------------------------
   Wanted notification (middle-left)
   -------------------------------------------------------------------------- */
/* GTA VI's box, rebuilt from a GTA VI frame (the single-player HUD's
   measurements, tools/render/wanted.py there, at 1080p: 430x110, 1px =
   var(--w) / 19.2). A dark tint that fades towards the right, a faint top
   edge, light text in sentence case (Segoe UI Variable Display 380, capitals
   16 px tall, so a 22.86 px font, baselines 31 apart, capitals 17.5 px below
   the top), and a dark red star and tell discs.

   The star and the tells follow the text rather than sitting at fixed spots:
   the star a gap after "you", the tells a row under the second line. At the
   default size that is exactly where GTA VI has them; with the text made
   bigger in /movehud, or a font with other widths, they move with it instead
   of landing on it, and the box grows to hold it all (never under 430 x 110). */
#wanted {
    position: absolute;
    left: 1.5cqw;
    top: 26.7cqh;
    width: max-content;
    min-width: calc(22.396 * var(--w));
    min-height: calc(5.729 * var(--w));
    box-sizing: border-box;
    background: linear-gradient(90deg, rgba(22, 20, 24, 0.88), rgba(40, 34, 44, 0.45));
    border-radius: calc((0.156 * var(--w)) * var(--rad-wanted, 1));
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10);
    padding: calc(0.452 * var(--w)) calc(0.859 * var(--w)) calc(0.5 * var(--w)) calc(0.859 * var(--w));
}
#wanted-copy {
    display: block;
    font-size: calc((1.1905 * var(--w)) * var(--fs-wanted, 1));
    font-weight: var(--fw-wanted, 380);
    color: rgb(215, 205, 210);
    line-height: 1.35625;
    letter-spacing: var(--ls-wanted, 0.065em);
    white-space: nowrap;
}
/* 28 px, a gap after "you", centred on the capitals (0.35 em above the
   baseline, their middle). */
#wanted .star {
    display: inline-block;
    width: calc((1.458 * var(--w)) * var(--ic-wanted, 1));
    height: calc((1.458 * var(--w)) * var(--ic-wanted, 1));
    margin-left: 0.45em;
    vertical-align: calc(0.35em - (0.729 * var(--w)) * var(--ic-wanted, 1));
}
/* The box's star is the corner row's star: no colours of its own, so it shows
   the same state (contact, searching, hollow or red) as the stars above it. */
#wanted-tells {
    display: flex;
    gap: calc(0.469 * var(--w));
    margin: calc(0.172 * var(--w)) 0 0 calc(0.183 * var(--w));
}

/* -----------------------------------------------------------------------------
   Honor toast — transient, fires only on an honor change
   -------------------------------------------------------------------------- */
/* Bottom-right, matching where the reference frames put the mugshot
   (right ~0.75%, top ~86%). The copy sits to the LEFT of the portrait because
   the portrait is hard against the right edge. Anchored above #prompts so the
   two never collide when a prompt is also showing. */
/* Anchored so the PORTRAIT lands exactly where the reference frames put it:
   right 0.75%, top 86.12%, 4.25% wide x 7.74% tall. The container offsets
   account for its own padding so the mugshot itself hits those numbers. */
#honor {
    position: absolute;
    right: 0.25cqw;
    bottom: 5.24cqh;
    box-shadow: var(--shadow-md), var(--hairline);
    display: flex;
    flex-direction: row-reverse;
    align-items: flex-end;
    gap: calc(0.8 * var(--w));
    padding: 0.9cqh calc(0.5 * var(--w)) 0.9cqh calc(1.3 * var(--w));
    background: rgba(18, 18, 22, 0.82);
    border-radius: calc((calc(0.4 * var(--w))) * var(--rad-honor, 1));
}
/* Config.Honor.showValue = false: no copy column at all, so the row gap and
   the copy-side padding both have to go -- otherwise the box keeps the width
   the text used to occupy and the portrait sits off to one side of it. The
   class is toggled in onHonor() (app.js) off whether either line has text,
   not off showValue directly, so a caller that sends a value but no reason
   still gets the right box. */
#honor.bare {
    gap: 0;
    padding: 0.9cqh calc(0.5 * var(--w));
}
#honor-copy {
    align-items: flex-end;
    text-align: right;
    padding-bottom: 0.6cqh;
}
/* The reference portrait is a PORTRAIT rectangle, not a square:
   4.25% of width by 7.74% of height. */
#honor-mug {
    position: relative;
    width: calc((calc(4.25 * var(--w))) * var(--ic-honor, 1));
    height: calc((7.74cqh) * var(--ic-honor, 1));
    border-radius: calc((calc(0.3 * var(--w))) * var(--rad-honor, 1));
    background: linear-gradient(155deg, #8d7f74, #4a423c);
    border: 1px solid rgba(255, 255, 255, 0.16);
    overflow: visible;
    flex-shrink: 0;
}
#honor-mug img { width: 100%; height: 100%; object-fit: cover; border-radius: calc((calc(0.3 * var(--w))) * var(--rad-honor, 1)); display: block; }
/* 1.0 * --w, not the 1.35 a font-size multiplier would suggest -- this is a
   raw <img>, and an emoji glyph at that font-size carries its own internal
   margin that a bare image doesn't, so reusing the old emoji constant here
   drew the badge oversized and spilling out of the mug frame. */
#honor-badge-wrap {
    position: absolute;
    left: 50%;
    bottom: -1.1cqh;
    transform: translateX(-50%);
    width: calc((calc(1.0 * var(--w))) * var(--fs-honor, 1));
    height: calc((calc(1.0 * var(--w))) * var(--fs-honor, 1));
}
#honor-badge {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: contain;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8));
}
/* Unrepairable floor: desaturated on top of the crack overlay below, same
   "spent, not merely low" treatment ox_inventory's/qbcore_vi_qbx_relog's own honor
   chips use (their .broken/.honor-broken rules) -- see onHonor() in app.js
   for the class toggle. Combined into one filter list, not two separate
   `filter` declarations, since the second would just replace the first. */
#honor-badge.broken {
    filter: grayscale(1) brightness(0.75) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8));
}
/* Unrepairable floor (qbx_honor's honorBroken): the badge swaps to the
   terrible-deed face and a crack overlay draws on top, permanently -- see
   onHonor() in app.js. 65% of the badge, centred ON IT (not independently
   anchored to the mug like the old flat 100%-size version was) -- at full
   size the crack read as a solid patch blotting out the face rather than a
   scratch across it. Same idea as #honor-pop-crack's 0.72 ratio below. */
#honor-crack {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: 65%;
    height: 65%;
    pointer-events: none;
    filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.6));
}
#honor-copy { display: flex; flex-direction: column; gap: 0.15cqh; }
#honor-title { font-size: calc((calc(1.08 * var(--w))) * var(--fs-honor, 1)); font-weight: var(--fw-honor, 700); color: var(--text); letter-spacing: var(--ls-honor, 0.01em); text-shadow: var(--ink-shadow); }
#honor-sub { font-size: calc((calc(0.92 * var(--w))) * var(--fs-honor, 1)); color: var(--text-dim); text-shadow: var(--ink-shadow); }

/* Reputation standing --------------------------------------------------------
   Mirrors #honor's shape (icon/mug + two lines of copy) but on the LEFT edge,
   bottom-anchored the same distance up as honor is on the right — the two
   read as a matched pair without ever overlapping. No mugshot: the icon is a
   plain glyph, so the row is `flex-direction: row` (icon first) rather than
   honor's row-reverse. */
#reputation {
    position: absolute;
    left: 0.25cqw;
    bottom: 5.24cqh;
    box-shadow: var(--shadow-md), var(--hairline);
    display: flex;
    flex-direction: row;
    align-items: flex-end;
    gap: calc(0.8 * var(--w));
    padding: 0.9cqh calc(1.3 * var(--w)) 0.9cqh calc(0.5 * var(--w));
    background: rgba(18, 18, 22, 0.82);
    border-radius: calc((calc(0.4 * var(--w))) * var(--rad-reputation, 1));
}
#reputation-icon {
    flex-shrink: 0;
    font-size: calc((calc(2.2 * var(--w))) * var(--fs-reputation, 1));
    line-height: 1;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8));
}
#reputation-copy { display: flex; flex-direction: column; gap: 0.15cqh; }
#reputation-title { font-size: calc((calc(1.08 * var(--w))) * var(--fs-reputation, 1)); font-weight: var(--fw-reputation, 700); color: var(--text); letter-spacing: var(--ls-reputation, 0.01em); text-shadow: var(--ink-shadow); }
#reputation-sub { font-size: calc((calc(0.92 * var(--w))) * var(--fs-reputation, 1)); color: var(--text-dim); text-shadow: var(--ink-shadow); }

/* -----------------------------------------------------------------------------
   Contextual action prompts (bottom-right)
   -------------------------------------------------------------------------- */
/* Measured off the reference at 1080p: a bold condensed capital label 15.5px
   to the cap, a 30px button to its right, rows 52px apart, the column 25px in
   from the right edge and 40px up from the bottom. A hold ring round a
   button takes it to 42px, and the cell every button sits in is that size
   whether or not there is a ring, so a column of them shares one centre line.

   Horizontal measures are in --w (the aspect-independent reference unit, see
   its definition above), not raw cqw, so the corner inset is the same on an
   ultrawide as on 16:9. */
#prompts {
    position: absolute;
    right: calc(1.31 * var(--w));
    bottom: 3.75cqh;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 1cqh;
}
.prompt {
    display: flex;
    align-items: center;
    gap: 0.5cqh;
}
.prompt-label,
.wa-label {
    font-size: calc(2.07cqh * var(--fs-prompts, 1));
    font-weight: var(--fw-prompts, 700);
    font-stretch: 86%;
    letter-spacing: var(--ls-prompts, 0.012em);
    line-height: 1;
    text-transform: uppercase;
    white-space: nowrap;
    color: #f6f2ee;
    /* A hard black outline, not just a soft drop shadow -- a prompt has to
       stay readable against a white wall and a night street alike, and the
       reference outlines its labels the same way. */
    text-shadow:
        -0.13cqh -0.13cqh 0 #000, 0.13cqh -0.13cqh 0 #000,
        -0.13cqh 0.13cqh 0 #000, 0.13cqh 0.13cqh 0 #000,
        0 -0.13cqh 0 #000, 0 0.13cqh 0 #000, -0.13cqh 0 0 #000, 0.13cqh 0 0 #000,
        0 0.18cqh 0.35cqh rgba(0, 0, 0, 0.6);
}
/* A row with no button at all keeps the cell's height, so it lines up with
   the rows around it. */
.prompt.bare { min-height: calc(3.89cqh * var(--ic-prompts, 1)); }
.prompt-glyph-wrap {
    flex: 0 0 auto;
    /* As wide as a ringed button's box (a third wider than a plain one), so
       ringed and plain rows centre on the same line to the pixel. */
    min-width: calc(4.067cqh * var(--ic-prompts, 1));
    height: calc(3.89cqh * var(--ic-prompts, 1));
    display: flex;
    align-items: center;
    justify-content: center;
}

/* -----------------------------------------------------------------------------
   Screen text and full-screen overlays
   -----------------------------------------------------------------------------
   Measured at 1080p on the reference clips. cqh is 10.8px there.

     subtitle    regular rounded sans, capitals 25.6px, lines 47px apart, centred,
                 last baseline 90px above the bottom
     objective   the same face in extra bold, capitals 18.3px, baseline 58px
                 above the bottom; the thing to go to is pink
     help box    32px in, 289px down, 420px wide, 16px of padding, capitals
                 17.4px, key words bold, on a dark plate
     feed        right-aligned 38px in, first line 428px down, lines 40px
                 apart, capitals 16px
   -------------------------------------------------------------------------- */
@font-face {
    font-family: 'ViceText';
    src: url('fonts/PlusJakartaSans-Variable.ttf') format('truetype-variations');
    font-weight: 200 800;
    font-style: normal;
    font-display: swap;
}

#subtitle, #objective, #help-text, #feed {
    position: absolute;
    font-family: 'ViceText', 'Segoe UI', Roboto, Arial, sans-serif;
    color: #ececf0;
    pointer-events: none;
}
#subtitle, #objective {
    left: 50%;
    transform: translateX(-50%);
    width: max-content;
    max-width: 62cqw;
    text-align: center;
    text-shadow:
        -0.14cqh -0.14cqh 0 rgba(8, 8, 10, 0.9), 0.14cqh -0.14cqh 0 rgba(8, 8, 10, 0.9),
        -0.14cqh 0.14cqh 0 rgba(8, 8, 10, 0.9), 0.14cqh 0.14cqh 0 rgba(8, 8, 10, 0.9),
        0 0 0.5cqh rgba(0, 0, 0, 0.75);
}
#subtitle {
    bottom: 7.47cqh;
    font-size: 3.21cqh;
    font-weight: 400;
    line-height: 1.352;
}
#objective {
    bottom: 4.9cqh;
    font-size: 2.3cqh;
    font-weight: 800;
    letter-spacing: 0.03em;
    line-height: 1.2;
}
#objective em, #subtitle em, #help-text em { font-style: normal; color: #e8869f; }

#help-text {
    left: var(--map-left, 1.67cqw);
    top: 26.76cqh;
    width: 39cqh;
    box-sizing: border-box;
    padding: 1.2cqh 1.48cqh;
    font-size: 2.15cqh;
    font-weight: 400;
    line-height: 1.25;
    background: rgba(38, 34, 46, 0.82);
    border-radius: 0.28cqh;
    box-shadow: inset 0 0 0 0.09cqh rgba(255, 255, 255, 0.14);
}
#help-text b, #subtitle b, #feed b { font-weight: 700; }
#help-text .help-tells { display: flex; gap: 0.5cqh; margin-top: 0.7cqh; }
#help-text .help-tells .tell { width: 2.1cqh; height: 2.1cqh; }

#feed {
    right: 1.98cqw;
    top: 38.5cqh;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 1.72cqh;
    font-size: 1.98cqh;
    font-weight: 400;
    line-height: 1;
    text-shadow:
        -0.12cqh -0.12cqh 0 rgba(8, 8, 10, 0.9), 0.12cqh -0.12cqh 0 rgba(8, 8, 10, 0.9),
        -0.12cqh 0.12cqh 0 rgba(8, 8, 10, 0.9), 0.12cqh 0.12cqh 0 rgba(8, 8, 10, 0.9),
        0 0 0.4cqh rgba(0, 0, 0, 0.7);
}
.feed-line { display: flex; align-items: center; gap: 1cqh; white-space: nowrap; }
.feed-line img { height: 1.75cqh; width: auto; display: block; filter: drop-shadow(0 0 0.15cqh rgba(0, 0, 0, 0.8)); }
/* A count of something going on, "2 scored" in GTA VI's basketball. Measured
   there at 1440p: capitals 25 px, the hoop after it 35 px tall, 24 px apart. */
.feed-line.counter { font-size: 2.4cqh; gap: 1.67cqh; }
.feed-line.counter img { height: 2.43cqh; }

/* A button in the help box's line ('aim with (RMB) and hold (LMB)'): the
   prompts' glyph, brought down to the text and sat on its middle. */
#help-text .help-glyph {
    --g-h: 2.6cqh;
    display: inline-block;
    vertical-align: -0.62cqh;
    margin: 0 0.15cqh;
}

/* The gain card, "Focus +2%". Measured off GTA VI's basketball at 1440p
   (cqh is 14.4px there): 520 x 115 px, 50 px in from the right and 45 up
   from the bottom, a 92 px ring 12 px thick round the eye with 11 px either
   side of it, the name in bold over the gain, both about 30 px. The plate is
   the help box's. */
#statgain {
    position: absolute;
    right: 3.47cqh;
    bottom: 3.13cqh;
    width: 36.1cqh;
    height: 8cqh;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    gap: 1.05cqh;
    padding: 0 0.76cqh;
    background: rgba(38, 34, 46, 0.82);
    border-radius: 1cqh;
    box-shadow: inset 0 0 0 0.09cqh rgba(255, 255, 255, 0.08);
    font-family: 'ViceText', 'Segoe UI', Roboto, Arial, sans-serif;
    color: #ececf0;
    pointer-events: none;
}
#statgain-ring { flex: 0 0 auto; width: 6.4cqh; height: 6.4cqh; }
#statgain-ring svg { display: block; width: 100%; height: 100%; }
#statgain .sg-track,
#statgain .sg-arc { fill: none; stroke-width: 13; }
#statgain .sg-track { stroke: rgba(167, 135, 255, 0.34); }
#statgain .sg-fill { stroke: rgba(167, 135, 255, 0.8); }
#statgain .sg-gain { stroke: #f4f2f8; }
#statgain .sg-eye { fill: #a787ff; }
#statgain .sg-pupil { fill: rgb(38, 34, 46); }
#statgain-copy { display: flex; flex-direction: column; gap: 0.5cqh; min-width: 0; }
#statgain-label { font-size: 2cqh; font-weight: 700; line-height: 1.1; }
#statgain-text { font-size: 2cqh; font-weight: 400; line-height: 1.1; }

@keyframes statGainIn {
    from { opacity: 0; transform: translateY(0.8cqh); }
    to   { opacity: 1; transform: translateY(0); }
}
#statgain.slot-in { animation: statGainIn 220ms ease-out both; }
@media (prefers-reduced-motion: reduce) {
    #statgain.slot-in { animation: none; }
}

/* Title cards. Three shapes in the reference:
     stack     "NINE MONTHS AGO." -- very large capitals 96px tall, one word a
               line, 160px apart, starting 613px in and 407px down
     center    "PROLOGUE" -- capitals 124px tall, tracked out, centred 702px down
     location  "PORT GELLHORN" -- a place name, capitals 45px tall, centred
               136px down, with a pair of sparkles after it
   All cut in and out, like the race cards. */
#title-card {
    position: absolute;
    color: #f6f2f4;
    font-family: 'Bahnschrift', 'DIN Condensed', 'Segoe UI', Arial, sans-serif;
    font-weight: 700;
    text-transform: uppercase;
    line-height: 1;
    white-space: pre-line;
    pointer-events: none;
}
#title-card.stack {
    left: 31.9cqw;
    top: 34.7cqh;
    font-size: 12.7cqh;
    line-height: 1.165;
    letter-spacing: 0.05em;
    transform: scaleX(1.12);
    transform-origin: 0 0;
}
#title-card.center {
    left: 50%;
    top: 65cqh;
    transform: translate(-50%, -50%);
    font-size: 16.4cqh;
    font-stretch: 75%;
    letter-spacing: 0.03em;
    text-shadow: 0 0 1.2cqh rgba(255, 200, 215, 0.35);
    white-space: nowrap;
}
#title-card.location {
    left: 50%;
    top: 12.5cqh;
    /* The reference letters this by hand, tall and very narrow: 13 characters
       in 258px at a 45px cap. Squeezed to that width rather than spaced. */
    transform: translate(-50%, -50%) scaleX(0.62);
    display: flex;
    align-items: center;
    gap: 1.4cqh;
    font-size: 5.83cqh;
    font-stretch: 75%;
    letter-spacing: 0.02em;
    text-shadow: 0 0 0.9cqh rgba(255, 255, 255, 0.45), 0 0.2cqh 0.5cqh rgba(0, 0, 0, 0.55);
    white-space: nowrap;
}
#title-card .title-sparkle { height: 0.95em; width: auto; flex: 0 0 auto; fill: currentColor; filter: drop-shadow(0 0 0.5cqh rgba(255, 255, 255, 0.5)); }

/* Binoculars. A lens-shaped opening with a soft dark edge and the rest of the
   screen black; a hairline reticle; the thing being looked at named bottom
   left, button first; ZOOM is an ordinary action prompt bottom right. */
#binoculars { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
#stage.bino > *:not(#binoculars):not(#prompts):not(#subtitle):not(#objective) { visibility: hidden !important; }
#bino-lens {
    position: absolute;
    left: 2.6cqw;
    right: 2.6cqw;
    top: 2.2cqh;
    bottom: 6.2cqh;
    border-radius: 46cqh;
    box-shadow:
        0 0 0 200cqw #000,
        inset 0 0 6cqh 3.4cqh rgba(0, 0, 0, 0.92),
        inset 0 0 16cqh 2cqh rgba(0, 0, 0, 0.55);
}
#bino-reticle { position: absolute; inset: 0; width: 100%; height: 100%; }
#bino-reticle path { fill: none; stroke: rgba(18, 18, 20, 0.6); stroke-width: 1.6; vector-effect: non-scaling-stroke; }
#bino-label {
    position: absolute;
    left: calc(0.62 * var(--w));
    bottom: 1.2cqh;
    display: flex;
    align-items: center;
    font-family: 'Bahnschrift', 'DIN Condensed', 'Segoe UI', Arial, sans-serif;
    gap: 0.6cqh;
}

/* Shop item panel. Top left: the store dimmed, a slash, and under it what is
   being browsed. Bottom left: how it is paid for and the price (dimmed when it
   cannot be afforded), the item, the variant, a row of 110px swatches with the
   chosen one outlined, and under a hairline the reason it cannot be bought.
   The BUY / EXIT buttons are action prompts, laid in a row while this is up. */
#shop { position: absolute; inset: 0; pointer-events: none; }
#shop-head {
    position: absolute;
    left: 1.77cqw;
    top: 2.3cqh;
    font-family: 'Bahnschrift', 'DIN Condensed', 'Segoe UI', Arial, sans-serif;
    font-weight: 700;
    font-stretch: 80%;
    text-transform: uppercase;
    line-height: 1;
    text-shadow: 0 0.15cqh 0.4cqh rgba(0, 0, 0, 0.6);
}
#shop-store { font-size: 3.3cqh; color: rgba(214, 212, 226, 0.58); }
#shop-store:not(:empty)::after { content: ' /'; }
#shop-category { margin-top: 0.75cqh; font-size: 4.23cqh; color: #f6f2f4; }
#shop-item {
    position: absolute;
    left: 1.82cqw;
    bottom: 3.1cqh;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    color: #f2f0ea;
}
#shop-price-row { display: flex; align-items: center; gap: 1.2cqh; }
#shop-pay { display: flex; gap: 1.2cqh; }
#shop-pay img { height: 3.2cqh; width: auto; display: block; }
#shop-price {
    font-family: 'Pricedown', 'Segoe UI', Arial, sans-serif;
    font-size: 4.27cqh;
    line-height: 1;
    transform: translateY(-0.075em);
    text-shadow: 0 0.14cqh 0.3cqh rgba(0, 0, 0, 0.9);
}
#shop.cant #shop-price { color: #8f8c98; }
#shop-name {
    margin-top: 1.17cqh;
    font-family: 'Bahnschrift', 'DIN Condensed', 'Segoe UI', Arial, sans-serif;
    font-weight: 700;
    font-stretch: 80%;
    font-size: 3.57cqh;
    line-height: 1;
    text-transform: uppercase;
    text-shadow:
        -0.12cqh -0.12cqh 0 #000, 0.12cqh -0.12cqh 0 #000, -0.12cqh 0.12cqh 0 #000, 0.12cqh 0.12cqh 0 #000,
        0 0.2cqh 0.4cqh rgba(0, 0, 0, 0.6);
}
#shop-variant {
    margin-top: 2.85cqh;
    font-family: 'ViceText', 'Segoe UI', Roboto, Arial, sans-serif;
    font-size: 2.47cqh;
    line-height: 1;
    text-shadow: 0 0.12cqh 0.3cqh rgba(0, 0, 0, 0.85);
}
#shop-variant:empty, #shop-swatches:empty { display: none; }
#shop-strip { display: flex; flex-direction: column; align-items: stretch; margin-top: 2.2cqh; }
#shop-row { position: relative; display: flex; }
#shop-swatches { display: flex; gap: 0.99cqh; }
/* The strip (data.strip): the row is the items, as the GTA VI barber shows its hairstyles - the picture fills its
   tile, "1 / 9" over the row's end, an arrow after the row while the list goes on, a hairline under it as long and
   as far along as the tiles shown are of the whole list, and the status ("Not applied") a plain line under that. */
#shop-counter {
    align-self: flex-end;
    margin-bottom: 1.1cqh;
    font-family: 'ViceText', 'Segoe UI', Roboto, Arial, sans-serif;
    font-size: 2.2cqh;
    line-height: 1;
    color: rgba(214, 212, 226, 0.72);
    text-shadow: 0 0.12cqh 0.3cqh rgba(0, 0, 0, 0.85);
}
#shop-counter:empty { display: none; }
#shop-more { position: absolute; left: 100%; top: 50%; transform: translateY(-50%); margin-left: 0.9cqh; }
#shop-more::before {
    content: '\203A';
    font-family: 'Segoe UI', Arial, sans-serif;
    font-size: 4.2cqh;
    line-height: 1;
    color: #f6f2f4;
    text-shadow: 0 0.12cqh 0.3cqh rgba(0, 0, 0, 0.85);
}
#shop-progress { position: relative; height: 0.28cqh; margin-top: 1.5cqh; background: rgba(255, 255, 255, 0.08); }
#shop-progress-thumb { position: absolute; top: 0; bottom: 0; background: rgba(246, 242, 244, 0.72); }
#shop-status {
    margin-top: 2.3cqh;
    font-family: 'ViceText', 'Segoe UI', Roboto, Arial, sans-serif;
    font-size: 2.47cqh;
    line-height: 1;
    text-shadow: 0 0.12cqh 0.3cqh rgba(0, 0, 0, 0.85);
}
#shop.strip .shop-swatch.pic { background-color: #c4beb9; background-size: cover; padding: 0; }
.shop-swatch {
    position: relative;
    width: 10.19cqh;
    height: 10.19cqh;
    box-sizing: border-box;
    box-shadow: inset 0 0 0 0.1cqh rgba(255, 255, 255, 0.28);
    background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0) 45%);
}
/* (a swatch that is a picture of the variant: the whole of it, on a plain tile) */
.shop-swatch.pic {
    background-color: rgba(18, 18, 24, 0.78);
    background-size: contain;
    background-origin: content-box;
    background-repeat: no-repeat;
    background-position: center;
    padding: 0.7cqh;
}
.shop-swatch.on { box-shadow: inset 0 0 0 0.3cqh #f6f2f4; }
.shop-swatch.locked::after, .shop-cant {
    font-family: 'Pricedown', 'Segoe UI', Arial, sans-serif;
    color: #e0303a;
    text-shadow: -0.1cqh -0.1cqh 0 #f6f2f4, 0.1cqh -0.1cqh 0 #f6f2f4, -0.1cqh 0.1cqh 0 #f6f2f4, 0.1cqh 0.1cqh 0 #f6f2f4;
    line-height: 1;
}
.shop-swatch.locked::after { content: '$'; position: absolute; right: 0.9cqh; bottom: 0.7cqh; font-size: 2.9cqh; }
#shop-note {
    display: flex;
    align-items: center;
    gap: 1.6cqh;
    margin-top: 2.74cqh;
    padding-top: 1.7cqh;
    min-width: 52.4cqh;
    box-shadow: inset 0 0.1cqh 0 rgba(255, 255, 255, 0.22);
    font-family: 'ViceText', 'Segoe UI', Roboto, Arial, sans-serif;
    font-size: 2.47cqh;
    line-height: 1;
    text-shadow: 0 0.12cqh 0.3cqh rgba(0, 0, 0, 0.85);
}
.shop-cant { font-size: 3cqh; margin-left: 0.6cqh; }
#stage.shopping #prompts { flex-direction: row; align-items: center; gap: 1.67cqh; }
/* The panel's swatches lie where the minimap is, and a shop takes the radar off the screen without telling the
   HUD: the map's outline, its corner badge and the drawn map go while the panel is up, and are back after. */
#stage.shopping #map-frame, #stage.shopping #map-badge, #stage.shopping #map3d { display: none !important; }
/* While a clothing, barber or tattoo store is open (illenium-appearance sets LocalPlayer.state.appearanceShop, client.lua
   sends moneyHidden): no money on the screen - neither the rows nor the "- $80" pop. */
#stage.money-off #money, #stage.money-off #money-pop { visibility: hidden !important; }

/* A prompt that is on screen but cannot be used right now: the reference greys
   both the word and the button. */
.prompt.off .prompt-label { color: #8a8794; }
.prompt.off .g-face { fill: #76727f; }
.prompt.off .g-ink, .prompt.off .g-text, .prompt.off .g-solid { opacity: 0.55; }

/* Vehicle stats panel. Measured on the bike rental at 1080p: 418px wide,
   32px in from the right, 505px down. A dark band with the make over the
   model (72px), a grey body of five rows 60px apart -- a bold condensed
   label, the figure on a dark chip at the right, and under them a 12px bar
   filled to value/10 with a small marker on the end -- and a dark band at
   the foot with the two ratings (47px). */
#veh-stats {
    position: absolute;
    right: 1.67cqw;
    top: 46.76cqh;
    width: 38.7cqh;
    font-family: 'Bahnschrift', 'DIN Condensed', 'Segoe UI', Arial, sans-serif;
    font-weight: 700;
    font-stretch: 80%;
    text-transform: uppercase;
    line-height: 1;
    color: #f6f2f4;
    pointer-events: none;
}
#vs-head, #vs-foot { background: rgba(52, 47, 62, 0.95); display: flex; align-items: center; }
#vs-head { position: relative; height: 6.65cqh; padding: 0 1.3cqh; overflow: hidden; }
#vs-names { display: flex; flex-direction: column; justify-content: center; gap: 0.62cqh; height: 100%; }
#vs-line { display: flex; align-items: center; gap: 0.6cqh; }
#vs-icon { width: 2.7cqh; height: 2.12cqh; flex: 0 0 auto; fill: #f6f2f4; }
#vs-icon .vs-key { fill: rgba(52, 47, 62, 1); }
#vs-make { font-size: 2.09cqh; }
#vs-model { font-size: 2.09cqh; color: #d6cbe6; font-weight: 600; }
#vs-road { position: absolute; right: 1.2cqh; bottom: 0; height: 5.6cqh; width: auto; fill: rgba(255, 255, 255, 0.12); }
#vs-rows {
    background: rgba(150, 140, 160, 0.72);
    padding: 1.26cqh 1.16cqh 1.52cqh 1.14cqh;
    display: flex;
    flex-direction: column;
    gap: 1.48cqh;
}
.vs-row { display: grid; grid-template-columns: 1fr auto; align-items: center; row-gap: 0.1cqh; }
.vs-label {
    font-size: 2.2cqh;
    text-shadow:
        -0.11cqh -0.11cqh 0 #1d1a24, 0.11cqh -0.11cqh 0 #1d1a24,
        -0.11cqh 0.11cqh 0 #1d1a24, 0.11cqh 0.11cqh 0 #1d1a24;
}
.vs-value {
    min-width: 3.57cqh;
    height: 2.87cqh;
    box-sizing: border-box;
    padding: 0 0.5cqh;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(52, 47, 62, 1);
    font-size: 2.2cqh;
    font-variant-numeric: tabular-nums;
}
.vs-bar {
    grid-column: 1 / -1;
    position: relative;
    height: 1.1cqh;
    background: rgba(120, 110, 128, 0.9);
    box-shadow: 0 0 0 0.13cqh #1d1a24;
}
.vs-fill { position: absolute; left: 0; top: 0; bottom: 0; width: calc(var(--vs, 0) * 100%); background: #f1e8f5; }
.vs-fill::after {
    content: '';
    position: absolute;
    right: -0.37cqh;
    top: -0.38cqh;
    width: 0.74cqh;
    height: 1.86cqh;
    box-sizing: border-box;
    background: #fbf7fc;
    border: 0.13cqh solid #1d1a24;
}
#vs-foot { height: 4.38cqh; padding: 0 1.14cqh; justify-content: space-between; }
#vs-foot-label { font-size: 2.09cqh; }
#vs-ratings { display: flex; align-items: center; gap: 0.9cqh; font-size: 2.09cqh; font-variant-numeric: tabular-nums; }
#vs-ratings svg { width: 2.7cqh; height: 2.7cqh; display: block; margin-right: 0.5cqh; }
#vs-ratings svg:last-child { margin-right: 0; }
#vs-ratings .vs-tile { fill: #fbf7fc; }
#vs-ratings .vs-mark { fill: none; stroke: #1d1a24; stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; }
#vs-ratings .vs-solid { fill: #1d1a24; }

/* Cinematic overlay (SetCinematicFx; the crash cam uses it). The same
   construction as #focus-fx -- a vignette that closes the edges in, an inner
   shadow, a slow breathe -- in black rather than Focus's purple, plus the two
   bars. It cuts in and out with the shot; nothing fades. vh, not cqh: it sits
   outside the stage so that it survives the HUD being hidden. */
#cine-fx {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 2;
    display: none;
}
/* The vignette breathes on its own layer: with the pulse on #cine-fx itself
   the bars swelled and drifted with it. They stay put, as in single player. */
#cine-fx::before {
    content: '';
    position: absolute;
    inset: 0;
    background:
        radial-gradient(ellipse 150% 135% at 50% 50%,
            transparent 40%,
            rgba(10, 8, 14, calc(0.34 * var(--cine-strength, 1))) 72%,
            rgba(4, 3, 8, calc(0.74 * var(--cine-strength, 1))) 100%);
    box-shadow: inset 0 0 14vh rgba(0, 0, 0, calc(0.5 * var(--cine-strength, 1)));
}
#cine-fx.active { display: block; }
#cine-fx.active.pulse::before { animation: cineFxPulse 2600ms ease-in-out infinite; }
@keyframes cineFxPulse {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.04); }
}
.cine-bar { position: absolute; left: 0; right: 0; height: var(--cine-bars, 3.2vh); background: #000; }
.cine-top { top: 0; }
.cine-bottom { bottom: 0; }
@media (prefers-reduced-motion: reduce) {
    #cine-fx.active.pulse::before { animation: none; }
}

/* Targeting's own page, hosted as a frame over the HUD (see index.html). It is
   transparent and takes no clicks until one of its panels opens. */
#target-ui {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
    background: transparent;
    color-scheme: normal;
    pointer-events: none;
    z-index: 900;
}
#target-ui.open { pointer-events: auto; }

/* -----------------------------------------------------------------------------
   Buttons
   -----------------------------------------------------------------------------
   One element, one SVG (setGlyph in app.js): a controller button as the
   player's own pad prints it, a key cap, or the mouse. --g-h is the height of
   a plain button and is the only thing a context has to set; --g-w and --g-s
   come from setGlyph and say how wide the button's box is and how much a hold
   ring adds round it.

   The disc is 32.8 of the box's 36 units, so --g-h of 3.05cqh is the
   reference's 30px button at 1080p.
   -------------------------------------------------------------------------- */
.glyph {
    --g-h: calc(3.05cqh * var(--ic-prompts, 1));
    flex: 0 0 auto;
    display: block;
    width: calc(var(--g-h) * var(--g-w, 1));
    height: calc(var(--g-h) * var(--g-s, 1));
    line-height: 0;
    filter: drop-shadow(0 0.14cqh 0.22cqh rgba(0, 0, 0, 0.5));
}
.glyph.none { display: none; }
.g-art { display: block; width: 100%; height: 100%; overflow: visible; }
.g-face { fill: #f4f0ea; stroke: rgba(14, 12, 18, 0.92); stroke-width: 1.3; }
.g-ink { fill: none; stroke: #1f2126; stroke-width: 2.7; stroke-linecap: round; stroke-linejoin: round; }
.g-ink.g-s { stroke-width: 2.2; }
.g-ink.g-t { stroke-width: 1.7; }
.g-solid { fill: #1f2126; }
.g-solid.g-dim { fill: #b9b4bd; }
.g-rim { fill: none; stroke: rgba(31, 33, 38, 0.5); stroke-width: 1.5; }
.g-text {
    fill: #1f2126;
    font-family: 'Bahnschrift', 'Segoe UI', Arial, sans-serif;
    font-weight: 700;
    font-stretch: 88%;
}
/* An Xbox pad prints its four face buttons in colour; a PlayStation one does
   not, and neither does the reference. */
.glyph.pad-a .g-face { fill: #6ec06e; }
.glyph.pad-b .g-face { fill: #de6165; }
.glyph.pad-x .g-face { fill: #6fa8dc; }
.glyph.pad-y .g-face { fill: #e8c95a; }

/* Hold-to-confirm (ShowActionPrompt's opts.hold): a ring round the button,
   grey until held, filling clockwise from the top as the hold runs. The
   reference shows it both ways -- empty and grey round a button nobody is
   holding, solid white round one that is. --prompt-frac is written straight
   onto the cell by onPromptProgress; nothing here is rebuilt per tick. */
.g-ring-edge { fill: none; stroke: rgba(14, 12, 18, 0.92); stroke-width: 6.6; }
.g-ring-track { fill: none; stroke: #a49cb2; stroke-width: 3.8; }
.g-ring-fill {
    fill: none;
    stroke: #f4f0ea;
    stroke-width: 3.8;
    stroke-dasharray: 100;
    stroke-dashoffset: calc(100 - var(--prompt-frac, 0) * 100);
}

/* -----------------------------------------------------------------------------
   Aim crosshair
   -----------------------------------------------------------------------------
   True screen centre. Deliberately NOT a /movehud element -- unlike every
   panel above, this has to track the camera's actual aim point, which is
   always dead centre regardless of any offset the player has tuned into the
   rest of the HUD. Moving it would just make it wrong.
   -------------------------------------------------------------------------- */
#crosshair {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 0;
    height: 0;
    pointer-events: none;
}
#crosshair.hidden { display: none !important; }

#cross-circle, #cross-tips { display: none; }
#crosshair[data-mode="vehicle"] #cross-circle { display: block; }
#crosshair[data-mode="foot"] #cross-tips { display: block; }

/* Vehicle: a plain ring with a centre dot. Static -- the reference shows no
   spread on this one, only on the foot reticle. */
#cross-circle { position: relative; width: 0; height: 0; }
.cc-ring {
    position: absolute;
    left: 50%;
    top: 50%;
    width: calc(1.1 * var(--w));
    height: calc(1.1 * var(--w));
    transform: translate(-50%, -50%);
    border: 1.5px solid rgba(255, 255, 255, 0.85);
    border-radius: 50%;
    box-shadow: 0 0 0.2cqh rgba(0, 0, 0, 0.6);
}
.cc-dot {
    position: absolute;
    left: 50%;
    top: 50%;
    width: calc(0.16 * var(--w));
    height: calc(0.16 * var(--w));
    transform: translate(-50%, -50%);
    background: #fff;
    border-radius: 50%;
    box-shadow: 0 0 0.15cqh rgba(0, 0, 0, 0.7);
}

/* Foot: three ticks -- left, right, bottom. No top tick, on purpose: that's
   both what the reference shows and the shape of GTA's own vanilla reticle.
   `--spread` is a plain length, pushed by app.js's onCrossFire() on every
   shot and left to fall back to 0; each tick's own `transition` on transform
   is what turns that discrete change into the widen-then-settle motion, so
   nothing here has to animate the custom property directly. */
#cross-tips {
    position: relative;
    width: 0;
    height: 0;
    --spread: 0px;
}
.ct {
    position: absolute;
    left: 50%;
    top: 50%;
    background: #f2f2ef;
    box-shadow: 0 0 0.15cqh rgba(0, 0, 0, 0.8);
    transition: transform 160ms ease-out;
}
.ct-left, .ct-right {
    width: calc(0.6 * var(--w));
    height: 1.5px;
    margin-top: -0.75px;
}
.ct-bottom {
    width: 1.5px;
    height: calc(0.6 * var(--w));
    margin-left: -0.75px;
}
.ct-left   { transform: translateX(calc(-100% - (0.55 * var(--w) + var(--spread)))); }
.ct-right  { transform: translateX(calc(0.55 * var(--w) + var(--spread))); }
.ct-bottom { transform: translateY(calc(0.55 * var(--w) + var(--spread))); }

/* The kill mark. Independent of [data-mode] -- it has to be able to flash
   over either reticle -- so it stays in normal flow with opacity 0 rather
   than being display:none by default. Coloured by onKillMark() in app.js,
   which just adds one of the three classes; nothing here decides what a
   clean kill is, see the classifier in client.lua.
   Four dart-shaped wedges baked into a PNG per colour (icons/hitmark_*.png)
   rather than a stroked SVG X -- the reference's marks are filled shapes
   with their own black outline, not thin crossing lines, so a flat
   currentColor stroke couldn't reproduce them. */
#cross-kill {
    position: absolute;
    left: 50%;
    top: 50%;
    width: calc(2.2 * var(--w));
    height: calc(2.2 * var(--w));
    transform: translate(-50%, -50%) scale(0.6);
    opacity: 0;
    background-repeat: no-repeat;
    background-position: center;
    background-size: contain;
    transition: opacity 320ms ease, transform 320ms cubic-bezier(0.2, 0.8, 0.3, 1.3);
}
#cross-kill.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
#cross-kill.kill-red {
    background-image: url('icons/hitmark_red.png');
    filter: drop-shadow(0 0 0.3cqh rgba(229, 72, 77, 0.7));
}
#cross-kill.kill-yellow {
    background-image: url('icons/hitmark_yellow.png');
    filter: drop-shadow(0 0 0.3cqh rgba(232, 197, 106, 0.7));
}
#cross-kill.kill-white {
    background-image: url('icons/hitmark_white.png');
    filter: drop-shadow(0 0 0.3cqh rgba(242, 242, 239, 0.6));
}

/* -----------------------------------------------------------------------------
   Third eye — 2+-option interact reticle
   -----------------------------------------------------------------------------
   Screen centre like #crosshair above, but a SEPARATE element -- that one is
   gated to an armed weapon actually being aimed (see its own header
   comment), and this has to show while unarmed too, since opening a
   vehicle's interact menu is usually not done with a gun out. The actual
   eye glyph (index.html's #third-eye-svg) is ox_target's own pre-qbcore_vi_vice_hud
   icon restored, not an invented shape -- styled here to match the same
   cream fill + hard dark outline every other prompt in this file uses, so
   it reads as part of the same system rather than a different one. Fed by
   exports.qbcore_vi_vice_hud:SetThirdEyeActive(bool), which ox_target only calls
   while its own eye keybind (Left Alt by default) is actually held -- see
   that resource's eyeKeyHeld.
   -------------------------------------------------------------------------- */
#third-eye {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 0;
    height: 0;
    pointer-events: none;
}
#third-eye.hidden { display: none !important; }
#third-eye-svg {
    position: absolute;
    left: 50%;
    top: 50%;
    width: calc(1.6 * var(--w));
    height: calc(1.6 * var(--w));
    transform: translate(-50%, -50%);
    fill: #f4f0e8;
    filter:
        drop-shadow(0 0 0.1cqh rgba(0, 0, 0, 0.95))
        drop-shadow(0 0.1cqh 0.3cqh rgba(0, 0, 0, 0.6));
}

/* -----------------------------------------------------------------------------
   Directional police glow
   -----------------------------------------------------------------------------
   Every layer is FULL VIEWPORT. The previous version used sized boxes
   (30vw wide, 70% tall) and any gradient that hadn't fully reached transparent
   by the box edge left a visible rectangle on screen — that was the grey box.
   Full-bleed layers with the falloff finishing well inside the viewport cannot
   produce an edge at any resolution or aspect ratio.
   -------------------------------------------------------------------------- */
#police {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 1;
}

.pol {
    position: fixed;
    inset: 0;
    opacity: 0;
    transition: opacity 420ms cubic-bezier(0.4, 0, 0.2, 1);
    will-change: opacity;
}
.pol > span { position: absolute; inset: 0; display: block; }

/* A real lightbar does not cross-fade. It fires short, hard bursts: a double
   tap of red, a double tap of blue half a cycle later, with the white takedown
   popping between them. Smooth easing is what made this read as "colours on the
   screen" rather than light. The stops are paired a hair apart (6.9% -> 7%) so
   each transition is a ~6ms ramp: visually instant, but no aliasing.

   Red and blue also drift a little (polSweepX/Y below), running in opposite
   phase, so they read as two independent rotating lamps rather than one blob
   that changes colour in place. */
.pol-left  .pol-r, .pol-right  .pol-r { animation: polRed  var(--pol-flash, 900ms) linear infinite, polSweepY var(--pol-sweep-ms, 2600ms) ease-in-out infinite; }
.pol-left  .pol-b, .pol-right  .pol-b { animation: polBlue var(--pol-flash, 900ms) linear infinite, polSweepY var(--pol-sweep-ms, 2600ms) ease-in-out infinite reverse; }
.pol-top   .pol-r, .pol-bottom .pol-r { animation: polRed  var(--pol-flash, 900ms) linear infinite, polSweepX var(--pol-sweep-ms, 2600ms) ease-in-out infinite; }
.pol-top   .pol-b, .pol-bottom .pol-b { animation: polBlue var(--pol-flash, 900ms) linear infinite, polSweepX var(--pol-sweep-ms, 2600ms) ease-in-out infinite reverse; }
.pol-w { animation: polWhite var(--pol-flash, 900ms) linear infinite; }

/* Gentle drift around each lamp's anchor point (see the per-edge positions
   below) — small enough to stay anchored to the edge, big enough to read as a
   beacon sweeping rather than sitting still. --pol-sweep (vh/vw) and
   --pol-sweep-ms are tunable from the /hudpolice edit panel. */
@keyframes polSweepY {
    0%, 100% { transform: translateY(calc(var(--pol-sweep, 1.6) * -1vh)); }
    50%      { transform: translateY(calc(var(--pol-sweep, 1.6) * 1vh)); }
}
/* vw, not --w: this is the X drift for the LEFT/RIGHT lamps, so it has the
   same width-tracks-too-far bug raw cqw would -- 1vw is exactly 1cqw's
   sibling (both 1% of the same full-viewport-width box), so it converts the
   same way. The Y version above stays in vh: height doesn't have this bug
   (see the header note on --w), so 1vh is already a constant physical drift
   at every aspect ratio. */
@keyframes polSweepX {
    0%, 100% { transform: translateX(calc(var(--pol-sweep, 1.6) * -1 * var(--w))); }
    50%      { transform: translateX(calc(var(--pol-sweep, 1.6) * var(--w))); }
}

@keyframes polRed {
    0%,   6.9%  { opacity: 1; }
    7%,   13.9% { opacity: 0; }
    14%,  20.9% { opacity: 1; }
    21%,  100%  { opacity: 0; }
}
@keyframes polBlue {
    0%,   49.9% { opacity: 0; }
    50%,  56.9% { opacity: 1; }
    57%,  63.9% { opacity: 0; }
    64%,  70.9% { opacity: 1; }
    71%,  100%  { opacity: 0; }
}
/* Short, bright, and off-beat from both colours - that stagger is most of what
   sells it as a rotating bar rather than two lamps taking turns. */
@keyframes polWhite {
    0%,   22.9% { opacity: 0; }
    23%,  25.9% { opacity: 1; }
    26%,  72.9% { opacity: 0; }
    73%,  75.9% { opacity: 1; }
    76%,  100%  { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
    .pol-left .pol-r, .pol-right .pol-r, .pol-top .pol-r, .pol-bottom .pol-r,
    .pol-left .pol-b, .pol-right .pol-b, .pol-top .pol-b, .pol-bottom .pol-b,
    .pol-w { animation: none; }
    .pol-r { opacity: 0.55; }
    .pol-b { opacity: 0.55; }
    .pol-w { opacity: 0; }
}

/* Two small lamps per edge, not one wash across half the screen — each stays
   tucked into its corner of the edge and fades out well before reaching the
   far side or the opposite lamp. --pol-lamp-a/b (ellipse radii, % of
   viewport), --pol-spread (how far apart the two sit) and --pol-softness (the
   falloff stop — lower is a harder edge) are tunable from /hudpolice edit.

   The ellipse is ALWAYS "a wide, b tall" here, on every edge — it used to
   swap to "b wide, a tall" on top/bottom so the long axis stayed parallel to
   whichever edge it was on, which sounded right but actually meant the same
   lamp looked like a different shape depending which edge it landed on
   (tall on the sides, short and wide on top/bottom). Same numbers, same
   shape, everywhere now; only the position swaps per edge.

   UNIT CHOICE, deliberately mixed:
   The gradient box here is `.pol > span`, which is `position:fixed; inset:0`
   -- full physical viewport, same as .stage. A percentage resolves against
   that box's OWN width for the first (horizontal) radius and its OWN height
   for the second (vertical) one, so "16%" horizontal is exactly the cqw bug
   this whole pass exists to fix (it grows with viewport width on a wide
   screen), while "24%" vertical already tracks height and is fine as-is,
   same as any other cqh value in this file. So the FIRST number of each pair
   converts to `var(--w)` (which is calibrated to equal "1% of width" at the
   16:9 reference, see --w's own comment) and the SECOND stays a plain
   percent. --pol-spread gets the same split: on the left/right lamps it
   offsets the pair vertically (percent of height, left alone); on the
   top/bottom lamps it offsets them horizontally (percent of width, so it
   converts too) -- otherwise the two lamps on a top/bottom edge would sit a
   width-scaled distance apart on an ultrawide instead of the reference's
   fixed physical spacing. */
.pol-left   .pol-r { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at 0% calc(50% - var(--pol-spread, 24) * 1%),   var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }
.pol-left   .pol-b { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at 0% calc(50% + var(--pol-spread, 24) * 1%),   var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }
.pol-right  .pol-r { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at 100% calc(50% - var(--pol-spread, 24) * 1%), var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }
.pol-right  .pol-b { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at 100% calc(50% + var(--pol-spread, 24) * 1%), var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }
.pol-top    .pol-r { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at calc(50% - var(--pol-spread, 24) * var(--w)) 0%,   var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }
.pol-top    .pol-b { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at calc(50% + var(--pol-spread, 24) * var(--w)) 0%,   var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }
.pol-bottom .pol-r { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at calc(50% - var(--pol-spread, 24) * var(--w)) 100%, var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }
.pol-bottom .pol-b { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at calc(50% + var(--pol-spread, 24) * var(--w)) 100%, var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }

/* Punchier than before: these are meant to feel like light spilling in from
   off-camera, and at 0.62 they were closer to a tint. */
.pol-r { --polc: rgba(255, 42, 58, 0.92); }
.pol-b { --polc: rgba(64, 118, 255, 0.92); }
/* White reaches less far by default (--pol-white-w/h default smaller than
   --pol-lamp-a/b). A takedown is a tight hot spot, not a wash - letting it
   cover the same area as the colours just greys the screen out.
   --pol-white-a is the takedown's own intensity, tunable separately from the
   red/blue brightness slider. Same "always a-wide-b-tall" rule as above, and
   the same width-radius-converts/height-radius-stays split as the lamps. */
.pol-w { --polc: rgba(255, 250, 240, var(--pol-white-a, 0.70)); }
#police.no-white .pol-w { display: none; }
.pol-left   .pol-w { background: radial-gradient(ellipse calc(var(--pol-white-w, 19) * var(--w)) calc(var(--pol-white-h, 30) * 1%) at 0% 50%,   var(--polc) 0%, transparent 58%); }
.pol-right  .pol-w { background: radial-gradient(ellipse calc(var(--pol-white-w, 19) * var(--w)) calc(var(--pol-white-h, 30) * 1%) at 100% 50%, var(--polc) 0%, transparent 58%); }
.pol-top    .pol-w { background: radial-gradient(ellipse calc(var(--pol-white-w, 19) * var(--w)) calc(var(--pol-white-h, 30) * 1%) at 50% 0%,   var(--polc) 0%, transparent 58%); }
.pol-bottom .pol-w { background: radial-gradient(ellipse calc(var(--pol-white-w, 19) * var(--w)) calc(var(--pol-white-h, 30) * 1%) at 50% 100%, var(--polc) 0%, transparent 58%); }

/* -----------------------------------------------------------------------------
   Radius police glow
   -----------------------------------------------------------------------------
   The alternative to the four fixed edges above: one lamp pair positioned by
   --pol-rx/ry (red) and --pol-bx/by (blue), set directly in percent by
   client.lua from the actual bearing to the siren — so a car to the
   south-west lands in the bottom-left corner instead of lighting the whole
   left edge and the whole bottom edge as two separate glows. Shares the same
   flash keyframes, --pol-lamp-a/b, and --pol-softness as edges mode so
   switching modes doesn't require retuning the shape.
   -------------------------------------------------------------------------- */
#police-radius {
    position: fixed;
    inset: 0;
    opacity: 0;
    pointer-events: none;
    z-index: 1;
    transition: opacity 420ms cubic-bezier(0.4, 0, 0.2, 1);
}
#police-radius > span { position: absolute; inset: 0; display: block; }

.polr-r { animation: polRed  var(--pol-flash, 900ms) linear infinite; }
.polr-b { animation: polBlue var(--pol-flash, 900ms) linear infinite; }
.polr-w { animation: polWhite var(--pol-flash, 900ms) linear infinite; }

/* Same width-radius-converts/height-radius-stays split as the edges mode
   above -- see the comment there. --pol-rx/ry/bx/by/wx/wy are genuine
   POSITIONS (client.lua's live bearing calc), left untouched. */
.polr-r { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at var(--pol-rx, 50%) var(--pol-ry, 0%), var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }
.polr-b { background: radial-gradient(ellipse calc(var(--pol-lamp-a, 16) * var(--w)) calc(var(--pol-lamp-b, 24) * 1%) at var(--pol-bx, 100%) var(--pol-by, 50%), var(--polc) 0%, transparent calc(var(--pol-softness, 68) * 1%)); }
.polr-w { background: radial-gradient(ellipse calc(var(--pol-white-w, 19) * var(--w)) calc(var(--pol-white-h, 30) * 1%) at var(--pol-wx, 75%) var(--pol-wy, 25%), var(--polc) 0%, transparent 58%); }

.polr-r { --polc: rgba(255, 42, 58, 0.92); }
.polr-b { --polc: rgba(64, 118, 255, 0.92); }
.polr-w { --polc: rgba(255, 250, 240, var(--pol-white-a, 0.70)); }
#police-radius.no-white .polr-w { display: none; }

/* GTA VI police lights + arrow. A full-bleed canvas app.js draws on only
   while the row or the arrow is showing (see "GTA VI police lights" there);
   the classic lamps above sit at opacity 0 while it is in use. */
#police-vi {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    z-index: 1;
}

@media (prefers-reduced-motion: reduce) {
    .polr-r, .polr-b, .polr-w { animation: none; }
    .polr-r { opacity: 0.55; }
    .polr-b { opacity: 0.55; }
    .polr-w { opacity: 0; }
}

/* -----------------------------------------------------------------------------
   Minimap frame
   -----------------------------------------------------------------------------
   Sits exactly on the native minimap's rect (same vars the slot stack uses), so
   the map reads as a framed panel rather than a soft-edged blob. The mask
   texture handles clipping the map content; this handles the boundary.
   -------------------------------------------------------------------------- */
#map-frame {
    position: absolute;
    left: var(--map-left, 1.75cqw);
    bottom: var(--map-bottom-edge, 2.6cqh);
    /* Sized from the map's rect, then scaled per axis. Done on width/height
       rather than with a transform so the border stays a hairline and the
       left/bottom anchor stays exact -- the frame's whole job is to sit on the
       map, and the map's rect is a different shape from the frame that has to
       land on it, so the two axes have to move independently. */
    width:  calc(var(--map-width, 15.8cqw) * var(--sc-mapframe-x, 1));
    height: calc(var(--map-height, 15.4cqh) * var(--sc-mapframe-y, 1));
    border: calc(0.085 * var(--w)) solid rgba(236, 236, 240, 0.30);
    border-radius: calc((calc(0.62 * var(--w))) * var(--rad-mapframe, 1));
    /* --shadow-md rather than the one-off 0 0.25cqh 0.6cqh rgba(0,0,0,0.42)
       that used to be here. It was within a hair of the token and had no reason
       to be its own value: the frame and the two panels stacked on top of it
       are one object in the corner of the screen, and one object should not
       cast three subtly different shadows. The inset stays — it is doing
       something the token does not, darkening the map's own edge inward.

       The top-lit inset is the same rule .slot carries, and is the other half
       of making these read as one material: every plate in this corner is now
       brightest along its top edge, as if lit from above. The frame was the
       one piece drawing a flat outline instead. */
    box-shadow:
        var(--shadow-md),
        inset 0 0 0.5cqh rgba(0, 0, 0, 0.22),
        inset 0 0.09cqh 0 rgba(255, 255, 255, 0.055);
    pointer-events: none;
}

/* -----------------------------------------------------------------------------
   The drawn minimap (map3d.js)
   -----------------------------------------------------------------------------
   The map's rect, grown by a tenth of its height on every side: the island is
   painted into the middle, and the overhang is there for a blip pinned to the
   edge to sit across it. While the drawn map stands in for the radar the rect
   is the single-player minimap's panel (ViceMap3D.rect in client_map3d.lua),
   and the map is drawn as single player draws it -- its own corners, outline,
   badge and distance -- so it takes none of the frame's /movehud nudges, and
   the HUD's own frame, badge and distance figure step aside (body.map3d-on,
   set by map3d.js).
   -------------------------------------------------------------------------- */
#map3d {
    --m3d: calc(var(--map-height, 15.4cqh) * 0.1);
    position: absolute;
    left: calc(var(--map-left, 1.75cqw) - var(--m3d));
    bottom: calc(var(--map-bottom-edge, 2.6cqh) - var(--m3d));
    width:  calc(var(--map-width, 15.8cqw) + 2 * var(--m3d));
    height: calc(var(--map-height, 15.4cqh) * 1.2);
    pointer-events: none;
}
.map3d-on #map-frame,
.map3d-on #map-badge,
.map3d-on #nav-compass {
    display: none !important;
}

/* -----------------------------------------------------------------------------
   Map badge
   -----------------------------------------------------------------------------
   A flat glyph in the map's own top-left corner. Anchored off the same
   --map-* rect #map-frame uses: left matches the frame's left edge, and the
   top edge is derived the same way --map-bottom-edge is (bottom-edge +
   height = the map's top), since nothing publishes a top offset directly. */
#map-badge {
    position: absolute;
    /* The map's own rect (--map-left/-bottom-edge/-height) is left in raw cqw/
       cqh -- it is a snapshot of the native, aspect-bucket-managed minimap
       rect and out of scope here (see the header comment on #map-frame). The
       fixed 0.4 inset and the badge's own 2.1 size are this element's own
       geometry, though, so those two convert to --w the same as any other
       icon size/margin. */
    left: calc(var(--map-left, 1.75cqw) + calc(0.4 * var(--w)));
    bottom: calc(var(--map-bottom-edge, 2.6cqh) + var(--map-height, 15.4cqh) - 0.4cqh - calc(2.1 * var(--w)));
    width: calc(2.1 * var(--w));
    height: calc(2.1 * var(--w));
    object-fit: contain;
    pointer-events: none;
    z-index: 3;
}
#map-badge {
    transform: translate(var(--off-mapbadge-x,0cqw), var(--off-mapbadge-y,0cqh)) scale(var(--sc-mapbadge-x,1), var(--sc-mapbadge-y,1));
    transform-origin: top left;
    /* Base 0.62 is the "slightly transparent" look asked for; the editor's
       op row multiplies on top of that, same convention as vehlogo's 0.42. */
    opacity: calc(0.62 * var(--op-mapbadge, 1));
}

/* -----------------------------------------------------------------------------
   Nav popup
   -----------------------------------------------------------------------------
   ONE bar: instruction + distance on the left, a full-height turn tile flush
   to the right edge. Built to match the reference overlay, which is why a few
   things here deliberately break this file's usual habits:

     - The surface is a FLAT dark fill, not `.plate`. The plate's gradient and
       sheen are this HUD's own material; the reference bar is matte, and the
       sheen on it was the most obvious "not the same thing" tell.
     - The type is HelveticaNeueHUD, set EXPLICITLY rather than inherited.
       #slots defaults to GTAArtDeco (see the note by the font rows further
       down) and the nav bar is the one panel in that stack that must not take
       it -- the reference sets this in a plain bold grotesque, and Art Deco's
       flat terminals read as a different UI entirely at this size.
     - The turn tile is FULL BLEED: it runs the whole height of the bar and
       right up to its edge, clipped by the bar's own radius (which is what
       the overflow:hidden is for). It is not an inset icon chip -- that was
       the "green area is too small" this went through several rounds of.

   NOT independently positioned: it is the last child of #slots (see
   index.html), so the same column-reverse flow that stacks the vehicle panel
   above the zone bar stacks this above both, growing and shrinking with
   whatever else is showing. The editor's generic x/y/scale rows (navpopup)
   still nudge it via the transform below, same as every other element. */
#nav-popup {
    width: 100%;
    display: flex;
    flex-direction: column;
    pointer-events: none;
}
#nav-popup { transform: translate(var(--off-navpopup-x,0cqw), var(--off-navpopup-y,0cqh)) scale(var(--sc-navpopup-x,1), var(--sc-navpopup-y,1)); transform-origin: bottom left; opacity: var(--op-navpopup, 1); }
#nav-popup { -webkit-font-smoothing: var(--sm-navpopup, antialiased); }

/* #nav-popup carries its own editor-tuned position/scale transform above, so
   it CANNOT use the generic .slot-in entrance: that keyframe drives the
   `transform` property directly and, with fill-mode `both`, pins the element
   at identity transform for as long as slot-in stays applied -- i.e. the
   whole time the popup is on screen, silently discarding the editor's offset
   and scale, not just during the animation. Same story for #nav-compass,
   which has its own absolute-position transform. Both get a plain opacity
   fade instead -- higher specificity (id + class) than `.slot.slot-in`, so it
   wins without touching the shared rule the other slot panels rely on. */
@keyframes navFadeIn { from { opacity: 0; } to { opacity: 1; } }
#nav-popup.slot-in, #nav-compass.slot-in {
    animation: navFadeIn 280ms ease both;
}
@media (prefers-reduced-motion: reduce) {
    #nav-popup.slot-in, #nav-compass.slot-in { animation: none; }
}

#nav-turn {
    /* One number drives the bar: its own height, the tile's size and the type
       scale all come off this, so the bar resizes as a unit instead of three
       values drifting apart.

       cqh, and the type in this bar is in --w (never raw cqw) -- both track
       viewport HEIGHT, so the bar's internal proportions hold at any aspect
       ratio. Sizing the type in cqw instead is exactly the ultrawide bug --w
       exists to prevent: it made this text 50% of the bar height at 21:9
       against the 37% it is at 16:9. See --w's comment near the top. */
    --nav-h: calc(5.6cqh * var(--sc-navbar, 1));
    height: var(--nav-h);
    display: flex;
    align-items: stretch;
    justify-content: space-between;
    background: var(--nav-bg, rgba(35, 37, 46, 0.95));
    border-radius: calc(0.55 * var(--w) * var(--rad-navpopup, 1));
    /* Clips the full-bleed tile to the bar's rounded corner. */
    overflow: hidden;
    box-shadow: var(--shadow-md), var(--hairline);
}
#nav-turn-copy {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 0.12cqh;
    min-width: 0;
    padding: 0 calc(0.7 * var(--w)) 0 calc(0.95 * var(--w));
}
/* Explicit family, not `inherit` -- see the header note above. */
#nav-turn-text {
    font-family: var(--ff-navpopup, 'HelveticaNeueHUD', 'Helvetica Neue', sans-serif);
    font-weight: var(--fw-navpopup, 700);
    font-size: calc(1.18 * var(--w) * var(--fs-navpopup, 1) * var(--fit, 1));
    line-height: 1.12;
    color: #fff;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
#nav-turn-dist {
    font-family: var(--ff-navpopup, 'HelveticaNeueHUD', 'Helvetica Neue', sans-serif);
    font-weight: var(--fw-navpopup-dist, 500);
    font-size: calc(0.95 * var(--w) * var(--fs-navpopup, 1) * var(--fit, 1));
    line-height: 1.12;
    color: rgba(255, 255, 255, 0.82);
    white-space: nowrap;
}

/* The turn tile. Square, full height, flush right.
   Two glyphs share the svg (see index.html): a plain up-arrow for "straight"
   and a hooked turn glyph for everything else, swapped by [data-dir] rather
   than one shape stretched to mean four different things. Left is the glyph's
   own drawn direction; right is that path mirrored, u-turn is it rotated a
   half turn -- one curve authored once, not three. Stroked, not filled: a
   road bending is a LINE, and a filled chevron rotated 90 degrees reads as
   "bigger arrow", not "turn". */
#nav-turn-arrow {
    width: calc(var(--nav-h) * var(--ic-navpopup, 1));
    height: 100%;
    flex-shrink: 0;
    /* Three-deep: an explicit /movehud customisation (--nav-accent, set by
       the "Turn tile colour" editor row) always wins; failing that, the
       character-gender default client.lua pushes once it has resolved
       (--nav-accent-gender-default, a SEPARATE property so applyOffsets()
       clearing an unset --nav-accent on every layout load can't wipe this
       one out too, see onNavAccent in app.js); failing even that (gender not
       resolved yet, or client.lua too old to send it), the teal this always
       shipped with. */
    background: var(--nav-accent, var(--nav-accent-gender-default, #47aba7));
    display: flex;
    align-items: center;
    justify-content: center;
}
#nav-turn-arrow svg {
    width: 58%;
    height: 58%;
    fill: none;
    stroke: var(--nav-accent-ink, var(--nav-accent-ink-gender-default, #0b1a19));
    stroke-width: 2.6;
    stroke-linecap: round;
    stroke-linejoin: round;
    transition: transform 180ms ease;
}
#nav-turn-arrow .arrow-turn { display: none; }
#nav-turn-arrow[data-dir="left"]  .arrow-straight,
#nav-turn-arrow[data-dir="right"] .arrow-straight,
#nav-turn-arrow[data-dir="uturn"] .arrow-straight { display: none; }
#nav-turn-arrow[data-dir="left"]  .arrow-turn,
#nav-turn-arrow[data-dir="right"] .arrow-turn,
#nav-turn-arrow[data-dir="uturn"] .arrow-turn { display: inline; }
#nav-turn-arrow[data-dir="right"] svg { transform: scaleX(-1); }
#nav-turn-arrow[data-dir="uturn"] svg { transform: rotate(180deg); }

/* -----------------------------------------------------------------------------
   Nav remaining-distance figure
   -----------------------------------------------------------------------------
   The whole-route distance, sitting in the map's own top-left corner the way
   the reference puts it -- ON the map, not in the panel stack above it, so it
   is anchored to the live --map-* vars #map-frame reads (see the note there:
   --map-bottom is screen-bottom to the map's TOP edge).

   The needle an earlier version drew here is gone: no bearing is sent from
   client.lua so it was purely decorative, and the reference has no needle
   either -- just the figure. */
#nav-compass {
    position: absolute;
    left: calc(var(--map-left, 1.75cqw) + 0.5 * var(--w));
    bottom: calc(var(--map-bottom, 17.9cqh) - 2.1cqh);
    display: flex;
    align-items: center;
    pointer-events: none;
}
#nav-compass {
    transform: translate(var(--off-navcompass-x,0cqw), var(--off-navcompass-y,0cqh)) scale(var(--sc-navcompass-x,1), var(--sc-navcompass-y,1));
    transform-origin: top left;
    opacity: var(--op-navcompass, 1);
}
#nav-compass-dist {
    font-family: var(--ff-navcompass, 'HelveticaNeueHUD', 'Helvetica Neue', sans-serif);
    font-weight: var(--fw-navcompass, 500);
    font-size: calc(0.88 * var(--w) * var(--fs-navcompass, 1));
    color: #fff;
    text-shadow: 0 0.1cqh 0.3cqh rgba(0, 0, 0, 0.85);
    white-space: nowrap;
    -webkit-font-smoothing: var(--sm-navcompass, antialiased);
}

/* -----------------------------------------------------------------------------
   Exhaustion vignette
   -----------------------------------------------------------------------------
   Darkens and closes in from the screen edges as stamina empties, with a slow
   breathing pulse. Kept mild on purpose — it is a "you are winded" cue, not a
   damage overlay, so it must never fight the HUD for attention.

   UNIT CHOICE (audited during the --w aspect-ratio pass, deliberately NOT
   touched): --exhaust-w/-h are plain PERCENTAGES of this element's own box
   (#exhaust is `position: fixed; inset: 0`, i.e. the full physical viewport),
   not cqw/cqh, so they were never subject to the bug the rest of this file's
   --w conversion fixes. That is also the CORRECT unit for a full-screen
   vignette specifically, unlike an icon or a panel: a vignette's job is to
   frame whatever screen it is on, so its aperture should scale with both the
   viewport's width AND height and keep the same RELATIVE coverage at every
   aspect ratio, not stay a fixed physical size the way a HUD element does.
   Converting these to --w would do the opposite of what's wanted here: on an
   ultrawide the aperture would stay pinned to its 16:9 physical size while
   the screen grew around it, leaving a widescreen gap of un-vignetted space
   down the middle instead of a proportionally-framed edge. Same reasoning
   applies to #focus-fx's `150% 135%` gradient below -- left alone too. */
#exhaust {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 1;
    opacity: 0;
    transition: opacity 500ms ease;
    /* --exhaust-tunnel closes the aperture as you tire: the clear centre
       shrinks, so the edges creep inward rather than just darkening. */
    background:
        radial-gradient(ellipse var(--exhaust-w, 74%) var(--exhaust-h, 68%) at 50% 50%,
            transparent 30%,
            rgba(8, 4, 10, 0.42) 62%,
            rgba(5, 2, 7, 0.88) 88%,
            rgba(3, 1, 4, 0.97) 100%);
}

#exhaust.breathing {
    animation: exhaustPulse var(--exhaust-pulse, 1100ms) ease-in-out infinite;
}

/* Multiplies the base opacity set from Lua, so the pulse rides on top of the
   level rather than replacing it. */
@keyframes exhaustPulse {
    0%, 100% { filter: brightness(1); }
    50%      { filter: brightness(1.5); }
}

@media (prefers-reduced-motion: reduce) {
    #exhaust.breathing { animation: none; }
}

/* Focus vignette -------------------------------------------------------
   The NUI is composited OVER the 3D game as a transparent layer, not mixed
   with it, so nothing in here can desaturate or blur the world itself --
   only draw on top of it. This is that: a purple vignette that closes the
   edges in and a pulsing inner glow, in the same colour as the focus bar,
   so the bar and the screen read as one signal instead of two.

   --fxstrength-focusfx / --fxpulse-focusfx / --fxspeed-focusfx are the
   editor's three rows for this element (Vignette strength / Pulse size /
   Pulse speed, /movehud > Focus screen effect) -- multipliers on the alpha,
   reach and blur set here, and on the keyframes' own swing below, so 1.00
   everywhere reproduces exactly this shipped look. */
#focus-fx {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 1;
    opacity: 0;
    transition: opacity 450ms ease;
    background:
        radial-gradient(ellipse 150% 135% at 50% 50%,
            transparent 38%,
            rgba(58, 30, 92, calc(0.32 * var(--fxstrength-focusfx, 1))) 70%,
            rgba(28, 10, 52, calc(0.68 * var(--fxstrength-focusfx, 1))) 100%);
    /* Blur DEPTH, not the ellipse's own coverage -- unlike the two-axis radial
       gradient above (left as plain %, see the header note on #exhaust for
       why a full-screen vignette's aperture should scale with the viewport
       in both directions), a single box-shadow blur radius has only one
       number to give both axes, so leaving it in cqw would grow the glow's
       reach disproportionately on a wide screen the same way an icon would.
       calc(7 * var(--w)) keeps it a constant physical depth instead. */
    box-shadow: inset 0 0 calc(7 * var(--w) * var(--fxstrength-focusfx, 1)) rgba(180, 140, 240, calc(0.55 * var(--fxstrength-focusfx, 1)));
}
#focus-fx.active {
    opacity: 1;
    animation: focusFxPulse var(--fxspeed-focusfx, 1100ms) ease-in-out infinite;
}
/* Base swing raised from the original brightness(1.3)/saturate(1.2) flicker
   -- too small to notice mid-fight -- to a visible breathe: brighter,
   more saturated, AND a touch of scale so the vignette itself grows and
   shrinks each beat instead of just flickering in place. --fxpulse-focusfx
   is a multiplier on top of this baseline, so 0 is dead flat and turning it
   up keeps making the beat bigger past what's shipped here. */
@keyframes focusFxPulse {
    0%, 100% { filter: brightness(1) saturate(1); transform: scale(1); }
    50%      {
        filter: brightness(calc(1 + 0.45 * var(--fxpulse-focusfx, 1)))
                saturate(calc(1 + 0.35 * var(--fxpulse-focusfx, 1)));
        transform: scale(calc(1 + 0.05 * var(--fxpulse-focusfx, 1)));
    }
}
@media (prefers-reduced-motion: reduce) {
    #focus-fx.active { animation: none; }
}

/* -----------------------------------------------------------------------------
   Per-element offsets (HUD editor)
   -----------------------------------------------------------------------------
   Every movable element carries its own translate, driven from Lua and saved
   per player. Offsets are in stage units (cqw/cqh) so a position set on one
   resolution holds on another.
   -------------------------------------------------------------------------- */
/* Each movable element carries its own translate AND scale, both driven from
   the editor and saved per player. Scale uses its own origin per element so a
   corner-anchored item grows toward the screen rather than off it. */
#status   { transform: translate(var(--off-status-x,0cqw),   var(--off-status-y,0cqh))   scale(var(--sc-status-x,1), var(--sc-status-y,1));   transform-origin: top left; opacity: var(--op-status, 1); }
#tells    { transform: translate(var(--off-tells-x,0cqw),    var(--off-tells-y,0cqh))    scale(var(--sc-tells-x,1), var(--sc-tells-y,1));    transform-origin: top right; opacity: var(--op-tells, 1); }
#topright { transform: translate(var(--off-topright-x,0cqw), var(--off-topright-y,0cqh)) scale(var(--sc-topright-x,1), var(--sc-topright-y,1)); transform-origin: top right; opacity: var(--op-topright, 1); }
#ammo-wrap{ transform: translate(var(--off-ammo-x,0cqw),     var(--off-ammo-y,0cqh))     scale(var(--sc-ammo-x,1), var(--sc-ammo-y,1));       transform-origin: top right; opacity: var(--op-ammo, 1); }
#money    { transform: translate(var(--off-money-x,0cqw),    var(--off-money-y,0cqh))    scale(var(--sc-money-x,1), var(--sc-money-y,1));    transform-origin: top right; opacity: var(--op-money, 1); }
#slots    { transform: translate(var(--off-slots-x,0cqw),    var(--off-slots-y,0cqh))    scale(var(--sc-slots-x,1), var(--sc-slots-y,1));    transform-origin: bottom left; opacity: var(--op-slots, 1); }
/* Higher specificity than the plain #slots rule above, so it wins while the
   dock is active. Adds the -50% half-width pullback `left: 50%` on its own
   needs (translate can't read percentages of the element's OWN size from a
   separate property), composed BEFORE the nudge so the nudge reads as "a bit
   left/right of centre" rather than being fought by it. The nudge itself is
   --off-slots-FOOT-x/y, not the plain --off-slots-x/y the rule above reads --
   Foot position X/Y in /movehud (only offered on Map panels, only while the
   dock is actually on screen -- see markTarget()) is a SEPARATE pair from the
   normal Position X/Y, because that one only ever applies while the map is
   up; reusing it here would mean moving one moved the other. */
#slots.map-hidden {
    transform: translate(-50%, 0)
               translate(var(--off-slots-foot-x,0cqw), var(--off-slots-foot-y,0cqh))
               scale(var(--sc-slots-x,1), var(--sc-slots-y,1));
    transform-origin: bottom center;
}
#wanted   { transform: translate(var(--off-wanted-x,0cqw),   var(--off-wanted-y,0cqh))   scale(var(--sc-wanted-x,1), var(--sc-wanted-y,1));   transform-origin: top left; opacity: var(--op-wanted, 1); }
#honor    { transform: translate(var(--off-honor-x,0cqw),    var(--off-honor-y,0cqh))    scale(var(--sc-honor-x,1), var(--sc-honor-y,1));    transform-origin: bottom right; opacity: var(--op-honor, 1); }
#reputation { transform: translate(var(--off-reputation-x,0cqw), var(--off-reputation-y,0cqh)) scale(var(--sc-reputation-x,1), var(--sc-reputation-y,1)); transform-origin: bottom left; opacity: var(--op-reputation, 1); }
#prompts  { transform: translate(var(--off-prompts-x,0cqw),  var(--off-prompts-y,0cqh))  scale(var(--sc-prompts-x,1), var(--sc-prompts-y,1));  transform-origin: bottom right; opacity: var(--op-prompts, 1); }
#honor-pop{
    transform: translate(-50%, -50%)
               translate(var(--off-honorpop-x,0cqw), var(--off-honorpop-y,0cqh))
               scale(var(--sc-honorpop-x,1), var(--sc-honorpop-y,1));
    transform-origin: center;
    opacity: var(--op-honorpop, 1);
}
#money-pop{
    transform: translate(-50%, -50%)
               translate(var(--off-moneypop-x,0cqw), var(--off-moneypop-y,0cqh))
               scale(var(--sc-moneypop-x,1), var(--sc-moneypop-y,1));
    transform-origin: center;
    opacity: var(--op-moneypop, 1);
}
#reputation-pop{
    transform: translate(-50%, -50%)
               translate(var(--off-reputationpop-x,0cqw), var(--off-reputationpop-y,0cqh))
               scale(var(--sc-reputationpop-x,1), var(--sc-reputationpop-y,1));
    transform-origin: center;
    opacity: var(--op-reputationpop, 1);
}
#map-frame{ transform: translate(var(--off-mapframe-x,0cqw), var(--off-mapframe-y,0cqh)); transform-origin: bottom left; opacity: var(--op-mapframe, 1); }
/* Centre origin, unlike the corner-anchored panels: the pip row sits in the
   middle of the vehicle panel, so growing it from a corner would walk it out of
   the plate rather than swelling it in place. */
/* The mark carries the same centring translate as #honor-pop: the -50% pair
   puts it in the middle of the panel, and the editor's offset is applied AFTER
   so nudging it does not have to know what the centring was.
   The 0.42 is the shipped strength, and it multiplies rather than being
   replaced -- an untouched Opacity row of 1.00 renders the design, and turning
   the row down fades the mark out from there. It is low on purpose: this sits
   directly behind the make and model, and a watermark that competes with the
   text it is behind has failed at the one thing it has to do. */
#veh-logo {
    transform: translate(-50%, -50%)
               translate(var(--off-vehlogo-x,0cqw), var(--off-vehlogo-y,0cqh))
               scale(var(--sc-vehlogo-x,1), var(--sc-vehlogo-y,1));
    transform-origin: center;
    /* 0.42 was tuned against the old warm --plate. On the flatter, darker
       --panel the same value reads as hard grey BLOCKS with a visible straight
       cut where the clip crops them -- particularly the wide, angular marks
       (Truffade, Benefactor), which have no round silhouette to soften them.
       This is a watermark: it should be felt, not read. Still multiplied by
       the editor's Vehicle badge -> Opacity row, so 1.00 there is this. */
    opacity: calc(0.22 * var(--op-vehlogo, 1));
}
#veh-pips { transform: translate(var(--off-vehpips-x,0cqw), var(--off-vehpips-y,0cqh)) scale(var(--sc-vehpips-x,1), var(--sc-vehpips-y,1)); transform-origin: center; opacity: var(--op-vehpips, 1); }

/* -----------------------------------------------------------------------------
   Per-element spacing (HUD editor)
   -----------------------------------------------------------------------------
   A multiplier on the gap between an element's own children. Without it,
   scaling an element's icons or type left the spacing at its original value and
   the result read as crowded or as adrift — the size control alone could never
   produce a proportionate stack. Every fallback is the shipped gap, so an
   untouched element is unchanged.
   -------------------------------------------------------------------------- */
/* Row pitch = icon + gap = 1.45w, i.e. 0.25 of the focus bar (measured off the
   reference: 40.5px pitch on a 162px bar). Was 1.6cqh (~0.9w), which made the
   rows ~40% further apart than the reference. */
#status    { gap: calc(0.28 * var(--w) * var(--sp-status, 1)); }
#topright  { gap: calc(0.14cqh * var(--sp-topright, 1)); }
#tells     { gap: calc(0.4725 * var(--w) * var(--sp-tells, 1)); }
#money     { gap: calc(0.38cqh * var(--sp-money, 1)); }
#slots     { gap: calc(0.45cqh * var(--sp-slots, 1)); }
#wanted    { gap: calc(1.1cqh  * var(--sp-wanted, 1)); }
#honor     { gap: calc(0.8 * var(--w) * var(--sp-honor, 1)); }
#honor-pop { gap: calc(0.35 * var(--w) * var(--sp-honorpop, 1)); }
#money-pop { gap: calc(0.35 * var(--w) * var(--sp-moneypop, 1)); }
#reputation     { gap: calc(0.8 * var(--w) * var(--sp-reputation, 1)); }
#reputation-pop { gap: calc(0.35 * var(--w) * var(--sp-reputationpop, 1)); }
#prompts   { gap: calc(1cqh    * var(--sp-prompts, 1)); }
.srow      { gap: calc(0.145 * var(--w) * var(--sp-status, 1)); }   /* reference: 4px on a 162px bar */

/* -----------------------------------------------------------------------------
   Per-element typeface and text rasterisation (HUD editor)
   -----------------------------------------------------------------------------
   --ff-* used to be written by the editor for EVERY element and read by only
   two of them, so choosing a font on the zone bar, the wanted box, honor or the
   prompts did nothing at all — the row moved, the screen did not. Both
   properties inherit, so setting them on the container reaches the copy inside.

   --sm-* is `-webkit-font-smoothing`, which decides how glyph edges are
   rasterised and was previously hardcoded to `antialiased` for the whole page.
   That is a real lever on how sharp or how soft text looks, and which setting
   wins depends on the display, so it belongs to the player rather than to this
   stylesheet:
     auto         subpixel where the compositor allows it, otherwise grayscale
     antialiased  grayscale — the shipped behaviour, slightly lighter stems
     none         no antialiasing at all: hard edges, genuinely aliased
   -------------------------------------------------------------------------- */
#status    { font-family: var(--ff-status, inherit);    -webkit-font-smoothing: var(--sm-status, antialiased); }
#topright  { -webkit-font-smoothing: var(--sm-topright, antialiased); }
#ammo-wrap { -webkit-font-smoothing: var(--sm-ammo, antialiased); }
#tells     { font-family: var(--ff-tells, inherit);     -webkit-font-smoothing: var(--sm-tells, antialiased); }
#money     { -webkit-font-smoothing: var(--sm-money, antialiased); }
/* GTA Art Deco is the DEFAULT for the two map panels, not just an option in
   the Font row. It is GTA's own display face and it is what the make, the
   model and the zone name are set in; Helvetica stays the fallback for the
   moment before the file loads and for any glyph Art Deco does not carry.
   Both panels inherit from here, so this is the one place that decides it --
   and the Font rows on Map panels / Vehicle panel / Zone bar still override,
   because every one of them reads --ff-* first. */
#slots     { font-family: var(--ff-slots, 'GTAArtDeco', 'HelveticaNeueHUD', 'Helvetica Neue', sans-serif);     -webkit-font-smoothing: var(--sm-slots, antialiased); }
#zone      { -webkit-font-smoothing: var(--sm-zone, inherit); }
#vehicle   { -webkit-font-smoothing: var(--sm-vehicle, inherit); }
#veh-pips  { font-family: var(--ff-vehpips, inherit);   -webkit-font-smoothing: var(--sm-vehpips, inherit); }
#wanted    { font-family: var(--ff-wanted, 'Segoe UI Variable Display', 'Segoe UI', sans-serif);    -webkit-font-smoothing: var(--sm-wanted, antialiased); }
#honor     { font-family: var(--ff-honor, inherit);     -webkit-font-smoothing: var(--sm-honor, antialiased); }
#honor-pop { font-family: var(--ff-honorpop, inherit);  -webkit-font-smoothing: var(--sm-honorpop, antialiased); }
#money-pop { -webkit-font-smoothing: var(--sm-moneypop, antialiased); }
#reputation     { font-family: var(--ff-reputation, inherit);     -webkit-font-smoothing: var(--sm-reputation, antialiased); }
#reputation-pop { font-family: var(--ff-reputationpop, inherit);  -webkit-font-smoothing: var(--sm-reputationpop, antialiased); }
#prompts   { font-family: var(--ff-prompts, 'Bahnschrift', 'DIN Condensed', 'Segoe UI', Arial, sans-serif);   -webkit-font-smoothing: var(--sm-prompts, antialiased); }
#map-frame { -webkit-font-smoothing: var(--sm-mapframe, antialiased); }

/* -----------------------------------------------------------------------------
   Component outlines (/hudrects)
   -----------------------------------------------------------------------------
   A diagnostic, not decoration. Deliberately thin, unfilled and labelled: the
   whole point is to see what is UNDER them, and an outline you cannot name is
   no better than no outline.
   -------------------------------------------------------------------------- */
#map-rects .mrect {
    position: absolute;
    border: 1px solid currentColor;
    pointer-events: none;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55), inset 0 0 0 1px rgba(0, 0, 0, 0.55);
}
#map-rects .mrect span {
    position: absolute;
    left: 0;
    bottom: 100%;
    font-size: calc(0.72 * var(--w));
    line-height: 1.2;
    padding: 0 calc(0.25 * var(--w));
    white-space: nowrap;
    /* Black on the outline's own colour. NOT `background: currentColor` — the
       span sets its own `color` for the text, so currentColor resolves to that
       and every label came out a black bar on black. */
    color: #000;
    font-family: system-ui, sans-serif;
}
#map-rects .mrect span b { color: #000; font-weight: 700; }
#map-rects .m-minimap           { color: #5ce07a; }
#map-rects .m-minimap span      { background: #5ce07a; }
#map-rects .m-minimap_mask      { color: #56d7f2; }
#map-rects .m-minimap_mask span { background: #56d7f2; }
#map-rects .m-minimap_blur      { color: #f2d24f; }
#map-rects .m-minimap_blur span { background: #f2d24f; }
/* Stagger the labels so three overlapping rects do not stack their captions on
   one line and hide each other. */
#map-rects .m-minimap_mask span { bottom: calc(100% + 1.4cqh); }
#map-rects .m-minimap_blur span { bottom: calc(100% + 2.8cqh); }

/* -----------------------------------------------------------------------------
   Shrink-to-fit overflow (see fitBox in app.js)
   -----------------------------------------------------------------------------
   Long names are scaled down to fit their panel. `.fit-wrap` is the last stop
   before that becomes unreadable: at the floor the words are allowed to wrap
   instead of being cut, because "Western Motorcycle Company" does not fit this
   panel at any size you could still read, and half a name is worth less than a
   taller panel. Both panels size from their content, so they simply grow.
   -------------------------------------------------------------------------- */
#zone.fit-wrap #zone-text,
#vehicle.fit-wrap .vname {
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
    /* Break inside a word only when the word alone is wider than the panel —
       a single long model name should not silently vanish either. */
    overflow-wrap: anywhere;
    line-height: 1.05;
}
/* The zone bar has a FIXED height, which would clip a second line straight back
   off. Wrapping turns it into a minimum. */
#zone.fit-wrap {
    height: auto;
    min-height: 3.11cqh;
    padding-top: 0.35cqh;
    padding-bottom: 0.35cqh;
}

/* -----------------------------------------------------------------------------
   Per-element text alignment (HUD editor)
   -----------------------------------------------------------------------------
   One control in /movehud writes TWO custom properties per element:
     --al-<key>   a text-align value   (left | center | right)
     --ai-<key>   the matching flex alignment (flex-start | center | flex-end)
   Two are needed because these panels are flex containers: text-align alone
   moves the glyphs inside a box that is still hard against one edge, and the
   flex value alone does nothing to copy that wraps. Setting both is what makes
   "pull it left / centre it / push it right" behave the way it reads.

   Every fallback below is the value the element already shipped with, so
   "Default" in the editor renders exactly as designed and an untouched layout
   is byte-for-byte the old one.
   -------------------------------------------------------------------------- */
#status            { align-items: var(--ai-status, stretch); }
#topright          { align-items: var(--ai-topright, flex-end); }
#ammo-wrap         { align-items: var(--ai-ammo, flex-end); }
#tells             { justify-content: var(--ai-tells, flex-start); }
#money             { align-items: var(--ai-money, flex-end); }
#slots             { align-items: var(--ai-slots, stretch); }
/* CENTRED, not left, is the shipped default for the map panels: the reference
   centres the zone name and the make/model over the panel, and with the nav
   bar in the same stack a left-ragged zone bar above a centred one read as two
   different components. The editor's Text align row still moves them. */
#zone              { justify-content: var(--ai-slots, center); }
#veh-head          { align-items: var(--ai-slots, center); }
#zone-text, .vname { text-align: var(--al-slots, center); }
#wanted            { align-items: var(--ai-wanted, stretch); }
#wanted-copy       { text-align: var(--al-wanted, left); }
#honor-copy        { align-items: var(--ai-honor, flex-end); text-align: var(--al-honor, right); }
#honor-pop         { justify-content: var(--ai-honorpop, center); }
#money-pop         { justify-content: var(--ai-moneypop, center); }
#reputation-copy   { align-items: var(--ai-reputation, flex-start); text-align: var(--al-reputation, left); }
#reputation-pop    { justify-content: var(--ai-reputationpop, center); }
#prompts           { align-items: var(--ai-prompts, flex-end); }
.prompt            { justify-content: var(--ai-prompts, flex-start); }

/* -----------------------------------------------------------------------------
   HUD editor  (/movehud)  —  "liquid glass"
   -----------------------------------------------------------------------------
   THERE IS NO backdrop-filter HERE, AND THERE CANNOT BE.

   `backdrop-filter` samples the PAGE's own backdrop. This page is a transparent
   overlay: the game is composited underneath by the ENGINE, not by Chromium, so
   as far as the blur is concerned the backdrop is empty. CEF does not treat
   "empty" as "leave it alone" — it rasterises the backdrop root and hands back
   OPAQUE BLACK, in a rectangle that is larger than the element itself.

   That never showed up while the editor drew a full-screen scrim: the scrim was
   the backdrop, so the blur had something real to sample. Remove the scrim and
   the panel sits in a black box with the HUD behind it gone. Both cannot exist
   at once, and of the two, seeing the HUD you are editing is the point.

   So the look has to be carried entirely by things that do NOT depend on a
   backdrop, and that is what everything below is doing:
     · a translucent tint, so the world genuinely shows through
     · a bright specular rim plus an inset top highlight, which is what actually
       reads as "a pane of glass" rather than "a grey box"
     · a soft top-lit gradient and a wide, low shadow for thickness
     · generous radii, so the rim curves the way real glass edges do
   Do not swap the layered fills for one flat colour "to simplify" — the flat
   version is the grey box.
   -------------------------------------------------------------------------- */
#editor {
    position: fixed;
    inset: 0;
    z-index: 100;

    /* NO SCRIM, and no backdrop-filter out here.
       This used to be a full-screen graded wash with a 3px blur over it. The
       game and the ENGINE-DRAWN minimap are composited underneath this page, so
       that wash dimmed the very thing the editor exists to position — the map
       went black, the HUD went murky, and you were tuning by memory. A layout
       tool has to leave the layout visible. */
    background: none;
    pointer-events: none;

    /* The glass tokens now live on :root — see the block near the top. They
       were editor-local while the editor was the only glass surface; three
       surfaces later, each hand-rolling its own near-miss of the same colours,
       is how a design becomes an approximation of itself. */

    /* System UI for the chrome. Helvetica Neue is the HUD's voice; the editor is
       a tool sitting on top of the game, and the platform face is what makes it
       read that way. */
    font-family: system-ui, 'Segoe UI Variable Text', 'Segoe UI', -apple-system,
                 'SF Pro Text', Inter, Roboto, sans-serif;
}
#editor.hidden { display: none !important; }

#editor-panel {
    /* Absolutely placed rather than flex-centred, because it is DRAGGABLE:
       once you pick it up, JS writes left/top in pixels and clears the
       centring transform. Until then it sits in the middle. */
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    pointer-events: auto;

    /* Height-relative, so the panel is the same physical size on 16:9 and
       on a 3440-wide ultrawide instead of growing to half the screen.
       Deliberately narrower than it was: at 52 units it spanned roughly half a
       1080p screen and there was no corner of the HUD it did not cover. */
    /* A STARTING size, not a fixed one: #ed-resize writes width/height in px
       from here on. The max-width used to be min(74vw, 920px), which is what
       made the settings labels ellipsise with no way out — a ceiling on a panel
       whose contents the player controls is a wall. It is now only wide enough
       to stop the panel covering the entire screen. */
    width: calc(46 * var(--w));
    min-width: 560px;
    max-width: 96vw;
    display: flex;
    flex-direction: column;
    max-height: 92cqh;
    overflow: hidden;

    /* Surface comes from .plate — the MAP PANEL material.
       Every surface this HUD draws is now the same plate: the zone bar and
       vehicle panel, this editor, the skills screen, the level-up card, the
       police-light editor, ox_lib's popup layer, and the ox_target / qb-menu /
       qb-input menus. .glass is kept below as a recipe but nothing uses it.
       No backdrop-filter in either recipe — see the note above. */
    border-radius: calc(1.5 * var(--w));
    font-size: calc(1.02 * var(--w));
    color: var(--text);
}
/* Dragged: JS owns left/top from here on, so the centring offset has to go or
   the panel would sit half a panel-width away from the cursor. */
#editor-panel.dragged { transform: none; }

/* Resized: JS owns width/height. max-height has to go with them, or a panel
   dragged taller than 92cqh would be clipped back to it and the grip would
   appear to stop responding partway down. */
#editor-panel.resized { max-height: none; max-width: none; }
#editor-panel.resizing { transition: none; user-select: none; }
/* The body is the part that grows. Without this the two columns keep their
   content height and a taller panel is just more empty plate underneath. */
#editor-panel.resized #editor-body { flex: 1 1 auto; min-height: 0; }

/* The grip itself: bottom-right corner, two hairline strokes. Deliberately not
   an icon font or an SVG — it is two gradients and it scales with the panel.
   Sits above the head's drag handler because it is a corner of the same panel;
   z-index rather than DOM order so it stays last in the tab sequence. */
#ed-resize {
    position: absolute;
    right: 0;
    bottom: 0;
    width: calc(1.5 * var(--w));
    height: calc(1.5 * var(--w));
    cursor: nwse-resize;
    z-index: 3;
    pointer-events: auto;
    background:
        linear-gradient(135deg, transparent 0 45%,
            rgba(255, 255, 255, 0.34) 45% 52%, transparent 52%),
        linear-gradient(135deg, transparent 0 66%,
            rgba(255, 255, 255, 0.22) 66% 73%, transparent 73%);
    border-bottom-right-radius: calc(1.5 * var(--w));
}
#ed-resize:hover { background-color: rgba(255, 255, 255, 0.07); }
/* Collapsed the panel is just its title bar, and there is nothing to resize. */
#editor-panel.collapsed #ed-resize { display: none; }
#editor-panel.dragging { transition: none; cursor: grabbing; }

/* Collapsed (H). The body and the buttons fold away and only the title bar is
   left — small enough to park anywhere, and still draggable, so nothing about
   the editor is ever hidden behind an invisible state. */
#editor-panel.collapsed #editor-body,
#editor-panel.collapsed #editor-actions,
#editor-panel.collapsed #editor-keys { display: none; }
#editor-panel.collapsed #editor-head { border-bottom: none; }
#editor-panel.collapsed { width: auto; min-width: 0; }

/* The whole head is the drag handle. */
#editor-head { cursor: grab; }
#editor-panel.dragging #editor-head { cursor: grabbing; }

/* Peek (hold Space). The panel all but disappears for as long as the key is
   held, so you can check the piece you just nudged against the rest of the HUD
   without moving, collapsing or closing anything. */
#editor-panel { transition: opacity 120ms ease; }
#editor-panel.peek { opacity: 0.07; }

/* The specular cap and the stacking that goes with it now come from .glass. */

#editor-head {
    padding: 1.5cqh calc(1.5 * var(--w)) 1.1cqh;
    border-bottom: 1px solid var(--g-line);
}
#editor-titlerow {
    display: flex;
    align-items: baseline;
    gap: calc(0.7 * var(--w));
    flex-wrap: wrap;
}
#editor-title {
    font-size: calc(1.32 * var(--w));
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--text);
}
#editor-sub {
    font-size: calc(0.88 * var(--w));
    color: rgba(244, 244, 242, 0.55);
}

/* Collapse toggle, pushed to the far end of the title row. */
#ed-collapse {
    margin-left: auto;
    flex: 0 0 auto;
    cursor: pointer;
    font: inherit;
    line-height: 1;
    font-size: calc(0.9 * var(--w));
    padding: 0.4cqh calc(0.55 * var(--w));
    border-radius: calc(0.4 * var(--w));
    color: rgba(244, 244, 242, 0.7);
    background: var(--g-fill);
    border: 1px solid rgba(255, 255, 255, 0.13);
}
#ed-collapse:hover { color: var(--text); background: var(--g-fill-hi); }
#ed-collapse:focus-visible { outline: 2px solid var(--g-accent); outline-offset: 2px; }

/* Shortcut chips. One long sentence of bindings is unreadable; grouped chips
   let the eye land on the one it wants. */
#editor-keys {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.35cqh calc(0.85 * var(--w));
    margin-top: 0.9cqh;
}
#editor-keys .kg {
    display: inline-flex;
    align-items: center;
    gap: calc(0.16 * var(--w));
}
#editor-keys .kg i {
    font-style: normal;
    margin-left: calc(0.28 * var(--w));
    font-size: calc(0.8 * var(--w));
    color: rgba(244, 244, 242, 0.5);
}
#editor-keys .kx { color: rgba(244, 244, 242, 0.35); font-size: calc(0.78 * var(--w)); }
#editor-keys kbd {
    font: inherit;
    font-size: calc(0.76 * var(--w));
    line-height: 1;
    padding: 0.32cqh calc(0.34 * var(--w));
    min-width: calc(1.35 * var(--w));
    text-align: center;
    border-radius: calc(0.28 * var(--w));
    color: rgba(244, 244, 242, 0.86);
    background: var(--g-fill);
    border: 1px solid rgba(255, 255, 255, 0.13);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
}

/* Column captions, so each side of the body says what it is. */
.ed-colhead {
    font-size: calc(0.76 * var(--w));
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: rgba(244, 244, 242, 0.42);
    padding: 0 calc(0.3 * var(--w)) 0.6cqh;
}

#editor-list { list-style: none; overflow-y: auto; flex: 1 1 auto; min-height: 0; }

/* Group heading inside the list. The three kinds of element behave differently
   — page CSS, engine-owned, another resource entirely — and interleaving them
   with no separation was the main reason the list read as a wall. */
#editor-list .ed-group {
    padding: 1.0cqh calc(0.3 * var(--w)) 0.35cqh;
    font-size: calc(0.74 * var(--w));
    font-weight: 600;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: rgba(244, 244, 242, 0.34);
    pointer-events: none;
}
#editor-list .ed-group:first-child { padding-top: 0.1cqh; }

#editor-list .ed-item {
    display: flex;
    align-items: center;
    gap: calc(0.6 * var(--w));
    padding: 0.78cqh calc(0.75 * var(--w));
    border-radius: calc(0.72 * var(--w));
    cursor: pointer;
    color: rgba(244, 244, 242, 0.72);
    border: 1px solid transparent;
    transition: background 120ms ease, color 120ms ease;
}
#editor-list .ed-item:hover {
    background: rgba(255, 255, 255, 0.05);
    color: var(--text);
}
/* The selected row is its own small pane of glass, lifted off the panel. */
#editor-list .ed-item.sel {
    color: var(--text);
    background:
        linear-gradient(180deg, rgba(255, 255, 255, 0.17), rgba(255, 255, 255, 0.06));
    border-color: rgba(255, 255, 255, 0.18);
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, 0.34),
        0 0.28cqh 0.8cqh rgba(0, 0, 0, 0.26);
}

/* "Changed from default" marker. Answers "what have I actually touched" without
   reading fourteen rows of numbers. */
.ed-dot {
    flex: 0 0 auto;
    width: calc(0.34 * var(--w));
    height: calc(0.34 * var(--w));
    min-width: 5px;
    min-height: 5px;
    border-radius: 50%;
    background: transparent;
}
.ed-dot.on {
    background: var(--g-accent);
    box-shadow: 0 0 calc(0.5 * var(--w)) rgba(95, 216, 200, 0.55);
}

#editor-list .ed-name { font-weight: 500; min-width: 0; flex: 1 1 auto; }
#editor-list .ed-desc {
    font-size: calc(0.8 * var(--w));
    color: rgba(244, 244, 242, 0.44);
    display: block;
    font-weight: 400;
    margin-top: 0.1cqh;
}
#editor-list .ed-val {
    font-variant-numeric: tabular-nums;
    font-size: calc(0.82 * var(--w));
    color: rgba(244, 244, 242, 0.6);
    white-space: nowrap;
    flex: 0 0 auto;
    text-align: right;
}

#editor-actions {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: calc(0.6 * var(--w));
    padding: 1.15cqh calc(1.5 * var(--w));
    border-top: 1px solid var(--g-line);
}
.ed-actgroup { display: flex; gap: calc(0.5 * var(--w)); }
.ed-actgroup-end { margin-left: auto; }

#editor-actions button {
    font: inherit;
    font-size: calc(0.92 * var(--w));
    font-weight: 500;
    white-space: nowrap;
    padding: 0.82cqh calc(1.0 * var(--w));
    border-radius: calc(0.62 * var(--w));
    cursor: pointer;
    color: var(--text);
    border: 1px solid rgba(255, 255, 255, 0.15);
    background: linear-gradient(180deg, var(--g-fill-hi), var(--g-fill));
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.20);
    transition: background 120ms ease, border-color 120ms ease;
}
#editor-actions button:hover {
    background: linear-gradient(180deg, rgba(255, 255, 255, 0.20), rgba(255, 255, 255, 0.10));
    border-color: rgba(255, 255, 255, 0.24);
}
#editor-actions button:active { background: rgba(255, 255, 255, 0.07); }
#editor-actions button.ghost {
    color: rgba(244, 244, 242, 0.72);
    background: rgba(255, 255, 255, 0.045);
    box-shadow: none;
}
#editor-actions button.ghost:hover { color: var(--text); background: rgba(255, 255, 255, 0.10); }
/* The one affirmative action, tinted with the HUD's accent so it is findable
   without being loud. */
#editor-actions button.primary {
    background: linear-gradient(180deg, rgba(95, 216, 200, 0.34), rgba(95, 216, 200, 0.17));
    border-color: rgba(95, 216, 200, 0.46);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28),
                0 0.2cqh 0.7cqh rgba(95, 216, 200, 0.14);
}
#editor-actions button.primary:hover {
    background: linear-gradient(180deg, rgba(95, 216, 200, 0.44), rgba(95, 216, 200, 0.24));
}
#editor-actions button:focus-visible { outline: 2px solid var(--g-accent); outline-offset: 2px; }

/* Thin, self-coloured scrollbars. The default chrome bar is opaque and lands on
   a translucent panel like a strip of tape. */
#editor-list::-webkit-scrollbar,
#editor-props::-webkit-scrollbar { width: calc(0.34 * var(--w)); }
#editor-list::-webkit-scrollbar-thumb,
#editor-props::-webkit-scrollbar-thumb {
    background: rgba(255, 255, 255, 0.16);
    border-radius: calc(1 * var(--w));
}
#editor-list::-webkit-scrollbar-track,
#editor-props::-webkit-scrollbar-track { background: transparent; }

/* The element currently being moved gets a marker so you can see what you're
   dragging without having to guess from the list. */
.ed-target {
    outline: calc(0.14 * var(--w)) dashed var(--g-accent);
    outline-offset: 0.3cqh;
}

/* At full exhaustion the pulse tightens and deepens — this is the state that
   costs health, so it should feel different from merely being winded. */
#exhaust.critical { animation-duration: 620ms !important; }
#exhaust.critical.breathing { animation-name: exhaustPulseHard; }

@keyframes exhaustPulseHard {
    0%, 100% { filter: brightness(1) saturate(0.85); }
    50%      { filter: brightness(1.85) saturate(0.7); }
}
@media (prefers-reduced-motion: reduce) {
    #exhaust.critical.breathing { animation: none; }
}

/* Wanted stars, searching.
   Police lost contact but are still working your last known position: the
   star pulses between a hollow cut-out and a white fill, literal colour same
   as the other three states (see .star.contact/.red/.hollow above), not
   the pink/teal character accent, that's the waypoint/nav bar's own thing. */
.star.searching path {
    animation: starHunt 900ms steps(1, end) infinite;
}
@keyframes starHunt {
    0%, 49%   { fill: transparent; }
    50%, 100% { fill: #f2f2f0; }
}
@media (prefers-reduced-motion: reduce) {
    .star.searching path { animation: none; fill: rgba(242, 242, 240, 0.55); }
}


/* -----------------------------------------------------------------------------
   Honor change indicator
   -------------------------------------------------------------------------- */
/* The reference shows an honor change as a sign and a face in the middle of the
   screen — "-" bad-deed art or "+" good-deed art — not as a titled panel. The
   corner mugshot is a separate, PERSISTENT readout of where your honor
   currently stands. */
#honor-pop {
    position: absolute;
    left: 50%;
    top: 50%;
    /* NO `transform` here. The centring translate(-50%, -50%) lives in the
       layout block above, where it is composed with the editor's offset and
       scale vars. Re-declaring transform in this later rule has the same
       specificity and therefore WINS, which silently threw away
       --off-honorpop-x/y and --sc-honorpop-x/y — that is why /movehud could
       select "Honor +/- popup" but never actually move it. */
    display: flex;
    align-items: center;
    gap: calc(0.35 * var(--w));
    font-size: calc((calc(3.1 * var(--w))) * var(--fs-honorpop, 1));
    line-height: 1;
    text-shadow: 0 0.25cqh 0.5cqh rgba(0, 0, 0, 0.75);
    pointer-events: none;
}
#honor-pop-sign {
    font-weight: var(--fw-honorpop, 700);
    color: #fff;
}
#honor-pop.up   #honor-pop-sign { color: #bfe3c8; }
#honor-pop.down #honor-pop-sign { color: #e3a9bf; }

/* Unrepairable floor: no sign (see index.html's comment), face swapped for
   the terrible-deed art with a crack overlaid, same treatment as the corner
   badge's #honor-badge/#honor-crack (see onHonorPop() in app.js). em-sized so
   it tracks #honor-pop's own font-size without repeating that calc(). */
/* 0.75em, not the 1em a font-size box would suggest for a text glyph -- this
   is a raw <img>, and an emoji glyph carries its own internal margin that a
   bare image doesn't, so 1em here drew the face oversized next to the sign.
   #honor-pop-crack keeps the same 0.72 ratio to the face it overlays. */
#honor-pop-face-wrap { position: relative; display: inline-flex; }
#honor-pop-face {
    width: 0.75em;
    height: 0.75em;
    object-fit: contain;
    display: block;
}
#honor-pop-crack {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: 0.54em;
    height: 0.54em;
    pointer-events: none;
    filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.6));
}

/* Money change popup ----------------------------------------------------------
   Same "sign + icon" shape as honor-pop, but keyed off the amount's own sign
   rather than a separate direction flag -- money has no equivalent of honor's
   "broken" state, so up/down covers every case. */
#money-pop {
    position: absolute;
    left: 50%;
    top: 50%;
    /* NO transform here -- see the note on #honor-pop above, same reason. */
    display: flex;
    align-items: center;
    gap: calc(0.35 * var(--w));
    font-size: calc((calc(3.1 * var(--w))) * var(--fs-moneypop, 1));
    line-height: 1;
    text-shadow: 0 0.25cqh 0.5cqh rgba(0, 0, 0, 0.75);
    pointer-events: none;
}
#money-pop-sign {
    /* Pinned, not tunable -- same reasoning as .money-amt: money is Pricedown
       and stays Pricedown. */
    font-family: 'Pricedown', sans-serif;
    font-weight: var(--fw-moneypop, 700);
    color: #fff;
}
#money-pop.up   #money-pop-sign { color: #bfe3c8; }
#money-pop.down #money-pop-sign { color: #e3a9bf; }
#money-pop.transfer #money-pop-sign { color: #f6f2ee; }
#money-pop-icon {
    width: 0.75em;
    height: 0.75em;
    object-fit: contain;
    display: block;
}

/* Reputation change popup ----------------------------------------------------
   Same "+N icon" shape as honor-pop, minus the up/down split — reputation
   only ever goes up, so there is exactly one colour, not a pair keyed off a
   class that would never be toggled. */
#reputation-pop {
    position: absolute;
    left: 50%;
    top: 50%;
    /* Transform lives in the layout block above; see the note on #honor-pop. */
    display: flex;
    align-items: center;
    gap: calc(0.35 * var(--w));
    font-size: calc((calc(3.1 * var(--w))) * var(--fs-reputationpop, 1));
    line-height: 1;
    text-shadow: 0 0.25cqh 0.5cqh rgba(0, 0, 0, 0.75);
    pointer-events: none;
}
#reputation-pop-sign {
    font-weight: var(--fw-reputationpop, 700);
    color: #bfe3c8;
}

/* -----------------------------------------------------------------------------
   HUD editor — body: element list on the left, its settings on the right
   -----------------------------------------------------------------------------
   Buttons as well as keys, because NUI focus is held while the editor is open
   and the mouse is the discoverable way in. See the glass note further up for
   why the surfaces are layered the way they are.
   -------------------------------------------------------------------------- */
#editor-body {
    display: flex;
    align-items: stretch;
    gap: calc(1.1 * var(--w));
    min-height: 0;
    padding: 1.1cqh calc(1.2 * var(--w)) 1.2cqh;
}
/* The settings column carries labels of a fixed length ("Letter spacing",
   "Corner radius") next to a stepper that cannot shrink, so it gets the larger
   share of a narrower panel. At 40% every label ellipsised to "Letter ...". */
#editor-col-list  { flex: 1 1 54%; min-width: 0; display: flex; flex-direction: column; min-height: 0; }
#editor-col-props { flex: 0 0 45%; min-width: 0; display: flex; flex-direction: column; min-height: 0; }

/* An inset well rather than a bare divider line. Recessing the settings gives
   the panel a second depth plane, which is most of what stops a glass surface
   reading as flat. */
#editor-col-props {
    background: rgba(0, 0, 0, 0.18);
    border: 1px solid rgba(255, 255, 255, 0.07);
    border-radius: calc(1.0 * var(--w));
    padding: 0.9cqh calc(0.75 * var(--w));
    box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.28);
}
#editor-col-props .ed-colhead {
    /* The caption names the element being tuned, so it can be long. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: rgba(244, 244, 242, 0.55);
}

#editor-props {
    display: flex;
    flex-direction: column;
    gap: 0.12cqh;
    overflow-y: auto;
    min-height: 0;
    flex: 1 1 auto;
}

.ed-prop {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: calc(0.5 * var(--w));
    padding: 0.42cqh calc(0.4 * var(--w));
    border-radius: calc(0.5 * var(--w));
    cursor: pointer;
    border: 1px solid transparent;
}
.ed-prop:hover { background: rgba(255, 255, 255, 0.045); }
.ed-prop.sel {
    background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05));
    border-color: rgba(255, 255, 255, 0.15);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.26);
}
.ed-prop.off { opacity: 0.35; pointer-events: none; }

/* The Follow/Own switch. Only the eleven rows that really inherit carry one --
   see INHERITS in app.js. Following is the quieter of the two states because it
   is the default and the common one; Own is the row that has been taken over,
   so it reads in the accent like every other changed value. */
.ed-follow {
    flex: 0 0 auto;
    cursor: pointer;
    font: inherit;
    line-height: 1;
    font-size: calc(0.68 * var(--w));
    padding: 0.3cqh calc(0.42 * var(--w));
    border-radius: calc(0.35 * var(--w));
    letter-spacing: 0.02em;
    color: var(--g-accent);
    background: rgba(95, 216, 200, 0.10);
    border: 1px solid rgba(95, 216, 200, 0.34);
}
.ed-follow.on {
    color: rgba(244, 244, 242, 0.5);
    background: rgba(255, 255, 255, 0.05);
    border-color: rgba(255, 255, 255, 0.12);
}
.ed-follow:hover { color: var(--text); background: rgba(255, 255, 255, 0.14); }
.ed-follow:focus-visible { outline: 2px solid var(--g-accent); outline-offset: 1px; }
/* A following row's VALUE is not this element's own, so it is dimmed to say so
   -- the number is real and live, it just belongs to the parent. */
.ed-prop.following .ed-pval { color: rgba(244, 244, 242, 0.62); }

.ed-plabel {
    /* Trimmed with the panel: these labels are fixed strings ("Letter
       spacing", "Corner radius") and ellipsising them costs more than a
       hairline of size does. */
    font-size: calc(0.82 * var(--w));
    color: rgba(244, 244, 242, 0.68);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    min-width: 0;
}
.ed-prop.sel .ed-plabel { color: var(--text); }
/* A row that carries an explanation says so, rather than hiding it behind a
   hover nobody thinks to try. */
.ed-plabel.has-hint { cursor: help; border-bottom: 1px dotted rgba(244, 244, 242, 0.28); }
/* A row that is no longer at its default gets its label lit, matching the dot
   in the element list. */
.ed-prop.changed .ed-plabel { color: var(--g-accent); }

/* − value + as ONE control. Three loose children read as three buttons; the
   grouped capsule reads as a stepper acting on the number inside it. */
.ed-step {
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
    border-radius: calc(0.5 * var(--w));
    background: rgba(255, 255, 255, 0.05);
    border: 1px solid rgba(255, 255, 255, 0.12);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.13);
    overflow: hidden;
}
.ed-step button {
    font: inherit;
    font-size: calc(0.92 * var(--w));
    line-height: 1;
    padding: 0.3cqh calc(0.5 * var(--w));
    border: 0;
    background: transparent;
    color: rgba(244, 244, 242, 0.8);
    cursor: pointer;
}
.ed-step button:hover { background: rgba(255, 255, 255, 0.15); color: var(--text); }
.ed-step button:active { background: rgba(255, 255, 255, 0.24); }
.ed-step button:focus-visible { outline: 2px solid var(--g-accent); outline-offset: -2px; }

/* The value between the − and the + is a real text field on every numeric row.
   It has to look like the label it replaced — a stepper that suddenly sprouts a
   chrome input in the middle would read as a different control — so the UA
   appearance is stripped and the two side borders of the capsule are kept. */
input.ed-pval {
    -webkit-appearance: none;
    appearance: none;
    border-top: 0;
    border-bottom: 0;
    background: transparent;
    font-family: inherit;
    width: calc(5.6 * var(--w));
    cursor: text;
}
input.ed-pval:focus {
    outline: none;
    /* Editing is a state worth seeing: until you commit, this number is not yet
       what the HUD is doing. */
    background: rgba(95, 216, 200, 0.16);
    color: #fff;
}
input.ed-pval::selection { background: rgba(95, 216, 200, 0.42); color: #fff; }

.ed-pval {
    font-size: calc(0.84 * var(--w));
    color: var(--text);
    min-width: calc(4.6 * var(--w));
    padding: 0 calc(0.15 * var(--w));
    text-align: center;
    font-variant-numeric: tabular-nums;
    border-left: 1px solid rgba(255, 255, 255, 0.09);
    border-right: 1px solid rgba(255, 255, 255, 0.09);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* -----------------------------------------------------------------------------
   Minimap centre crosshair (/hudcross)
   Sits on the centre of the rect client.lua publishes for the map. If the
   player arrow does not land on it, the gap is the thing to measure.
   -------------------------------------------------------------------------- */
#map-cross {
    position: absolute;
    left: calc(var(--map-left, 1.75cqw) + (var(--map-width, 15.8cqw) / 2));
    bottom: calc(var(--map-bottom-edge, 2.6cqh) + (var(--map-height, 15.4cqh) / 2));
    width: 0;
    height: 0;
    pointer-events: none;
    z-index: 60;
}
#map-cross::before,
#map-cross::after {
    content: '';
    position: absolute;
    background: #ff2ea6;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.85);
}
/* Horizontal arm. left is HALF of width, negated, to keep the arm centred on
   the map's middle point -- same "position tied to size" relationship as
   .scap's marker above, so both convert together rather than just width. */
#map-cross::before {
    left: calc(-3 * var(--w));
    top: -0.075cqh;
    width: calc(6 * var(--w));
    height: 0.15cqh;
}
/* Vertical arm -- same centring pairing as the horizontal one above. */
#map-cross::after {
    top: -2.4cqh;
    left: calc(-0.04 * var(--w));
    width: calc(0.08 * var(--w));
    height: 4.8cqh;
}

/* -----------------------------------------------------------------------------
   Skills
   -----------------------------------------------------------------------------
   Two pieces with opposite jobs.

   #skillup is a TOAST. It is the only part most players will ever see, so it
   has to be readable in a glance and gone before it becomes furniture. It
   borrows the map panels' arrival animation rather than inventing a second one.

   #skills is a SCREEN you deliberately open. It sits outside .stage, like the
   editor, because it is not a HUD element and must not inherit the offsets and
   scales the editor applies to the HUD.
   -------------------------------------------------------------------------- */
/* The level-up card ----------------------------------------------------------
   The one moment in this HUD worth marking, so it is the same glass as the
   editor with a single warm accent — a gold rim light and a gold numeral —
   rather than a second visual language invented for it.

   Laid out as icon / copy / number on one line. The earlier version stacked
   three centred lines of different sizes, which is a shape that says "a system
   generated this" rather than "something happened".
   -------------------------------------------------------------------------- */
#skillup {
    position: absolute;
    left: 50%;
    bottom: 22cqh;
    /* Placement only. The card inside does the moving. */
    transform: translateX(-50%)
               translate(var(--off-skillup-x, 0cqw), var(--off-skillup-y, 0cqh))
               scale(var(--sc-skillup-x, 1), var(--sc-skillup-y, 1));
    transform-origin: center bottom;
    opacity: var(--op-skillup, 1);
}

#skillup-card {
    display: flex;
    align-items: center;
    gap: calc(0.9 * var(--w) * var(--sp-skillup, 1));
    padding: 1.15cqh calc(1.25 * var(--w));
    border-radius: calc(0.85 * var(--w) * var(--rad-skillup, 1));
    /* The warm note, laid over the shared rim rather than replacing it: the
       card is the same material as everything else, lit differently. */
    border-color: rgba(232, 197, 106, 0.42);
    box-shadow:
        0 2.6cqh 7cqh rgba(0, 0, 0, 0.55),
        0 0.5cqh 1.6cqh rgba(0, 0, 0, 0.34),
        inset 0 1px 0 rgba(255, 240, 205, 0.42),
        0 0 calc(2.2 * var(--w)) rgba(232, 197, 106, 0.16);
    font-family: var(--ff-skillup, inherit);
    transform-origin: center bottom;
}

#skillup-icon {
    flex: 0 0 auto;
    width: calc(1.9 * var(--w) * var(--ic-skillup, 1));
    height: calc(1.9 * var(--w) * var(--ic-skillup, 1));
    display: block;
}
#skillup-icon svg { width: 100%; height: 100%; display: block; }
#skillup-icon path {
    fill: var(--g-gold);
    /* Its own glow, so the star reads as the lit thing rather than as a shape
       that happens to be yellow. */
    filter: drop-shadow(0 0 calc(0.3 * var(--w)) rgba(232, 197, 106, 0.55));
}

#skillup-copy {
    display: flex;
    flex-direction: column;
    gap: 0.15cqh;
    min-width: 0;
    text-align: var(--al-skillup, left);
}
#skillup-label {
    font-size: calc(0.95 * var(--w) * var(--fs-skillup, 1));
    font-weight: var(--fw-skillup, 500);
    letter-spacing: 0.13em;
    text-transform: uppercase;
    color: var(--g-gold);
    white-space: nowrap;
}
#skillup-blurb {
    font-size: calc(0.82 * var(--w) * var(--fs-skillup, 1));
    color: rgba(244, 244, 242, 0.62);
    white-space: nowrap;
}
#skillup-level {
    flex: 0 0 auto;
    margin-left: calc(0.4 * var(--w));
    padding-left: calc(0.9 * var(--w));
    border-left: 1px solid var(--g-line);
    font-size: calc(2.15 * var(--w) * var(--fs-skillup, 1));
    font-weight: 700;
    line-height: 1;
    font-variant-numeric: tabular-nums;
    color: var(--text);
    text-shadow: 0 0 calc(0.6 * var(--w)) rgba(232, 197, 106, 0.35),
                 var(--ink-shadow);
}

/* The CARD animates, never the outer element — see the note in index.html. */
@keyframes skillUpIn {
    from { opacity: 0; transform: translateY(45%) scale(0.92); }
    to   { opacity: 1; transform: translateY(0)   scale(1); }
}
#skillup.slot-in #skillup-card {
    animation: skillUpIn 520ms cubic-bezier(0.16, 1.28, 0.38, 1) both;
}
@media (prefers-reduced-motion: reduce) {
    #skillup.slot-in #skillup-card { animation: none; }
}

#skills {
    position: fixed;
    inset: 0;
    z-index: 90;
    display: flex;
    align-items: center;
    justify-content: center;
    /* A scrim IS right here, unlike the editor: this is a screen you opened to
       read, not a tool for positioning the things behind it. */
    background: radial-gradient(120% 100% at 50% 45%,
        rgba(6, 6, 12, 0.55) 0%, rgba(3, 3, 7, 0.82) 100%);
    font-family: system-ui, 'Segoe UI Variable Text', 'Segoe UI', -apple-system,
                 Inter, Roboto, sans-serif;
}
#skills.hidden { display: none !important; }

#skills-panel {
    width: calc(44 * var(--w));
    min-width: 520px;
    max-width: min(70vw, 860px);
    max-height: 78cqh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    /* Surface comes from .glass on the element itself. What is left here is
       only what is specific to this panel. Hand-rolling a second near-miss of
       the same colours is what made these look related rather than identical. */
    border-radius: calc(1.4 * var(--w));
    color: var(--text);
}
#skills-head {
    display: flex;
    align-items: baseline;
    gap: calc(0.8 * var(--w));
    padding: 1.5cqh calc(1.4 * var(--w)) 1.1cqh;
    border-bottom: 1px solid var(--g-line);
}
#skills-title { font-size: calc(1.32 * var(--w)); font-weight: 600; }
#skills-sub { font-size: calc(0.88 * var(--w)); color: rgba(244,244,242,0.55); }
#skills-close {
    margin-left: auto;
    cursor: pointer;
    font: inherit;
    font-size: calc(0.95 * var(--w));
    line-height: 1;
    padding: 0.45cqh calc(0.6 * var(--w));
    border-radius: calc(0.4 * var(--w));
    color: rgba(244, 244, 242, 0.7);
    background: var(--g-fill);
    border: 1px solid rgba(255, 255, 255, 0.13);
}
#skills-close:hover { color: var(--text); background: var(--g-fill-hi); }

#skills-list {
    list-style: none;
    overflow-y: auto;
    flex: 1 1 auto;
    min-height: 0;
    padding: 0.8cqh calc(1.4 * var(--w));
}
.sk { padding: 0.85cqh 0; }
.sk + .sk { border-top: 1px solid var(--g-line); }
.sk-head { display: flex; align-items: baseline; gap: calc(0.6 * var(--w)); }
.sk-name { font-size: calc(1.0 * var(--w)); font-weight: 500; flex: 1 1 auto; }
.sk-level {
    font-size: calc(1.15 * var(--w));
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    color: var(--g-gold);
}
.sk.maxed .sk-level { font-size: calc(0.86 * var(--w)); letter-spacing: 0.12em; }
.sk-track {
    height: 0.62cqh;
    margin: 0.55cqh 0 0.4cqh;
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.09);
    overflow: hidden;
}
.sk-fill {
    height: 100%;
    width: 100%;
    /* scaleX from the left, like the status bars — animating width leaves the
       fill stuck at whatever value it first received. */
    transform-origin: left center;
    background: linear-gradient(90deg, rgba(232, 197, 106, 0.8), var(--g-gold));
    transition: transform 260ms ease;
}
.sk.maxed .sk-fill { background: linear-gradient(90deg, #7fd6a2, #4fc27b); }
.sk-sub {
    font-size: calc(0.8 * var(--w));
    color: rgba(244, 244, 242, 0.5);
    font-variant-numeric: tabular-nums;
}
#skills-foot {
    padding: 1.0cqh calc(1.4 * var(--w));
    border-top: 1px solid var(--g-line);
    font-size: calc(0.8 * var(--w));
    color: rgba(244, 244, 242, 0.45);
}
#skills-list::-webkit-scrollbar { width: calc(0.34 * var(--w)); }
#skills-list::-webkit-scrollbar-thumb {
    background: rgba(255, 255, 255, 0.16);
    border-radius: 999px;
}
#skills-list::-webkit-scrollbar-track { background: transparent; }

/* -----------------------------------------------------------------------------
   Race HUD
   -----------------------------------------------------------------------------
   Fed by exports.qbcore_vi_vice_hud:SetLapTimer -- qbcore_vi_streetkings is the one resource
   calling it today, but this has no idea that resource exists.

   Measured off the reference race footage (1902x1066), bottom right, three
   right-aligned rows:

     PROGRESS [30%]    cap height 24px; the figure sits in a 90x36 dark chip
     [  1 / 6  |]      109x64 plate, chequered in two dark greys with 21px
                       squares, a 5px pink bar down its right edge
     00:30.39          cap height 38px, unboxed

   A race with laps has LAP [2/2] as its first row instead. That row, the
   plate's figures and where the block stands were re-measured on the
   dirt-bike race (1920x1080), which is over bright ground and so shows how
   much of each piece is see-through; the numbers are with each rule.

   Every size below is one of those numbers as a share of the frame, in cqh so
   the block holds its proportions on an ultrawide instead of stretching.

   The face is a condensed DIN. None of the fonts this resource ships is one,
   and FiveM is Windows-only, so this asks for Bahnschrift -- a DIN variable
   font that has been part of Windows since 10 -- at its condensed width
   rather than adding a font file for three rows of digits.
   -------------------------------------------------------------------------- */
#lap-hud {
    position: absolute;
    right: 1.67cqw;
    bottom: 3.13cqh;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 1.58cqh;
    pointer-events: none;
    color: #f4eff2;
    font-family: 'Bahnschrift', 'DIN Condensed', 'Oswald', 'Arial Narrow', sans-serif;
    font-stretch: 75%;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    line-height: 1;
    /* The label and the clock are white with a hard dark line round them. */
    --lap-line: #2e2d3a;
    --lap-outline:
        -0.17cqh -0.17cqh 0 var(--lap-line), 0.17cqh -0.17cqh 0 var(--lap-line),
        -0.17cqh 0.17cqh 0 var(--lap-line), 0.17cqh 0.17cqh 0 var(--lap-line),
        0 -0.17cqh 0 var(--lap-line), 0 0.17cqh 0 var(--lap-line),
        -0.17cqh 0 0 var(--lap-line), 0.17cqh 0 0 var(--lap-line);
}
#lap-hud.hidden { display: none !important; }

/* The first row: a label, then its figure on a dark chip. LAP 2/2 for a race
   with laps, PROGRESS 30% for a point to point (see onLapHud).

   The lap row is measured on the dirt-bike race at 1080p: the chip is
   84 x 40px and stops 32px short of the right edge, its figure has 22px
   capitals, and LAP (24px capitals) stands 26px to its left. */
#lap-badge,
#lap-progress {
    display: flex;
    align-items: center;
    gap: 0.9cqh;
    font-size: 3.1cqh;
    letter-spacing: 0.02em;
}
#lap-badge.hidden,
#lap-progress.hidden { display: none; }
#lap-badge { gap: 2.15cqh; font-size: 3.16cqh; }
/* The face's capitals sit a little low in their line; the padding puts them
   level with the middle of the chip, as they are in the reference. */
#lap-badge-label,
#lap-progress-label { padding-bottom: 0.08em; text-shadow: var(--lap-outline); }
#lap-progress-chip,
#lap-badge-chip {
    min-width: 8.4cqh;
    height: 3.4cqh;
    padding: 0 0.6cqh;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: center;
    /* The chip is not a rectangle: its left edge leans, 0.47 of a unit over
       its 3.67 height, it is lighter at that end than at the square one, and
       it has a fine line round it. Read off a vectorised copy of the race
       block, which shows what the footage blurs. */
    background: rgba(36, 34, 44, 0.9) linear-gradient(90deg, rgba(70, 68, 86, 0.5), rgba(70, 68, 86, 0) 48%, rgba(6, 6, 8, 0.42));
    box-shadow: inset 0 0 0 0.1cqh #343341;
    clip-path: polygon(0.47cqh 0, 100% 0, 100% 100%, 0 100%);
}
#lap-badge-chip {
    min-width: 7.76cqh;
    height: 3.7cqh;
    /* The extra on the left is the letter-spacing after the last figure, so
       the count stays in the middle of the chip. */
    padding: 0 1.05cqh 0.3cqh calc(1.35cqh + 0.125em);
    font-size: 2.9cqh;
    letter-spacing: 0.125em;
}

/* The chequered plate: 130 x 62.5px for "7 / 14", three 21px squares tall
   starting light in its top left corner, darker towards the top, a 1px dark
   line round it and a 5px pink bar inside that line down its right edge.
   One conic gradient per 2x2 block keeps the squares square at any size --
   the reference's flag is a texture, and a gradient is the same pattern
   without a file to ship. */
#lap-cp-plate {
    position: relative;
    min-width: 10.2cqh;
    height: 5.79cqh;
    padding: 0 1.96cqh 0.03cqh 1.09cqh;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: center;
    background-color: rgba(38, 36, 54, 0.64);
    background-image:
        linear-gradient(to bottom, rgba(22, 16, 32, 0.6), rgba(22, 16, 32, 0) 72%),
        conic-gradient(
            transparent 0 25%, rgba(190, 175, 178, 0.27) 0 50%,
            transparent 0 75%, rgba(190, 175, 178, 0.27) 0);
    background-size: auto, 3.86cqh 3.86cqh;
    background-position: 0 0, left top;
    box-shadow: inset 0 0 0 0.1cqh rgba(24, 16, 24, 0.9);
}
/* The pink bar. The nav tile's own accent, so a character whose route and turn
   tile are teal gets a teal bar rather than a second, unrelated colour. */
#lap-cp-plate::after {
    content: '';
    position: absolute;
    top: 0.1cqh;
    right: 0.1cqh;
    bottom: 0.1cqh;
    width: 0.46cqh;
    background: var(--nav-accent, var(--nav-accent-gender-default, #f68baf));
}
/* 33px figures, a third smaller than the clock's. */
#lap-cp-row {
    font-size: 4.32cqh;
    word-spacing: -0.06em;
    white-space: nowrap;
}

/* 38.5px figures with the dark line round them, stopping 12px short of the
   plate's right edge. */
#lap-timer {
    font-size: 4.95cqh;
    /* Its line box is taller than its figures: this closes the gap under
       the plate to the 21px of the reference. */
    margin-top: -0.54cqh;
    margin-right: 1.02cqh;
    text-shadow: var(--lap-outline);
}

/* The race block takes the corner the action prompts and the honor toast
   normally stand in. While a race is up they stand on top of it instead of
   under it -- app.js puts .racing on the stage for exactly as long as the
   block is shown. */
#stage.racing #prompts,
#stage.racing #honor { bottom: 27cqh; }

/* -----------------------------------------------------------------------------
   Race cards
   -----------------------------------------------------------------------------
   Top centre, measured off the reference race footage at 1902x1066. The title
   card before the start and the result card at the finish are the same three
   rows -- chip, big line, chip -- with different rows filled in:

     row          y in the frame     size
     top chip     95 .. 135 px       40 px tall, 22 px caps
     big line     148 .. 206 px      58 px caps
     bottom chip  220 .. 260 px      40 px tall, 22 px caps

   The result card has no top chip and its big line starts at 108 px, which is
   where the same box lands when the first row is simply absent, so there is
   no second layout to keep in step. Same condensed DIN as the race block.

   No transition anywhere: both cards cut in and cut out in the reference.
   -------------------------------------------------------------------------- */
#race-card {
    position: absolute;
    left: 0;
    right: 0;
    top: 8.9cqh;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.25cqh;
    pointer-events: none;
    color: #dedce0;
    font-family: 'Bahnschrift', 'DIN Condensed', 'Oswald', 'Arial Narrow', sans-serif;
    font-stretch: 75%;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    line-height: 1;
    text-transform: uppercase;
    white-space: nowrap;
}
#race-card.hidden { display: none !important; }
.race-chip {
    height: 3.75cqh;
    padding: 0 0.95cqh;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(34, 33, 42, 0.62);
    font-size: 2.9cqh;
    letter-spacing: 0.01em;
}
/* A row with nothing in it is a row that is not there. */
.race-chip:empty { display: none; }
#race-card-main {
    font-size: 7.7cqh;
    letter-spacing: 0.01em;
    text-shadow: 0 0.12cqh 0.4cqh rgba(0, 0, 0, 0.55);
    /* The reference sets this line in a narrower cut than Bahnschrift has:
       measured against the footage at the same cap height, "SALTON TO WANING
       SANDS" comes out 26% too wide and "1ST" 39% too wide at the face's
       narrowest width. 0.76 across splits the two (4% under on one, 5% over
       on the other) without touching the height the layout is built on. */
    transform: scaleX(0.76);
}
#race-card-main:empty { display: none; }
/* The time under a finishing position is set a touch larger than the chips on
   the title card: 24 px caps in a 42 px chip. */
#race-card.result .race-chip {
    height: 3.95cqh;
    font-size: 3.2cqh;
    margin-top: 0.3cqh;
}
/* FINAL LAP. Measured at 1080p: capitals 55px tall and 257px across starting
   121px down, white with a 2px dark line round them, and 18px under them the
   lap just finished on a 143 x 42px slate chip in 26px figures that stand
   closer together than this face sets them. */
#race-card.banner { top: 9.91cqh; gap: 0; }
#race-card.banner #race-card-main {
    font-size: 7.13cqh;
    transform: scaleX(0.823);
    color: #f6f3fa;
    text-shadow:
        -0.18cqh -0.18cqh 0 #2b2c3f, 0.18cqh -0.18cqh 0 #2b2c3f,
        -0.18cqh 0.18cqh 0 #2b2c3f, 0.18cqh 0.18cqh 0 #2b2c3f,
        0 -0.18cqh 0 #2b2c3f, 0 0.18cqh 0 #2b2c3f,
        -0.18cqh 0 0 #2b2c3f, 0.18cqh 0 0 #2b2c3f;
}
#race-card.banner .race-chip {
    height: 3.86cqh;
    box-sizing: border-box;
    margin-top: 0.92cqh;
    padding: 0 1.135cqh 0.19cqh 0.985cqh;
    font-size: 3.305cqh;
    letter-spacing: -0.034em;
    color: #dcdcf2;
    background: rgba(66, 67, 86, 0.9);
    box-shadow: inset 0 0 0 0.1cqh rgba(28, 28, 44, 0.85);
}

/* 3, 2, 1, go: one glyph in Pricedown, slanted, light with a dark outline and
   a hard offset shadow, 125 .. 258 px down the reference frame. Pricedown is
   the face (it is GTA's own, already shipped here for the money rows); the
   slant is the browser's, since Pricedown has no italic cut. */
#race-count {
    position: absolute;
    left: 50%;
    top: 10.6cqh;
    transform: translateX(-50%);
    pointer-events: none;
    font-family: 'Pricedown', 'Arial Black', sans-serif;
    font-weight: 400;
    font-style: italic;
    font-size: 14cqh;
    line-height: 1;
    text-transform: lowercase;
    color: #e6e4e8;
    -webkit-text-stroke: 0.95cqh #26242d;
    paint-order: stroke fill;
    text-shadow: -0.35cqh 0.4cqh 0 #26242d;
}
#race-count.hidden { display: none !important; }

/* -----------------------------------------------------------------------------
   Race marker
   -----------------------------------------------------------------------------
   The icon over the next checkpoint's pillar. Measured on the dirt-bike race at
   1080p (the pillar and smoke are world-space, client_overlays.lua):

     dot        14 px across, accent with a darker rim
     distance   white "453 ft" with a dark edge, 9 px capitals, its top 17 px
                under the dot; the pillar starts 5 px under it
     finish     a 25 x 26 px accent tile with rounded corners, a dark edge and
                a 4 x 3 chequer in its middle 60 x 46 %; the distance 10 px
                under it

   The element's bottom centre is the top of the pillar, so it is pulled up
   and left by its own size. Accent from the same variables as the race
   plate's bar and the nav tile, so a teal character's marker is teal.
   -------------------------------------------------------------------------- */
#race-marker {
    position: absolute;
    left: 50cqw;
    top: 50cqh;
    transform: translate(-50%, -100%);
    padding-bottom: 0.45cqh;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1.6cqh;
    pointer-events: none;
    --rm-accent: var(--nav-accent, var(--nav-accent-gender-default, #fc74a4));
}
#race-marker.hidden { display: none !important; }
#race-marker-icon {
    width: 1.38cqh;
    height: 1.38cqh;
    box-sizing: border-box;
    border-radius: 50%;
    background: var(--rm-accent);
    border: 0.16cqh solid rgba(48, 10, 28, 0.55);
}
#race-marker.finish { gap: 0.95cqh; }
#race-marker.finish #race-marker-icon {
    position: relative;
    width: 2.32cqh;
    height: 2.41cqh;
    border-radius: 0.42cqh;
    border: 0.17cqh solid rgba(40, 10, 24, 0.85);
}
/* The chequer: four squares across, three down, dark on the accent, inset
   from the tile's edge as in the reference. */
#race-marker.finish #race-marker-icon::after {
    content: '';
    position: absolute;
    left: 20%;
    right: 20%;
    top: 27%;
    bottom: 27%;
    background-image: conic-gradient(
        rgba(46, 14, 30, 0.82) 0 25%, transparent 0 50%,
        rgba(46, 14, 30, 0.82) 0 75%, transparent 0);
    background-size: 50% 66.67%;
}
#race-marker-dist {
    font-family: 'Bahnschrift', 'DIN Condensed', 'Oswald', 'Arial Narrow', sans-serif;
    font-weight: 500;
    font-size: 1.3cqh;
    letter-spacing: 0.03em;
    line-height: 1;
    white-space: nowrap;
    color: #f4f1f4;
    text-shadow:
        -0.08cqh -0.08cqh 0 #2a2a3a, 0.08cqh -0.08cqh 0 #2a2a3a,
        -0.08cqh 0.08cqh 0 #2a2a3a, 0.08cqh 0.08cqh 0 #2a2a3a, 0 0 0.25cqh rgba(20, 20, 32, 0.6);
}

/* -----------------------------------------------------------------------------
   Interact menu  (Phase 1 of the ox_target-replacement project)
   -----------------------------------------------------------------------------
   Just a list. No targeting geometry, no raycast highlight -- that engine is
   a separate, later project (see the comment on #interact in index.html).
   First pass: placement/sizing is a reasonable guess against the reference,
   not pixel-measured.
   -------------------------------------------------------------------------- */
#interact {
    position: absolute;
    left: 6cqw;
    top: 50%;
    transform: translateY(-50%);
    pointer-events: none;
    color: #f2f2ef;
}
#interact.hidden { display: none !important; }

#interact-list {
    display: flex;
    flex-direction: column;
    max-height: calc(4.6 * var(--w));
    overflow: hidden;
    /* The outer #interact is pointer-events:none (a HUD element that
       shouldn't eat clicks meant for the game world at rest); this is what
       lets the list itself still be clickable while it's open. */
    pointer-events: auto;
    /* Fades the last visible row toward transparent, reading as "more below"
       rather than a hard clip -- the reference's own list trails off the
       same way rather than ending on a clean edge. */
    -webkit-mask-image: linear-gradient(180deg, #000 78%, transparent 100%);
    mask-image: linear-gradient(180deg, #000 78%, transparent 100%);
}

.interact-row {
    position: relative;
    display: flex;
    align-items: center;
    gap: calc(0.55 * var(--w));
    padding: calc(0.45 * var(--w)) 0;
    opacity: 0.55;
    cursor: pointer;
}
/* The current/top row reads brightest; everything under it dims toward the
   fade at the bottom of the list, same reading the reference gives -- one
   clear default option, the rest a fading list of alternatives. */
.interact-row.selected { opacity: 1; }

.interact-marker {
    position: relative;
    flex: 0 0 auto;
    width: calc(1.1 * var(--w));
    height: calc(1.1 * var(--w));
    display: flex;
    align-items: center;
    justify-content: center;
}
/* The rail: a line from THIS marker's centre down to the next row's, so it
   reads as one continuous connector rather than floating dots. Skipped on
   the last row -- nothing below it to connect to. */
.interact-row:not(:last-child) .interact-marker::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 1.5px;
    height: 100%;
    background: rgba(255, 255, 255, 0.3);
    transform: translateX(-50%);
}
.interact-marker svg { width: 100%; height: 100%; position: relative; z-index: 1; }
.interact-marker-dot circle { fill: none; stroke: #f2f2ef; stroke-width: 7; }
.interact-marker-x circle { fill: none; stroke: #f2f2ef; stroke-width: 6; }
.interact-marker-x path { stroke: #f2f2ef; stroke-width: 8; stroke-linecap: round; }

.interact-label {
    font-size: calc(0.85 * var(--w));
    letter-spacing: 0.01em;
    white-space: nowrap;
}

.interact-badges {
    display: flex;
    gap: calc(0.2 * var(--w));
}
.interact-badge {
    width: calc(0.85 * var(--w));
    height: calc(0.85 * var(--w));
    box-shadow: var(--hairline);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(20, 20, 22, 0.6);
}
.interact-badge svg { width: 62%; height: 62%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.interact-badge svg circle { fill: currentColor; stroke: none; }
/* Same concepts qbcore_vi_vice_hud's own health/stamina/focus status rows already use --
   reused here rather than invented fresh, so an item that restores one reads
   consistently everywhere it shows up. */
.interact-badge.health  { color: #f5acc6; }
.interact-badge.stamina { color: #5fd8c8; }
.interact-badge.focus   { color: #b98bd6; }

/* -----------------------------------------------------------------------------
   World action prompt
   -----------------------------------------------------------------------------
   left/top below are the FALLBACK position (a caller that never passes
   coords to ShowWorldActions, e.g. qbx_vehiclekeys's Slim Jim/Smash Window
   today, stays on this fixed spot exactly as before). A caller that DOES
   pass coords gets those overridden every frame by inline styles from
   onWorldActionsPos in app.js (client_overlays.lua's world-anchoring
   thread) -- inline style always wins over the rule below, so no extra
   specificity/!important is needed to switch between the two.
   -------------------------------------------------------------------------- */
#world-actions {
    /* How big the world prompt is next to the corner prompts it borrows its
       look from (.glyph / .wa-label). Everything below that has a size is
       multiplied by it -- the button, the label, the dots, the gaps and the
       centring offset -- so the first button still lands on its point. */
    --wa-scale: 0.7;
    position: absolute;
    left: 46cqw;
    top: 54cqh;
    display: flex;
    flex-direction: column;
    gap: calc(0.9cqh * var(--wa-scale));
    pointer-events: none;
}
#world-actions .glyph { --g-h: calc(3.05cqh * var(--ic-prompts, 1) * var(--wa-scale)); }
#world-actions .wa-label { font-size: calc(2.07cqh * var(--fs-prompts, 1) * var(--wa-scale)); }
#world-actions.hidden { display: none !important; }
/* GetScreenCoordFromWorldCoord reported the anchored point is behind the
   camera this frame -- hide without touching the row list itself, since
   it'll likely be back on-screen again within a frame or two as the camera
   turns. Separate from .hidden, which is the caller explicitly closing it. */
#world-actions.wa-offscreen { display: none !important; }

/* Icon-only at range, full label once close enough to actually interact --
   see WA_COMPACT_DISTANCE in client_overlays.lua for the threshold this is
   driven by. The circle badge (.wa-btn) stays either way; only the text
   label disappears, so approaching a prop reads as the same marker growing
   a label rather than two different things swapping. */
#world-actions.wa-compact .wa-label { display: none; }

/* The reference's world prompt is a prompt row turned round: the button
   first, then the same bold condensed capitals (.wa-label shares the action
   prompts' label rule). The button itself is a .glyph, drawn by setGlyph. */
.wa-row {
    display: flex;
    align-items: center;
    /* 13px from the edge of a 36px button to its name in the reference stills. */
    gap: calc(0.8cqh * var(--wa-scale, 1));
}
.wa-btn { flex: 0 0 auto; }
/* An option that opens a list: three dots on a dark disc, the size of the
   button, between it and the name (FRIDGE in the reference). */
.wa-more {
    flex: 0 0 auto;
    width: calc(3.05cqh * var(--ic-prompts, 1) * var(--wa-scale, 1));
    height: calc(3.05cqh * var(--ic-prompts, 1) * var(--wa-scale, 1));
}
.wa-more svg { display: block; width: 100%; height: 100%; fill: #f6f2ee; }
.wa-more .wa-more-disc { fill: rgba(40, 38, 46, 0.94); }
/* A coloured dot in the same place: the reference's mark for something on sale. */
.wa-dot {
    flex: 0 0 auto;
    width: calc(1.7cqh * var(--wa-scale, 1));
    height: calc(1.7cqh * var(--wa-scale, 1));
    margin: 0 calc(0.35cqh * var(--wa-scale, 1));
    border-radius: 50%;
    background: #e8659f;
}
/* The first button sits on the point the prompt is about, its name running
   off to the right, as the reference draws it. */
#world-actions.wa-centred {
    transform: translate(calc(-1.525cqh * var(--ic-prompts, 1) * var(--wa-scale)), calc(-1.525cqh * var(--ic-prompts, 1) * var(--wa-scale)));
}
#world-actions {
    font-family: 'Bahnschrift', 'DIN Condensed', 'Segoe UI', Arial, sans-serif;
}

/* -----------------------------------------------------------------------------
   Lockpick check — the analog direction-fill ring
   -----------------------------------------------------------------------------
   REWORKED 2026-09-12 to exactly match lockpick_preview.html, the
   user-approved standalone mockup worked out over many rounds of feedback
   against the GTA VI: An Extended Look trailer footage — every ratio below
   is that mockup's own measured value (ring/glyph/stroke sizes, colours,
   the chevron glyph) converted from its 260px-wide preview stage into this
   file's own --w unit. The ring's own viewBox stays 0-100 same as the
   mockup's, so radius/stroke-width numbers below carry over 1:1 with no
   conversion at all; only the CSS-px glyph/chevron measurements (relative
   to the mockup's 260px stage) needed scaling to var(--w).
   -------------------------------------------------------------------------- */
#lockpick {
    position: absolute;
    left: 50%;
    top: 50%;
    width: calc(5 * var(--w));
    height: calc(5 * var(--w));
    transform: translate(-50%, -50%);
    pointer-events: none;
}
#lockpick.hidden { display: none !important; }

#lp-ring {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    fill: none;
    /* No rotate transform here any more (the old zone-ring used -90deg to
       start its dasharray at 12 o'clock) -- #lp-capacity/#lp-fill are now
       explicit point-by-point paths computed directly in this coordinate
       space (see index.html/app.js), and rotating the <svg> would rotate
       their already-absolute coordinates right along with it. */
}
/* Flat/bar-shaped ends, not rounded pill ends -- matches the mockup's own
   fix: the reference footage shows butt caps, not rounded ones. */
.lp-arc { fill: none; stroke-linecap: butt; }
/* Measured directly off the reference image in the mockup (multi-angle
   radial pixel scan): ring stroke ~12px against a ~20px glyph radius, i.e.
   stroke is ~0.6x the glyph radius -- no invented tint or extra glow layer,
   plain translucent white/grey, one shadow, nothing added. */
#lp-track {
    stroke: rgba(255, 255, 255, 0.35);
    stroke-width: 8;
}
/* The faint pink band IS the fillable area itself -- not a separate goal
   marker any more. No win/fail zone: filling it up is the whole point.
   #fc74a4 is qbcore_vi_vice_hud's OWN existing "pink" theme accent (client.lua:2421's
   ACCENT.pink, also config.lua's waypointColour pink entries), same as the
   old target zone re-used, just at low opacity here now that it's a
   capacity band rather than a highlight. */
#lp-capacity {
    stroke: rgba(252, 116, 164, 0.28);
    stroke-width: 8;
}
/* The growing fill, drawn on top of the capacity band at the same radius
   and width. Its `d` is rewritten live by onLockpickProgress() in app.js —
   see index.html's own comment for why a computed path replaces the old
   stroke-dasharray technique. */
#lp-fill {
    stroke: #fc74a4;
    stroke-width: 8;
    transition: stroke 150ms ease;
    filter: drop-shadow(0 0 0.6cqh rgba(252, 116, 164, 0.65));
}
#lockpick.lp-win #lp-fill { stroke: #6ec06e; }

/* Pushing it to 100% the wrong way (left-to-right) is a trap, not just "no
   progress" -- the whole band flashes red as the alarm trip, since there's
   no separate "fail" fill colour to redden any more (see the mockup's own
   comment: there's no fill arc dedicated to the wrong direction, both
   directions share the one fill). */
@keyframes lp-alarm-flash {
    0%, 100% { stroke: rgba(217, 88, 92, 0.9); }
    50%      { stroke: rgba(217, 88, 92, 0.35); }
}
#lockpick.lp-alarm #lp-track,
#lockpick.lp-alarm #lp-capacity {
    animation: lp-alarm-flash 350ms ease-in-out infinite;
}
#lp-alarm-banner {
    position: absolute;
    /* -34px against the mockup's 260px stage -> -0.1308 * container. */
    top: calc(50% - 3.635 * var(--w));
    left: 50%;
    transform: translate(-50%, -50%);
    color: #d9585c;
    font-weight: 800;
    /* 15px against the mockup's 260px stage. */
    font-size: calc(0.288 * var(--w));
    letter-spacing: 0.04em;
    text-transform: uppercase;
    white-space: nowrap;
    opacity: 0;
    transition: opacity 150ms ease;
    text-shadow: 0 0.1cqh 0.3cqh rgba(0, 0, 0, 0.8);
}
#lockpick.lp-alarm #lp-alarm-banner { opacity: 1; }

/* The key clone check (client_overlays.lua, the single-player HUD's CLONE
   KEY) on the same ring, as the approved key cloning mock draws it: the
   window in the band's pink with a soft glow, a white dot with a dark edge
   and a white glow, a third of the band filled per hit (#lp-fill, over the
   window), the track red for a moment on a miss, green with "Key cloned"
   when it's done. None of it shows outside lp-clone, and no chevron there:
   nothing is dragged. */
#lp-window, #lp-dot, #lp-done-banner { display: none; }
#lockpick.lp-clone #lp-window,
#lockpick.lp-clone #lp-dot { display: inline; }
#lockpick.lp-clone #lp-done-banner { display: block; }
#lockpick.lp-clone #lp-chevron { display: none; }
#lockpick.lp-win #lp-window, #lockpick.lp-win #lp-dot,
#lockpick.lp-alarm #lp-window, #lockpick.lp-alarm #lp-dot { display: none; }
#lp-window {
    stroke: rgba(252, 116, 164, 0.85);
    stroke-width: 8;
    filter: drop-shadow(0 0 0.37cqh rgba(252, 116, 164, 0.6));
}
#lp-dot {
    fill: #ffffff;
    stroke: rgba(0, 0, 0, 0.55);
    stroke-width: 1.2;
    filter: drop-shadow(0 0 0.28cqh rgba(255, 255, 255, 0.7));
}
#lockpick.lp-miss #lp-track { stroke: rgba(217, 88, 92, 0.85); }
#lp-done-banner {
    position: absolute;
    top: calc(50% - 3.635 * var(--w));
    left: 50%;
    transform: translate(-50%, -50%);
    color: #6ec06e;
    font-weight: 800;
    font-size: calc(0.288 * var(--w));
    letter-spacing: 0.04em;
    text-transform: uppercase;
    white-space: nowrap;
    opacity: 0;
    transition: opacity 150ms ease;
    text-shadow: 0 0.1cqh 0.3cqh rgba(0, 0, 0, 0.8);
}
#lockpick.lp-clone.lp-win #lp-done-banner { opacity: 1; }
.lp-pad-ink { display: block; }
.lp-pad-ink .g-ink { fill: none; stroke: #111111; stroke-width: 2.8; stroke-linecap: round; stroke-linejoin: round; }

/* Centre: a plain flex box occupying the whole ring, same trick .pip-ring's
   own centre label uses, so #lp-glyph only ever has to express its OWN
   drag-direction offset in `transform` without also fighting a "centred at
   50%/50%" translate baked into the same property. */
.lp-center {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
}
/* Reference shows a SOLID WHITE disc with a bold BLACK glyph in every
   frame. 64px/2px-border/box-shadow all measured against the mockup's
   260px stage -- diameter converted to --w (64/260 * 5 = 1.231), border
   and shadow kept as literal px same as every other hairline border in
   this file (e.g. the old #lp-glyph's own 1.5px), since those are meant to
   stay crisp at a fixed width rather than scale with the HUD.
   NO transition, ever -- it tracks the input directly, 1:1, including
   snapping straight back to centre the instant input stops. No eased
   "spring" animation on release; that's not in the reference, and the
   mockup calls this out explicitly. --lp-dx (-1..1, written every
   onLockpickProgress() push) drives the horizontal travel; GLYPH_MAX_OFFSET
   in the mockup was 20px against its 260px stage (20/260 * 5 = 0.3846). */
#lp-glyph {
    position: relative;
    width: calc(1.231 * var(--w));
    height: calc(1.231 * var(--w));
    border-radius: 50%;
    background: #fff;
    border: 2px solid rgba(0, 0, 0, 0.55);
    box-shadow: 0 0.2cqh 0.6cqh rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 800;
    font-size: calc(0.5 * var(--w));
    color: #111;
    transform: translateX(calc(var(--lp-dx, 0) * 0.3846 * var(--w)));
}
#lp-glyph-text {
    /* GetControlInstructionalButton's multi-char labels ('LMB', 'E', ...)
       need to fit the disc without overflowing it the way a single letter
       always did -- see resolveKey/waResolveKey (client.lua/
       client_overlays.lua) for where this text actually comes from. */
    max-width: 82%;
    text-align: center;
    line-height: 1;
    overflow: hidden;
    /* FIXED 2026-09-13 (first real in-game test): the LMB/RMB icon
       setLockpickGlyph() swaps in (see app.js) is an <img>, and an <img>'s
       default vertical-align:baseline leaves room for a text descender that
       doesn't exist -- reading as visibly off-centre inside the disc even
       though the parent #lp-glyph is already a centred flex box. Making this
       box its own flex centre (and the img itself block-level, below) removes
       baseline alignment from the equation entirely, for both the icon and
       the plain-text case. */
    display: flex;
    align-items: center;
    justify-content: center;
}
#lp-glyph-text img {
    display: block;
}
/* Pinned near the bottom inner edge of the white circle, solid black -- a
   hollow/outline chevron was explicitly rejected in the mockup in favour of
   this solid one. 13px/5px against the mockup's 260px stage, both
   converted the same way as #lp-glyph above (13/260*5 = 0.25,
   5/260*5 = 0.0962). Absolutely positioned WITHIN #lp-glyph now (not
   #lockpick) so it rides along with the glyph's own drag offset instead of
   staying pinned to the ring's static centre. */
#lp-chevron {
    position: absolute;
    bottom: calc(0.0962 * var(--w));
    left: 50%;
    transform: translateX(-50%);
    color: #000;
    font-size: calc(0.25 * var(--w));
    line-height: 1;
}

/* -----------------------------------------------------------------------------
   Police light editor  (/hudpolice edit)
   -----------------------------------------------------------------------------
   Deliberately much smaller than #editor: it has one purpose (drag sliders,
   watch the edges of the screen) and does not need a list, drag-to-move, or a
   collapse state. Reuses the same .glass surface and text tokens as the HUD
   editor and skills panel so it doesn't read as a fourth, slightly-different
   material.
   -------------------------------------------------------------------------- */
#police-editor {
    position: fixed;
    inset: 0;
    z-index: 100;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    padding: 0 calc(4 * var(--w));
    pointer-events: none;
    font-family: system-ui, 'Segoe UI Variable Text', 'Segoe UI', -apple-system,
                 'SF Pro Text', Inter, Roboto, sans-serif;
}
#police-editor.hidden { display: none !important; }

#pol-ed-panel {
    width: calc(30 * var(--w));
    min-width: 320px;
    max-width: min(60vw, 460px);
    pointer-events: auto;
    border-radius: calc(1.5 * var(--w));
    color: var(--text);
    font-size: calc(0.96 * var(--w));
}

#pol-ed-head {
    padding: 1.4cqh calc(1.5 * var(--w)) 1.0cqh;
    border-bottom: 1px solid var(--g-line);
    display: flex;
    flex-direction: column;
    gap: 0.2cqh;
}
#pol-ed-title { font-size: calc(1.2 * var(--w)); font-weight: 600; letter-spacing: -0.01em; }
#pol-ed-sub { font-size: calc(0.8 * var(--w)); color: rgba(244, 244, 242, 0.5); }

/* Which of the two rendering modes (four edges, or one lamp tracking the
   exact bearing) the sliders below are shaping. Both modes share every
   slider, so this is the only control that needs its own row. */
#pol-ed-modes, #pol-ed-shapes {
    display: flex;
    gap: calc(0.4 * var(--w));
    margin-top: 0.6cqh;
}
.pol-mode-btn, .pol-shape-btn {
    font: inherit;
    font-size: calc(0.84 * var(--w));
    font-weight: 500;
    padding: 0.5cqh calc(0.9 * var(--w));
    border-radius: calc(0.5 * var(--w));
    cursor: pointer;
    color: rgba(244, 244, 242, 0.6);
    border: 1px solid rgba(255, 255, 255, 0.12);
    background: rgba(255, 255, 255, 0.045);
}
.pol-mode-btn:hover, .pol-shape-btn:hover { color: var(--text); background: rgba(255, 255, 255, 0.09); }
.pol-mode-btn.active, .pol-shape-btn.active {
    color: var(--text);
    background: linear-gradient(180deg, rgba(95, 216, 200, 0.34), rgba(95, 216, 200, 0.17));
    border-color: rgba(95, 216, 200, 0.4);
}

#pol-ed-body {
    padding: 1.2cqh calc(1.5 * var(--w));
    display: flex;
    flex-direction: column;
    gap: 1.1cqh;
    max-height: 52cqh;
    overflow-y: auto;
}
.pol-ed-group {
    font-size: calc(0.76 * var(--w));
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: rgba(244, 244, 242, 0.4);
    margin-top: 0.4cqh;
}
.pol-ed-group:first-child { margin-top: 0; }
#pol-ed-body::-webkit-scrollbar { width: calc(0.34 * var(--w)); }
#pol-ed-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.16); border-radius: 999px; }
#pol-ed-body::-webkit-scrollbar-track { background: transparent; }
.pol-ed-row {
    display: grid;
    grid-template-columns: calc(8 * var(--w)) 1fr calc(3.6 * var(--w));
    align-items: center;
    gap: calc(0.7 * var(--w));
}
.pol-ed-row label { font-size: calc(0.88 * var(--w)); color: rgba(244, 244, 242, 0.78); }
.pol-ed-val {
    font-size: calc(0.84 * var(--w));
    font-variant-numeric: tabular-nums;
    color: rgba(244, 244, 242, 0.6);
    text-align: right;
}
.pol-ed-row input[type="range"] {
    width: 100%;
    accent-color: rgb(95, 216, 200);
}
#pol-ed-white-row {
    display: flex;
    align-items: center;
    gap: calc(0.6 * var(--w));
    font-size: calc(0.88 * var(--w));
    color: rgba(244, 244, 242, 0.78);
    cursor: pointer;
}
#pol-ed-white-row input { accent-color: rgb(95, 216, 200); }

#pol-ed-actions {
    display: flex;
    justify-content: flex-end;
    gap: calc(0.6 * var(--w));
    padding: 1.1cqh calc(1.5 * var(--w));
    border-top: 1px solid var(--g-line);
}
#pol-ed-actions button {
    font: inherit;
    font-size: calc(0.9 * var(--w));
    font-weight: 500;
    white-space: nowrap;
    padding: 0.82cqh calc(1.1 * var(--w));
    border-radius: calc(0.62 * var(--w));
    cursor: pointer;
    color: var(--text);
    border: 1px solid rgba(255, 255, 255, 0.15);
    background: linear-gradient(180deg, var(--g-fill-hi), var(--g-fill));
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.20);
}
#pol-ed-actions button.ghost {
    color: rgba(244, 244, 242, 0.72);
    background: rgba(255, 255, 255, 0.045);
    box-shadow: none;
    border-color: rgba(255, 255, 255, 0.12);
}
#pol-ed-actions button.primary {
    background: linear-gradient(180deg, rgba(95, 216, 200, 0.34), rgba(95, 216, 200, 0.17));
    border-color: rgba(95, 216, 200, 0.4);
}
