/* ============================================================================
   AURA HERO - ui.css        (owned by js/ui.js; this file is the whole HUD skin)

   REBUILT AGAINST A MEASURED SPEC. Every number below is a port of a measurement
   taken off the reference interface at 1920x1080 landscape. We copy the
   CONSTRUCTION (panel anatomy, border weight, bevel depth, type ladder, colour
   discipline). We copy NONE of its artwork - every glyph in js/ui.js is ours and
   on our own theme (ki, aura, martial arts, transformation).

   ---------------------------------------------------------------------------
   THE PORT RULE (why the numbers below are what they are)
   ---------------------------------------------------------------------------
   The spec hands us exactly one portrait anchor: the quest card is 7.22%H tall
   and keeps its own 2.56:1 aspect when re-anchored to the right edge.
       844 * 0.0722 = 60.9  ->  card 61px tall, 61 * 2.56 = 156px wide.
   That fixes ONE component scale for everything INSIDE a component:
       k = 61 / 78 = 0.782            (61px is our card, 78px was theirs)
   Every internal measurement in this file is a reference pixel * k, and the
   derivation is written next to it. Screen-level ANCHORS use the spec's own
   screen fractions applied to our axes (top 2.41%H = 20px, bottom 1.94%H = 16px,
   quest card top 8.33%H = 70px, ability button height 7.59%H = 64px).
   Layout that cannot survive the landscape->portrait flip (the reference's one
   centred top cluster of level + xp + three wallet pills is 29.8% of a 1920px
   row; the same cluster is 150% of a 390px row) is re-anchored per the spec's
   own rule: "everything is either centred on the screen axis or hugs an edge".

   ---------------------------------------------------------------------------
   TWO PANEL FAMILIES. THEY ARE NEVER MIXED.
   ---------------------------------------------------------------------------
   FAMILY A "HUD GLASS" - anything drawn over the 3D scene.
     fill rgba(30,30,40,.40)  -> the terrain reads THROUGH it. This is the single
     biggest correction in this pass: the old --panel was rgba(24,18,42,.84),
     more than twice as opaque, which is what made the HUD sit on top of the game
     instead of in front of it.
     rim  2.5px rgba(10,10,25,.62)   (3px * k = 2.35, rounded to a crisp 2.5)
     drop soft (0,+3) blur 8 at 18% black. NO hard offset shadow under a plate.
     radius 12px (15 * k = 11.7). No inner highlight in the normal state.
     The currency pills are the same recipe DENSER: rgba(20,14,6,.78).

   FAMILY B "MENU SOLID" - modals. Fully opaque, flat, zero gradient, ZERO
     border, soft shadow only. The hue changes per screen; the construction never
     does. Depth inside a menu is a DARKER SHADE OF THE PARENT, never a stroke.
     One detail card per dialog and it is pure #FFFFFF.

   ---------------------------------------------------------------------------
   ONE BUTTON. FOUR LAYERS. (.btn)
   ---------------------------------------------------------------------------
     1  3px outer stroke in a dark saturated version of the FACE hue (never a
        global black) -> 2.5px here
     2  FLAT face, no gradient across the body
     3  2px lighter inner highlight on the very top edge only
     4  an 8-10px SOLID darker bottom band == 11% of the button height. This band
        is the entire plastic illusion and the old build did not have it: it had
        `inset 0 -3px 0 rgba(0,0,0,.22)`, a third of the depth and a translucent
        black rather than a solid deeper shade of the face.
   PRESSED  = translate +4px down and eat the lip (7px -> 3px).
   DISABLED = DESATURATED, NEVER FADED. Geometry identical, opacity stays 100%,
              the coloured stroke disappears, the label stays white.
   LOCKED   = the container is UNTOUCHED and only the glyph changes, in two
              opposite languages: in-world buttons get a DARK engraved glyph in
              the button's own shadow colour; menu slots get a LIGHT ghost glyph.

   ---------------------------------------------------------------------------
   COLOUR DISCIPLINE (the reason this reads as a product and not as a toy)
   ---------------------------------------------------------------------------
   At most ONE saturated hue per component. The 3D scene supplies the colour; the
   chrome is near-neutral dark. Colour is spent ONLY on: a progress fill, a commit
   button, a reward state, an attention badge. There is no decorative colour. In
   particular the bottom rail is now three IDENTICAL slate buttons - the old
   green / blue / purple rail was three saturated hues doing no work at all.

   TEXT SEPARATION IS CONTEXTUAL, not one global outline:
     over the 3D world / on glass  -> hard 4-way outline in near-black --wo
     on a button face              -> hard outline in THAT BUTTON'S stroke hue
     on a solid menu panel         -> soft drop shadow only, no outline
     below ~12px                   -> no outline at all, 1px shadow only
                                      (12px here = the spec's ~11px cap * k)

   BAR-HEIGHT LADDER - importance is expressed as height, four+ distinct values:
     XP 24px  >  boss 20  >  hero HP 18  >  cargo 14  >  quest 9
   The XP bar is also the ONE bar in the game that is not a full pill (r5),
   exactly as measured.
   ============================================================================ */

:root {
  /* ---- FAMILY A: HUD GLASS ------------------------------------------------ */
  --glass:      rgba(30, 30, 40, .40);   /* measured composite over rock #414249 / over sand #9C8145 */
  --glassRim:   rgba(10, 10, 25, .62);
  --glassDense: rgba(20, 14, 6, .78);    /* the currency-pill fill, same recipe denser */
  --rimW: 2.5px;                          /* 3px * k */
  --glassSh: 0 3px 8px rgba(0, 0, 0, .18);/* (0,+4) blur10 18% black * k. Soft, never hard. */

  /* ---- radii. One value per FAMILY, derived from that family's own height --- */
  --rGlass: 12px;   /* glass cards      15/78 * 61 */
  --rBtn:   16px;   /* world buttons    20/79 * 62  (r/w .25, a squircle) */
  --rCta:    8px;   /* menu CTA         11/81 * 56  (r/h .14) */
  --rPlate:  8px;   /* icon plates      10/56 * 44 */
  --rNest:  12px;   /* nested menu rows 16 * k */
  --rSheet: 18px;   /* menu dialog      26/556 * 390 */
  --rPill: 999px;   /* anything that is a full lozenge */

  /* ---- bar-height ladder -------------------------------------------------- */
  --barXp: 24px;      /* 31px = 2.87%H, the hero readout */
  --barBoss: 20px;
  --barHp: 18px;
  --barCargo: 14px;
  --barQuest: 9px;    /* 12px = 1.11%H */

  /* ---- type scale. Five steps, each with a job. --------------------------- */
  --tShout: 22px;   /* banners, level-ups */
  --tHero:  15px;   /* xp label, wallet values - the numbers that ARE the payload */
  --tCta:   14px;   /* menu CTA labels, menu row names */
  --tBar:   12px;   /* labels sitting ON a bar (deliberately > the card title) */
  --tCard:  11px;   /* quest title, menu row meta */
  --tMicro:  9px;   /* tags, badges, captions */

  /* ---- text separation ---------------------------------------------------- */
  --wo: #0A0A19;                          /* the near-black world ink */
  --ol1: 1px 0 0 var(--wo), -1px 0 0 var(--wo), 0 1px 0 var(--wo), 0 -1px 0 var(--wo);
  --ol2: 2px 0 0 var(--wo), -2px 0 0 var(--wo), 0 2px 0 var(--wo), 0 -2px 0 var(--wo);
  --ol3: 2.5px 0 0 var(--wo), -2.5px 0 0 var(--wo), 0 2.5px 0 var(--wo), 0 -2.5px 0 var(--wo), 0 4px 0 var(--wo);
  --sh1: 0 1px 0 rgba(0, 0, 0, .6);       /* small type on glass: shadow, no outline */
  --shSolid: 0 1px 2px rgba(0, 0, 0, .42);/* type on a solid menu panel */

  /* ---- progress fills. One saturated hue each, on a dark version of itself. */
  --cyan: #55E2F8;      --cyanTrack: #16203C;
  --orange: #F17F26;    --orangeTrack: #33270F;
  --hpGreen: #59C94C;   --hpTrack: #102C16;
  --gold: #F8C676;      --goldTrack: #33270F;
  --goldL: #FFE9A8;
  --red: #E9242F;       --redTrack: #2C0A08;
  --cantAfford: #FF7A6B;  /* the unaffordable COST NUMBER. State is hue, not opacity. */

  /* ---- reward gold (the claim-state skin flip) ----------------------------
     Sampled off reference frame f01260's completed mission card. The four ramp
     stops are the fill; --rwHi/--rwHi2 are the two stops of the cream INNER TOP
     HIGHLIGHT that only the claim state has (it is the whole "raised and
     pressable" signal); --rwRim is the border over a near-black outer line;
     --rwInk is the dark-brown caption. */
  --rw1: #F7DA10; --rw2: #F8E40D; --rw3: #EBBD00; --rw4: #DE9A02;
  --rwHi: #F5EBC2; --rwHi2: #F6DE76; --rwRim: #BB7B00; --rwInk: #6B4A00;

  --sat: env(safe-area-inset-top, 0px);
  --sab: env(safe-area-inset-bottom, 0px);
  --sal: env(safe-area-inset-left, 0px);
  --sar: env(safe-area-inset-right, 0px);

  /* ---- THE BAND MAP ------------------------------------------------------
     FALLBACKS ONLY. js/ui.js owns this table (see the BANDS block there) and
     publishes it onto #hud in publishBands(), at init and on every resize, so
     the live values always come from the JS. These duplicates exist so the
     single frame that can be painted between the stylesheet loading and initUI
     running is already in band, and so this file is readable on its own.
     Fractions of the LIVE viewport height:
       header  0.0000-0.1200  top bar + boss hp bar
       columns 0.1200-0.3350  rail LEFT, missions RIGHT, banner CENTRE
       world   0.3350-0.6513  THE PLAY AREA - npc speech bubbles, boss pointer,
                              plot cost tags, objective arrow, chest widget
       cargo   0.6513-0.7000  the cargo track and its chip row
       prompt  0.7000-0.8400  ONE prompt: cargo warning OR ftue hint, never both
       vitals  0.8400-0.8720  hp + shield
       dock    0.8720-1.0000  the button row and the ftue pointer
       gutter  0.6900-0.8600  LEFT COLUMN ONLY (x < 0.16): the objective stack
     NO FLOATING COMPONENT IN THIS FILE MAY CARRY A BARE VERTICAL PERCENTAGE
     AGAIN. If it needs a new anchor it needs a band, and the band lives in
     js/ui.js. The three that remain (#rail 12.55%, #missions 13.46%, #setPop
     17.6%) are COLUMN components: reference-measured positions inside the
     columns band, pinned to a side rather than floating over the play area, and
     they are what the columns band was sized around. */
  --bHeader0: 0%;        --bHeader1: 12%;
  --bColumns0: 12%;      --bColumns1: 33.5%;
  --bWorld0: 33.5%;      --bWorld1: 65.13%;
  --bCargo0: 65.13%;     --bCargo1: 70%;
  --bPrompt0: 70%;       --bPrompt1: 84%;
  --bVitals0: 84%;       --bVitals1: 87.2%;
  --bDock0: 87.2%;       --bDock1: 100%;
  --bGutter0: 69%;       --bGutter1: 85.8%;
  --bPromptTop: 71.5%;   /* the ONE anchor both prompt-band tenants draw at */
  --bArrowBot: 7.8%;     /* ftue pointer: the strip between the hp bar and the dock */
  --bChestTop: 53%;      /* measured (IMG_5245/5246), inside the world band */
}

/* ============================================================================
   THE PUBLISHED BAND EDGE.
   Three boxes with no ink, no hit area and no children. They exist so npc.js -
   a different module, frozen this pass, which draws its speech bubble in world
   space and asks the HUD where the play area is - gets an answer that comes off
   the same table every element below uses. It probes by id
   (`botOf('topLeft'/'topCenter'/'topRight')`, max of the three); those ids
   named nothing, so it measured 0, fell back to 0.22*H, and was licensed to put
   a bubble anywhere down to 0.912*H. Their BOTTOM EDGE is the world band's top
   and that is the entire component.
   z-index 0 and pointer-events none: they are a measurement, not a layer.
   ============================================================================ */
#bandFrame { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
.bandEdge {
  position: absolute; top: 0; height: var(--bWorld0);
  pointer-events: none; visibility: hidden;
}
#topLeft { left: 0; width: 33.33%; }
#topCenter { left: 33.33%; width: 33.34%; }
#topRight { right: 0; width: 33.33%; }

/* GameFont ships one weight, so synthetic bold is the only "heavier" available
   and Chromium smears it on a rounded display face at 9-12px. Weight is carried
   by SIZE and by the outline instead, which is what the reference actually does:
   its "weight ~800" body type is 36px tall on a 1080px screen. */
#hud { color: #fff; font-family: 'GameFont', system-ui, -apple-system, "Segoe UI", sans-serif; font-weight: 400; letter-spacing: .3px; }
#hud .pe { pointer-events: auto; }
#hud svg { display: block; }

/* ============================================================================
   FAMILY A - the one glass plate
   ============================================================================ */
.glass {
  background: var(--glass);
  border: var(--rimW) solid var(--glassRim);
  border-radius: var(--rGlass);
  box-shadow: var(--glassSh);
  box-sizing: border-box;
}

/* ============================================================================
   THE ONE BUTTON
   ============================================================================ */
.btn {
  /* default variant = the in-world ability button */
  --bFace: #4F4D5F;
  --bHi: #6B687D;
  --bLip: #25262F;
  --bStroke: #0E0D16;
  --bInk: #0E0D16;
  --bLipH: 7px;                   /* 8-10px of 82 == 11% of the height; 11% of 64 = 7 */
  /* The top rim is a FRACTION of the button, not a constant: measured 0.0714 of
     the height on the reference's dock keys. 2px is right for the 28-39px
     buttons this default serves; .wbtn overrides it for the 53px ones. */
  --bHiH: 2px;
  position: relative; box-sizing: border-box;
  display: flex; align-items: center; justify-content: center;
  border: var(--rimW) solid var(--bStroke);
  border-radius: var(--rBtn);
  background: var(--bFace);       /* FLAT. No gradient across the body, ever. */
  box-shadow:
    inset 0 var(--bHiH) 0 var(--bHi),               /* 2 - lighter top inner edge */
    inset 0 calc(-1 * var(--bLipH)) 0 var(--bLip),  /* 4 - the solid bottom band */
    0 3px 7px rgba(0, 0, 0, .3);                    /* soft cast, not a hard drop */
  transition: transform .07s, box-shadow .07s;
}
/* the label always wears its own button's stroke hue as an outline */
.btn > span { color: #fff; text-shadow: 1.5px 0 0 var(--bInk), -1.5px 0 0 var(--bInk), 0 1.5px 0 var(--bInk), 0 -1.5px 0 var(--bInk), 0 1px 1px rgba(0, 0, 0, .45); }
.btn.down { transform: translateY(4px); box-shadow: inset 0 2px 0 var(--bHi), inset 0 -3px 0 var(--bLip); }

.btn.commit  { --bFace: #97E249; --bHi: #80D369; --bLip: #5ECD27; --bStroke: #0B4A08; --bInk: #0B4A08; }
.btn.dismiss { --bFace: #FBD231; --bHi: #FFC644; --bLip: #F4AA11; --bStroke: #9A4E00; --bInk: #9A4E00; }
.btn.util    { --bFace: #FFB500; --bHi: #FFD166; --bLip: #E09300; --bStroke: #9D5300; --bInk: #9D5300; }
.btn.danger  { --bFace: #F6524C; --bHi: #FF8A83; --bLip: #C6332E; --bStroke: #7C1B17; --bInk: #7C1B17; }
/* DISABLED = desaturated. Same geometry, same 100% opacity, no coloured stroke.
   DECLARED LAST ON PURPOSE: .btn.off and .btn.commit have identical specificity,
   so source order is the only thing that makes a disabled BUY stop being green. */
.btn.off { --bFace: #CCCAD6; --bHi: #E4E3EA; --bLip: #9F9FAF; --bStroke: #A7A5B3; --bInk: #090909; }
.btn.off svg { filter: grayscale(1); }

/* SHARED ATTENTION BADGE, re-measured this pass. The reference uses ONE badge
   everywhere - rail button, dock button, mission sticker, equipment tile - and
   its anatomy is exact: a flat circle 56px across (0.0424*W = 17px at 390)
   centred on the host's TOP-RIGHT CORNER, filled #ed1f1f on the upper half and
   darkening to ~#b01818 at the bottom, with a 4px near-black outline (1.5px
   ported) and NO white ring. The old build had a 22px disc with a 2px white
   border, which is a different component wearing the same job.
   OURS STILL CARRIES A COUNT where the reference's carries a glyph: an empty
   badge is a promise with no number behind it, and the count is what makes the
   rail buttons worth looking at. min-width keeps a "9+" legible without letting
   a single digit stretch the disc. */
.badge {
  position: absolute; top: -8.5px; right: -8.5px; min-width: 17px; height: 17px;
  border-radius: 9px; box-sizing: border-box; padding: 0 3px;
  background: linear-gradient(180deg, #ED1F1F 0%, #ED1F1F 46%, #B01818 100%);
  box-shadow: 0 0 0 1.5px var(--wo), 0 1px 2px rgba(0, 0, 0, .4);
  display: none; align-items: center; justify-content: center; z-index: 3;
}
.badge i { font-style: normal; font-size: 10px; line-height: 1; color: #fff; }
.hasBadge .badge { display: flex; animation: badgePulse 1.1s ease-in-out infinite; }
@keyframes badgePulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } }

/* ============================================================================
   #lay - THE LAYOUT FRAME, and the reason percentages are safe below.

   This pass re-anchors the HUD to MEASURED SCREEN FRACTIONS taken off the
   reference at 1320x2868 (see js/ui.js for the per-component derivations). A
   fraction of the WIDTH only means what it measured if the box it resolves
   against is phone-shaped: `left: 47.35%` is the coin pill on a 390px screen and
   a 570px offset in a 1200px desktop window. #lay is that box - full width on a
   phone, capped at 440 and centred on anything wider, side safe-areas removed -
   and every fraction-positioned component lives inside it.
   Full HEIGHT, because the vertical fractions (the cargo bar at 0.6513*H, the
   mission column at 0.1346*H, the objective stack's bottom anchor) are meant to
   track the real screen height and do.
   ============================================================================ */
#lay {
  position: absolute; top: 0; bottom: 0; left: 50%; transform: translateX(-50%);
  width: min(calc(100% - var(--sal) - var(--sar)), 440px);
  pointer-events: none; z-index: 2;
}

/* ============================================================================
   GENERATED ICONS, WITH THE VECTOR STILL BEHIND THEM.
   pic() in js/ui.js emits <span class="pic"><img><span class="picFb">SVG. The
   image wins while it loads; if it 404s js/ui.js removes the <img>, at which
   point the sibling selector below stops matching and the fallback paints. That
   is the whole graceful-degradation contract, and it is CSS rather than JS on
   purpose: there is no state to get wrong.
   ============================================================================ */
.pic { position: relative; display: block; width: 100%; height: 100%; }
.pic > .picImg { display: block; width: 100%; height: 100%; object-fit: contain; }
.pic > .picFb { position: absolute; inset: 0; }
.pic > .picFb > svg { width: 100%; height: 100%; }
.pic > .picImg + .picFb { display: none; }

/* ============================================================================
   THE TOP BAR - ONE ROW, FIVE SLOTS.
   MEASURED ON r01 (IMG_5241, 1320x2868). Left to right:
       level diamond . XP track . coin pill . crystal pill . cores pill

   ANCHOR. The topmost HUD pixel sits at y=219 on a device whose safe-area top
   is 186, i.e. HUD top = safeTop + 33px = safeTop + 0.011*H. That is the port
   rule, and it is why this bar is `var(--sat) + 9px` on our 844px axis rather
   than a hardcoded 20.

   WHAT CHANGED AND WHY. The old build split this into a wallet STACKED down the
   top-left corner and a level+xp cluster CENTRED above it - three widgets in two
   places. The reference keeps all five in one row, and that row is what makes
   the header read as a single instrument. The pills share one baseline
   (0.0847..0.1077 of H, i.e. 19px tall at 844) and the diamond is 1.7x taller
   than the row, overlapping the XP track's left cap - which is exactly what caps
   that end and is why there is no rounded left corner on the track.
   ============================================================================ */
#topBar { position: absolute; top: calc(var(--sat) + 9px); left: 0; right: 0; height: 33px; z-index: 3; }

/* THE LEVEL DIAMOND: a rounded square rotated 45deg, NOT a circle.
   THE ROUNDING HAS TO BE PAID FOR IN THE BOX, which is what went wrong here.
   The old rule set the box from a SHARP rotated square (side = bbox/sqrt2, so
   inset 14.65%) and then rounded the corners by 22%, which cut 5.8px off the
   visible shape without changing the box: measured 27.0px against the
   reference's 113 native = 0.0856*W = 33.4px, i.e. 19% narrow and reading as a
   blob rather than a diamond.
   A rounded square of side s rotated 45deg has a visible width of
   s*sqrt2 - 2r(sqrt2 - 1). At the reference's radius (arc spans 15 rows on
   IMG_5241 -> r = 10.6 native = 12.3% of the side) that is 1.312*s, so s = 25.5
   and the box is 33.4px: width 8.56%, inset 11.9%.
   THE GRADIENTS ARE DECLARED IN THE ROTATED FRAME. ::before is a child of the
   45deg rotation, so a 180deg gradient ran corner to corner, not top to bottom -
   which is why our face looked lit from the top-left corner instead of shaded
   down its screen axis. Local 135deg IS screen 180deg. The face also grades
   DOWNWARD INTO LIGHT (#2b2b2b top -> #4b4b4b bottom, flat #3e3e3e across the
   waist on IMG_5241); ours ran the other way and 27% darker overall. */
.lvlWrap {
  position: absolute; left: 2.50%; top: 0; width: 8.56%; aspect-ratio: 1;
  display: flex; align-items: center; justify-content: center;
  filter: drop-shadow(0 2px 3px rgba(0, 0, 0, .45));
}
.lvlDiamond {
  position: absolute; inset: 11.9%; transform: rotate(45deg);
  border-radius: 12.3%; box-sizing: border-box;
  background: linear-gradient(115deg, #C6C6C6, #A5A5A5);
  /* the keyline reads at 1.414x its own weight across a 45deg edge, so 1.4px was
     drawing a 2px black line against the reference's 0.6 */
  box-shadow: 0 0 0 1px var(--wo);
}
/* THE RIM IS MEASURED ACROSS THE WAIST, NOT AS AN INSET. Rounding the outer
   corner eats part of the band, so the geometric inset and the band a player
   sees are not the same number. Reference IMG_5241, row y=273: silver runs
   native x 34..47 = 3.8-4.1 CSS px at value ~193 over a face of ~61. Ours at
   inset 2.4 measured 2.5 CSS at 176; at 3.4 it measures 3.9. The gradient's dark
   stop moved #8A8A8A -> #A5A5A5 for the value, which was 17 levels down. */
.lvlDiamond::before {
  content: ''; position: absolute; inset: 3.4px; border-radius: 10%;
  background: linear-gradient(135deg, #2B2B2B, #4B4B4B);
}
/* glyph height 31 native = 0.0108*H = 9px cap at 844 -> 13px type, white with a
   3px (0.9px ported) black outline */
#lvlNum { position: relative; z-index: 1; font-size: 13px; line-height: 1; color: #F3F3F3; text-shadow: var(--ol1); }

/* ---- bars: one trough recipe, several heights --------------------------- */
.xpTrack, .bossTrack, .statBar, .qTrack {
  position: relative; box-sizing: border-box; overflow: hidden;
  border: none; box-shadow: var(--glassSh);
}
#xpFill, #bossFill, #hpFill, #qFill, #cargoFillBar {
  position: absolute; left: 0; top: 0; bottom: 0;
  transition: width .25s;
}

/* XP TRACK. x 0.0985..0.3606 (26.21% of W, 102px at 390), same 19px band as the
   pills. Opaque #232123, no border. The fill is INSET - 3px left/right and 7px
   top/bottom natively, which ports to 1px / 2.4px - and that inset is why a
   displayed 75.2% measured as a 72% bar: the fill is measured inside the inset,
   not edge to edge. */
.xpTrack {
  position: absolute; left: 9.85%; top: 7px; width: 26.21%; height: 20px;
  border-radius: var(--rPill); background: #232123;
}
/* flat orange body under a 2.6px top highlight strip that fades into it, which
   is the only shading measured anywhere on this bar */
#xpFill {
  left: 1px; top: 2.4px; bottom: 2.4px; width: 0%; border-radius: var(--rPill);
  background: linear-gradient(180deg, #FFBC8B 0%, #FFA870 22%, #F2802B 42%, #EF7E2B 100%);
}
/* CENTRED OVER THE WHOLE TRACK, not over the fill (measured). cap 26 native =
   0.0091*H = 7.7px -> 11px type. */
#xpText {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-size: var(--tCard); letter-spacing: .2px; text-shadow: var(--ol1);
}

/* ---- the three currency pills -------------------------------------------
   All three share the row's 19px band and a full-pill radius on an opaque
   #141417 fill. The difference between them is the ICON PLACEMENT, and it is
   load-bearing:
     coin + crystal  the icon HANGS OFF the left edge (measured: icon centre 31px
                     left of the pill start) with the green "+" welded to its
                     lower-right, half outside it. That overhang is what fills
                     the 0.0636*W gap between pills so the row reads continuous.
     cores (wide)    the reference's energy pill: 0.1924*W, icon INSIDE at the
                     left, no overhang and no "+".
   ------------------------------------------------------------------------- */
.cpill {
  position: absolute; top: 7px; height: 20px; box-sizing: border-box;
  border-radius: var(--rPill); background: #141417; box-shadow: var(--glassSh);
}
#coinPill { left: 47.35%; width: 11.44%; }
#crysPill { left: 65.15%; width: 11.52%; }
/* 13.00%, NOT the reference's 19.24%. That width was the ENERGY pill's, and it
   was that wide because it carries a chevron in its right end (measured: white
   glyph at x 0.9280..0.9553*W, and the reference's ink reaches within 2.4px of
   the pill's right edge). We dropped the chevron on purpose - three currencies,
   all three already on this row, so an expander opens onto nothing - and then
   kept its width, which left 42.4px of empty black, 56% of the pill. Dropping
   the component means dropping the space it occupied. 13.00% ends the pill 4px
   past the value's ink, which is the same trailing air the other two pills have. */
#corePill { left: 77.42%; width: 13.00%; }
/* 78px dia = 0.0591*W = 23px at 390; centre 31px (9px) left of the pill start */
.cpIco { position: absolute; left: -21px; top: -1.5px; width: 23px; height: 23px; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .5)); }
/* 48x64 native = 14x19 at 390, sitting inside the wide pill at its left */
.cpIcoIn { position: absolute; left: 2px; top: 1px; width: 17px; height: 18px; }
.cpVal {
  position: absolute; left: 4px; right: 5px; top: 0; bottom: 0;
  display: flex; align-items: center; justify-content: flex-end;
  font-size: var(--tCard); letter-spacing: .2px; text-shadow: var(--ol1);
}
#corePill .cpVal { left: 21px; justify-content: flex-start; }
/* THE BADGE HUGS THE COIN, IT DOES NOT SIT ON THE PILL. Measured on IMG_5241:
   the reference badge is 0.0288*W and 81.6% of it hangs OUTSIDE the pill, with
   its centre 0.0091*W (3.55px) LEFT of the pill's left edge. At left:-7px ours
   was centred exactly ON that edge with only 50% outside, so the mark read as
   part of the pill's chrome instead of as a control welded to the coin.
   left:-10px reproduces the measured centre offset and the overhang (79%).
   ART NOTE, NOT A CSS FIX: `assets/ui/icons/state-plus-128/256.png` is a green
   DISC with a small white plus inside it, and the white glyph is 42 of the
   asset's 128px frame, so at this size the plus a player actually sees is
   4.6px against the reference's bare 11.2-11.8px cross. That is why it reads as
   a green dot. The asset wants regenerating as a bare chunky cross at #58FF1A
   filling its frame; the box below is already the right size for one. */
.cpPlus { position: absolute; left: -10px; top: 9px; width: 14px; height: 14px; z-index: 2; }
.cpPlus::before { content: ''; position: absolute; inset: -15px; }   /* 44px hit area */
.cpPlus.down { transform: scale(.9); }
.cpill.pop { animation: curPop .3s cubic-bezier(.2, 1.4, .4, 1); }
@keyframes curPop { 40% { transform: scale(1.1); } }

/* ---- boss hp bar. LEFT-ANCHORED under the top bar, not centred.
   The mission column now owns the right 32.88% of the frame from y = 0.1346*H
   downward. A centred 196px bar on a 390px screen spans x 97..293, so 31px of it
   would sit under the cards; anchored left at 10px it ends at x=250 and the two
   can never meet. That also retires the 30px drop this bar used to make whenever
   a reward went unclaimed (#bossBar.qclaim is gone with it). ---- */
/* THE SLIDE-IN TRAVEL IS 4px, NOT 10, AND THAT IS THE HEADER'S ARITHMETIC.
   #topBar ends at var(--sat) + 42 and this bar's home is var(--sat) + 46, so
   there are exactly 4px of clear air above it. A 10px rise started the bar 6px
   INSIDE the currency row and dragged the boss name across the coin pill for the
   length of the transition - caught by _bandshot.js as an intermittent
   `topBar x bossBar` clash, intermittent only because it depends on whether the
   capture lands inside the 300ms. Opacity carries the reveal; the travel is now
   whatever the gap allows. */
#bossBar {
  position: absolute; top: calc(var(--sat) + 46px); left: 10px;
  width: min(62%, 240px); opacity: 0; transform: translateY(-4px);
  transition: opacity .3s, transform .3s; z-index: 2;
}
#bossBar.show { opacity: 1; transform: none; }
#bossName {
  font-size: var(--tCard); letter-spacing: 2px;
  color: #FFD54A; text-shadow: var(--ol1); margin-bottom: 3px;
}
.bossTrack { height: var(--barBoss); border-radius: var(--rPill); background: var(--redTrack); }
#bossFill { width: 100%; background: var(--red); border-radius: var(--rPill); transition: width .2s; }


/* ============================================================================
   THE LEFT RAIL. Three stacks, exactly as measured on r01 / IMG_5245.

     A  two rail buttons     x 0.0280, y 0.1255, 0.1008*W square (39px at 390)
     B  the IAP promo strip  NOT BUILT - we have no IAP and the spec calls that
                             strip the lowest-priority thing in the frame, so its
                             0.111*H is left as clear play area
     C  the free-reward timer  x 0.033..0.199, y 0.530..0.613
     D  the objective stickers bottom-anchored, pitch 0.0572*H, max 3

   NOTHING IN C OR D TAKES A TAP. The whole rail sits inside the joystick's
   capture band, and a readout that steals a drag start is worse than no readout.
   Only A is pressable, and A is 39px at the very top of the band where a
   thumb-drag effectively never begins.
   ============================================================================ */
/* var(--sat) FOR THE SAME REASON #topBar HAS IT. This rail's 12.55% was measured
   on a device with a 0.0649 top inset, i.e. it is an offset from the SAFE top,
   not from the viewport top - which is what every other header-relative anchor
   in this file already assumes. Without the term the header grew with the notch
   and the rail did not, and #bossBar (var(--sat) + 46px, 37px tall) walked down
   onto this button: 11px of overlap at a 34px inset, 37px at 62px. */
#rail {
  position: absolute; left: 2.80%; top: calc(var(--sat) + 12.55%);
  display: flex; flex-direction: column; gap: 8px; z-index: 4;
}
/* the reference's mission-log button: dark brown #6b463a with a lighter inner
   plate over a near-black 5px outline (1.5px ported), radius ~30 native = 9px.
   It is .btn, so the four-layer recipe is inherited and only the hue moves. */
/* EVERY LAYER WAS ABOUT 25 RGB LEVELS TOO DARK, sampled off IMG_5241's own
   mission-log button: highlight #BB8B80 (we had #9E6E5A), face #926055 (#7A5040),
   lip #673F35 (#4A2C22), outline #3B1C13 (#180C07). Not a capture gamma
   difference - on the SAME frame our XP fill body matches within 4 levels and
   pure white and black are clean, so the button really was reading as burnt
   wood next to the reference's warm clay. The lip is 8 native = 2.4px there and
   was 5px here, and a lip that deep on a 39px key reads as a shadow rather than
   as a bevel. */
.railBtn {
  width: 39px; height: 39px; border-radius: 12px; --bLipH: 3px;
  --bFace: #926055; --bHi: #BB8B80; --bLip: #673F35; --bStroke: #3B1C13; --bInk: #3B1C13;
  flex: none;
}
.railBtn.slate { --bFace: #5B5470; --bHi: #7C749A; --bLip: #2E2A3E; --bStroke: #0E0D16; --bInk: #0E0D16; }
.railBtn svg { width: 22px; height: 22px; margin-bottom: 2px; }
.railBtn::before { content: ''; position: absolute; inset: -5px; }   /* 49px hit area */
.railBtn.on { transform: translateY(4px); box-shadow: inset 0 2px 0 var(--bHi), inset 0 -3px 0 var(--bLip); }
#gearBtn.on svg { animation: gearSpin .5s cubic-bezier(.3, 1.2, .4, 1); }
@keyframes gearSpin { to { transform: rotate(90deg); } }

/* ---- C: the free-reward timer widget (ours is the chest) ------------------
   Measured 0.166*W wide and 0.083*H tall: a big prop with a rayed glow behind
   it and a green countdown pill under it. The reference's counts to 0s and the
   prop becomes claimable; ours counts S.timers.chest down and then says OUT NOW,
   because a field chest is claimed by walking to it. ---- */
/* --bChestTop is the MEASURED 0.530*H (IMG_5245/5246), published from the band
   table in js/ui.js rather than written here, so the one orientation switch
   moves it with everything else. It is a world-band tenant and the quietest of
   them: it stands down while the boss indicator is on top of it (updateChest). */
#chestW {
  position: absolute; left: 3.3%; top: var(--bChestTop); width: 16.6%;
  display: none; flex-direction: column; align-items: center; z-index: 3;
}
#chestW.on { display: flex; }
#chestRays {
  position: absolute; left: 50%; top: -4px; width: 74px; height: 74px; margin-left: -37px;
  border-radius: 50%; opacity: .85;
  background: radial-gradient(circle, rgba(255, 226, 150, .55) 0%, rgba(255, 200, 90, .22) 42%, transparent 70%);
  animation: chestPulse 2.2s ease-in-out infinite;
}
@keyframes chestPulse { 0%, 100% { transform: scale(1); opacity: .55; } 50% { transform: scale(1.12); opacity: .95; } }
/* 135x160 native = 40x47 at 390x844 */
#chestIco { position: relative; width: 44px; height: 44px; filter: drop-shadow(0 3px 3px rgba(0, 0, 0, .45)); }
/* 210x40 native = 0.159*W x 0.014*H = 62 x 12 */
#chestPill {
  position: relative; margin-top: 3px; width: 62px; height: 14px; border-radius: var(--rPill);
  background: #2E9A3B; box-sizing: border-box; box-shadow: 0 0 0 1.4px var(--wo), var(--glassSh);
  display: flex; align-items: center; justify-content: center;
}
#chestTxt { font-size: var(--tMicro); letter-spacing: .4px; color: #fff; text-shadow: var(--sh1); }
/* the claimable state: the reference's timer widget goes bright the instant the
   countdown hits zero, and that is the only moment it is allowed to shout */
#chestW.live #chestPill { background: var(--gold); }
#chestW.live #chestTxt { color: #40270A; text-shadow: none; }
#chestW.live #chestIco { animation: chestBob .8s ease-in-out infinite; }
@keyframes chestBob { 0%, 100% { transform: translateY(0) scale(1); } 50% { transform: translateY(-4px) scale(1.06); } }

/* ---- D: the objective stickers -------------------------------------------
   0.0955*W squares (37px), pitch 0.0572*H (48px, i.e. an 11px gap), anchored to
   the BOTTOM and growing upward, and with NO CONTAINER PLATE: each is a
   free-form illustrated sticker sitting directly on the world with its own dark
   outline and a soft drop shadow. The badge is the shared component, centred on
   the sticker's top-right corner. ---- */
/* THE LEFT GUTTER. Bottom-anchored on the gutter band's floor - this stack
   grows upward through three bands and is declared as a COLUMN rather than
   pretending to fit one of them (see the BANDS note in js/ui.js). Its x range
   is 0.037..0.132 and nothing centred can reach it. */
#objStack {
  position: absolute; left: 3.71%; bottom: calc(100% - var(--bGutter1));
  display: flex; flex-direction: column; gap: 11px; z-index: 3;
}
.objCard { position: relative; width: 37px; height: 37px; display: none; }
.objCard.on { display: block; animation: objIn .28s cubic-bezier(.2, 1.4, .4, 1); }
@keyframes objIn { from { transform: translateX(-16px) scale(.8); opacity: 0; } }
.objIco { width: 100%; height: 100%; filter: drop-shadow(0 3px 3px rgba(0, 0, 0, .5)); }
/* these are always-on markers, not counts, so their badge does not wait for
   .hasBadge the way a rail button's does */
.objCard .badge { display: flex; }


/* Settings popover, opening BESIDE the gear now that the gear lives on the left
   rail (a small menu solid, so it is opaque). It flies out to the right because
   the gear sits at x 2.80%W and there is nothing to its left. */
#setPop {
  /* THE NOTCH TERM IS LOAD BEARING, do not drop it back to a bare percentage.
     #rail gained `calc(var(--sat) + ...)` so the gear rides the safe-area inset
     with the rest of the header; this popover flies out beside that gear, so it
     has to ride the same inset or it detaches by exactly the notch height. It
     was measured drifting -62px at the reference device's own 62px inset, i.e.
     the menu opened a full inset ABOVE the button that spawned it, and the
     capture harness could never see it because it runs at inset 0. */
  position: absolute; top: calc(var(--sat) + 17.6%); left: calc(2.80% + 47px); z-index: 6;
  display: flex; flex-direction: column; gap: 6px; padding: 7px;
  background: #241E38; border: none; box-sizing: border-box;
  border-radius: var(--rNest); box-shadow: 0 8px 18px rgba(0, 0, 0, .45);
  opacity: 0; transform: translateX(-8px) scale(.92); transform-origin: 0 20%;
  /* VISIBILITY, NOT JUST OPACITY + POINTER-EVENTS. `pointer-events: none` here
     was being overridden by the `pe` class on the rows INSIDE, so a closed,
     invisible popover kept two 36px live tap targets parked at x 262..382,
     y 62..154 - directly on top of the quest card. Harmless while the card was
     inert; it swallowed the claim tap outright the moment the card became a
     button (measured: elementFromPoint at the card's centre returned #sndSfx).
     visibility:hidden takes the whole subtree out of hit-testing and children do
     not re-enable it, and it is delayed by the fade so the close still animates.
     Same recipe #sheet already uses. */
  visibility: hidden; pointer-events: none;
  transition: opacity .16s, transform .16s cubic-bezier(.2, 1.2, .4, 1), visibility 0s .16s;
}
#setPop.open {
  opacity: 1; transform: none; pointer-events: auto; visibility: visible;
  transition: opacity .16s, transform .16s cubic-bezier(.2, 1.2, .4, 1), visibility 0s 0s;
}
/* nesting inside a menu solid = a LIGHTER slot, never a border */
.setRow {
  display: flex; align-items: center; gap: 9px; height: 36px; padding: 0 10px;
  border-radius: var(--rCta); background: #362E52;
  font-size: var(--tCard); letter-spacing: 1px; text-shadow: var(--shSolid);
}
.setRow span { flex: 1; min-width: 54px; }
.setIco { width: 17px; height: 17px; flex: none; }
.setIco svg { width: 100%; height: 100%; }
.setSw {
  position: relative; width: 32px; height: 17px; border-radius: var(--rPill); flex: none;
  background: #5ECD27; transition: background .15s;
}
.setSw i {
  position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%;
  background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .4);
  transform: translateX(15px); transition: transform .15s cubic-bezier(.2, 1.2, .4, 1);
}
.setRow.off .setSw { background: #4A4560; }
.setRow.off .setSw i { transform: translateX(0); }
.setRow.off .setIco { filter: grayscale(1); }
.setRow.down { transform: scale(.97); }

/* ============================================================================
   THE MISSION COLUMN - measured on IMG_5256 (three cards + expander) and r01
   (the gold complete state).

   COLUMN GEOMETRY, verbatim from the measurement:
     cards are FLUSH TO THE RIGHT SCREEN EDGE and clipped there - the right
     rounded corners are off-screen, which is what makes them read as tabs
     sliding in rather than as floating cards
     left edge      0.6712*W          visible width 0.3288*W  (128px at 390)
     height         0.0596*H  (50px)  pitch 0.0666*H  (56px, i.e. a 6px gap)
       RE-MEASURED. The card box is 171 native outline-to-outline on IMG_5246 /
       5249 / 5250 / 5256 / 5259 (= 50.3px here) with an 18-native gap (5.3px),
       not 48 + 8. The PITCH was already right; the height/gap split was not, and
       the 2px went straight out of the interior - which is where all three of
       this card's row collisions lived (see #qText below).
     first card top 0.1346*H  (114px) - level with the scrim line under the bar
     MAX 3 VISIBLE, with a down-chevron expander ~9px under the last one

   CARD ANATOMY
     translucent dark plate. The world TINTS it but does not READ through it -
     see the alpha note on .mCard below, which is a legibility budget rather
     than a copied number. 7px near-black outline on left/top/bottom (2px ported)
     plus a ~2px lighter inner stroke. Radius ~30 native = 9px, left corners
     only. Content left inset 28 native = 8px.
     title       cap 25 native = 11px type, wraps to 2 lines, pitch 13px
     reward row  a coin and the amount in GREY #a1a1a1 - grey because it is not
                 yet claimable. The moment it is, the card flips to the gold
                 skin and this row is replaced.
     bar         75 x 5px, full pill, track #292244, with the count centred ON
                 it and OVERFLOWING it vertically - the type is taller than the
                 bar, and that is measured, not a mistake.
     content column starts 8px from the card's OUTER edge (left:6 inside a 2px
                 border), which is the reference's 28-native inset re-read at
                 full resolution as 7.4-7.7px. It was 10px, and the 2px it cost
                 came out of the title field - the one box on this card that had
                 none to spare.
     tile        36px square, radius 7, inset 4px from the SCREEN edge, FLAT
                 light blue #499cf0 (constant, never per-mission - 30 flat 8x8
                 blocks with std < 1.5, identical on IMG_5246/5249/5256) with the
                 target's art bleeding to the tile's edges. It sits 6 native px
                 high of the card's centre.
   ============================================================================ */
#missions { position: absolute; top: calc(var(--sat) + 13.46%); right: 0; width: 32.88%; z-index: 2; }
/* THE ALPHA IS A LEGIBILITY BUDGET, NOT A SAMPLED NUMBER, and this is the one
   place on the HUD where those two answers disagree.
   A flat alpha blend passes world VARIANCE linearly - sigma(card) = (1-a) *
   sigma(world) - so the plate's job is to divide the meadow's texture down far
   enough that a rock silhouette stops being a silhouette. Measured behind the
   live stack in the meadow (_uifCardBg.js, _uifPix.js, shots/_uif_cardbg_idle):
   the world under card 3 runs sigma_R = 51.7 and peaks at luminance 245.
     a = .55  ->  surviving sigma_R 23.3, grey reward ink #A1A1A1 at 1.71:1
     a = .82  ->  surviving sigma_R  9.3, grey reward ink at 4.39:1
   1.71:1 is why foliage was reading through the reward row; 4.39:1 clears AA on
   the BRIGHTEST pixel in frame, not the average one. 18% of the world still
   comes through, so the card is still glass and still takes the meadow's colour.
   XP HERO'S OWN CARD SOLVES TO ~rgba(31,31,31,0.40) - LOWER than ours already
   was. Matching it would have made this worse, so this is a deliberate
   departure and not a miss. The fill is neutralised from (20,16,32) toward grey
   because the extra 16 points of blue were the only reason our card read colder
   than the reference's at any alpha. */
.mCard {
  position: absolute; left: 0; right: 0; height: 50px; box-sizing: border-box;
  background: rgba(18, 16, 26, .82);
  border: 2px solid rgba(8, 6, 14, .82); border-right: none;
  border-radius: 9px 0 0 9px;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .10), 0 3px 8px rgba(0, 0, 0, .28);
}
#qCard { top: 0; }
#mq1 { top: 56px; } #mq2 { top: 112px; }
/* TYPE FITTED TO THE FIELD, NOT TO THE TOKEN LADDER. Measured cap is 25 native
   = 0.0087*H = 7.3px, which is a ~10px face - one notch under --tCard.
   THE THREE ROWS NOW FIT, WHICH THEY DID NOT. Measured card-relative on the
   48px box (_uifFit.js): title 6..29, reward 28..39, bar 40..45 and #qCount
   34..51 - i.e. the title's box crossed the reward row on 29 of 30 shipped
   quests, and the count hung 3px below the card entirely. On the 50px box:
     title  4..27   (top:2, two 11.5px lines)
     reward 27..38  (top:25)
     bar    40..45  (top:38)   5px of clearance under it, was 3
   --title-fs IS SET PER QUEST BY renderQuest(), not here. Three shipped titles
   need three lines at 10px in a 76px field and were being cut mid-phrase with no
   ellipsis - "Charge aura and TRANSFORM" rendered as "Charge aura / and", losing
   the verb the mission is named after. The ladder in ui.js steps the face down
   until the string fits two lines; 29 of 30 keep the measured 10px. */
#qText {
  position: absolute; left: 6px; top: 2px; right: 42px;
  font-size: var(--title-fs, 10px); line-height: 1.15;
  max-height: calc(var(--title-fs, 10px) * 2.3); overflow: hidden;
  color: #fff; text-shadow: var(--sh1);
}
/* THE REWARD ROW IS RIGHT-ALIGNED ABOVE THE BAR'S TAIL, not left-aligned under
   the title, and that is arithmetic rather than taste: the card is 50px tall,
   the title owns y4..27 and the bar owns y40..45, so the row has 13px between
   them and it has to share that band with a count that is centred on the bar at
   x33..58. Right-aligning it to x60..86 is the only placement where all three
   fields fit in the measured box without shrinking one of them.
   XP Hero left-aligns its chip under the title instead (IMG_5256, card-rel
   x19..44) and we do not, deliberately: their card has no count centred on the
   bar to collide with, and n=1 across 19 stills is not enough to trade a
   collision-free layout for. */
#qReward { position: absolute; right: 44px; top: 25px; height: 11px; display: flex; align-items: center; gap: 6px; }
/* a rung with no reward gives its title the extra line instead of a hole */
#qCard.noRwd #qText { max-height: calc(var(--title-fs, 10px) * 3.45); }
/* OUTLINE, NOT SHADOW. Grey-on-glass is the weakest ink on the HUD and it sits
   on the busiest row of the card; --sh1 is a drop shadow and does nothing for it
   against a bright meadow. --ol1 is what every other small type on this HUD uses
   over the world, and it keeps the grey (which is the "not claimable yet" cue)
   instead of buying contrast by brightening the colour. */
.rwdPill { display: inline-flex; align-items: center; gap: 3px; font-size: var(--tMicro); color: #A1A1A1; text-shadow: var(--ol1); }
.rwdPill .pic { width: 11px; height: 11px; }
.rwdPill svg { width: 11px; height: 11px; }
.rwdPill b { font-weight: 400; }
.qTrack { position: absolute; left: 6px; top: 38px; width: 75px; height: 5px; border-radius: var(--rPill); background: #292244; }
#qFill { width: 0%; background: var(--cyan); border-radius: var(--rPill); }
/* deliberately taller than the bar it is centred on - see the anatomy note.
   15px not 17: at 17 the box ran to card-rel 51 on a 48px card, so the ink's
   descenders sat below the outline. Centred on the same bar, it now ends at 48. */
#qCount {
  position: absolute; left: 6px; top: 33px; width: 75px; height: 15px;
  display: flex; align-items: center; justify-content: center;
  font-size: 10px; line-height: 1; text-shadow: var(--ol1); pointer-events: none;
}
#qPlate {
  position: absolute; right: 4px; top: 5px; width: 36px; height: 35px;
  border-radius: 7px; box-sizing: border-box; overflow: hidden;
  background: #499CF0; box-shadow: 0 0 0 1.6px rgba(8, 6, 14, .85);
}
#qPortrait { position: absolute; inset: 1px; }
#qPortrait svg, #qPortrait .pic { width: 100%; height: 100%; }
#qCheck { position: absolute; inset: 6px; display: none; }
#qCheck svg { width: 100%; height: 100%; }
/* A WAIT-GATED QUEST REPLACES ITS BAR WITH ITS CLOCK rather than stacking a
   third row on a card whose height is fixed. "Returns in 1:20" IS the progress
   for a boss respawn or a chest timer, and 0/1 next to it says nothing. */
#qTimer {
  display: none; position: absolute; left: 8px; top: 35px; right: 42px; height: 12px;
  font-size: var(--tMicro); line-height: 12px; color: #8FD0EA; text-shadow: var(--sh1);
  white-space: nowrap; overflow: hidden;
}
#qCard.timed #qTimer { display: block; }
#qCard.timed .qTrack, #qCard.timed #qCount { display: none; }

#qCard.slidein { animation: qSlide .4s cubic-bezier(.2, 1.2, .4, 1); }
/* slide only, never fade: a half-transparent card mid-swap reads as a bug */
@keyframes qSlide { from { transform: translateX(60px); } to { transform: none; } }

/* while a boss is up the reward row steps aside - the active objective at that
   point is the boss, and the card still has to carry its title and its bar */
#qCard.bossMode #qReward { display: none; }

/* ---- the rest of the list. The two preview cards are the NEXT TWO RUNGS of
   BAL.quests, and they are collapsed by default: a preview of the chain is
   something the player asks for, not something that should sit unasked on the
   play area. The chevron under card 1 and the mission-log button at the top of
   the left rail are the two handles on the same toggle. ---- */
/* THE PREVIEW RUNGS CARRY THEIR STATE IN THE INK, NOT IN THE FILL. .42 was the
   old differentiator and it was the .mCard defect twice over - captured with the
   list open in the meadow, a rock silhouette and the villager both read straight
   through cards 2 and 3. At .72 they still did. The fill is now identical to the
   live card's; what says "next, not now" is the copy colour (#CFC9DF vs #fff)
   and the desaturated plate, which are the two things a player reads anyway.
   XP Hero does not dim its preview rungs at all (IMG_5256: plate #499cf0, title
   #f9f8f9 and fill all identical to the live card's) - keeping the ink dimmed is
   ours, because our list previews FUTURE rungs where theirs is a live stack. */
.mCard.prev { background: rgba(18, 16, 26, .82); opacity: 0; transform: translateX(26px); pointer-events: none; transition: opacity .2s, transform .22s cubic-bezier(.2, 1.2, .4, 1); }
.mCard.prev.none { display: none; }
#missions.open .mCard.prev { opacity: 1; transform: none; }
.mTxt {
  position: absolute; left: 8px; top: 0; bottom: 0; right: 42px;
  display: flex; align-items: center; font-size: 10px; line-height: 1.2;
  color: #CFC9DF; text-shadow: var(--sh1); overflow: hidden;
}
.mPlate {
  position: absolute; right: 4px; top: 5px; width: 36px; height: 35px;
  border-radius: 7px; box-sizing: border-box; overflow: hidden;
  background: #3C6F9E; box-shadow: 0 0 0 1.6px rgba(8, 6, 14, .7);
}
.mPlate .pic, .mPlate svg { width: 100%; height: 100%; }
.mPlate { filter: saturate(.6) brightness(.85); }
/* 42w x 33h native = 0.0318*W x 0.0115*H -> 12 x 10, drawn at 22x18 so the
   glyph has a tap target that does not need an invisible extension */
#mqExp {
  position: absolute; right: 11px; top: 59px; width: 22px; height: 18px;
  transition: top .22s cubic-bezier(.2, 1.2, .4, 1), transform .22s;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .45));
}
#mqExp::before { content: ''; position: absolute; inset: -13px; }   /* 48x44 hit area */
#mqExp svg { width: 100%; height: 100%; }
/* THE OPEN HANDLE SITS ON THE STACK, NOT UNDER IT. At top:169 the glyph ran to
   fy 0.3562 with 17.9px of itself inside the world band - a stray by the band
   map's own definition, and the only one the collapsed default was hiding.
   The arithmetic leaves no other answer: three cards at a 56px pitch from
   0.1346*H end at 0.3266, and the columns band floors at 0.3350, so 7px of
   clearance cannot hold an 18px chevron. Moving the band would have bought a
   transient expander 10px of the play area permanently, which is the wrong
   trade. XP Hero's own chevron overlaps its last card (IMG_5256, fy 0.2913..
   0.3142), so straddling card 3's bottom edge is also what the reference does.
   top:146 puts the settled glyph at fy 0.3076..0.3289 and clips 6px of the
   preview plate's bottom corner, under its own drop shadow. The extra 2px of
   headroom over top:148 pays for the ROTATION TRANSIENT: getBoundingClientRect
   of a mid-flip 22x18 box peaks at 26.7px tall, and at 148 that transient
   reached 0.3364 - outside the band for 220ms. Legal at every instant beats
   legal at rest. */
#missions.open #mqExp { top: 146px; transform: rotate(180deg); }
#mqExp.down { opacity: .75; }
/* ===========================================================================
   THE CLAIM STATE - the reward moment of the entire loop.

   Measured off reference frame f01260 (crop 270x150 at 1650,60, upscaled 3.4x
   with nearest-neighbour, then the plate again at 8x). The completed mission
   card there is 197 x 66 in a 1920x1080 frame and it bleeds to x=1920.

   THE CARD DOES NOT GAIN A CHIP - IT BECOMES ONE, IN THE SAME BOX. Height,
   left-corner radius and right-edge bleed are all inherited untouched from
   #qCard above; every declaration below is a SKIN change, and that is the whole
   point of the effect. What flips:
     fill      translucent glass  ->  opaque vertical gold ramp
     highlight none               ->  a cream inner top lip (the pressable cue)
     rim       --glassRim         ->  --rwRim over a near-black outer line
     bar       9px cyan track     ->  DELETED
     copy      one white title    ->  dark caption + white outlined imperative
     plate     flat blue          ->  light plate + overflowing green check

   THE PORT. Two different scales, because our card is proportionally narrower
   than theirs (156/197 = 0.79 across, 61/66 = 0.92 down), and type has to fit
   the field it sits in, not the height of the box:
     highlight   4px  * 0.92 = 3.7  -> 4px  (a 3.7px gradient band smears on the
                                             corner curve; 4px is the honest read)
     rim         3px  * 0.92 = 2.77 -> --rimW (2.5px). The BOX must not change
                                       size, so the extra weight is bought with a
                                       2px near-black outer line instead, which is
                                       what the reference has under its border.
     caption cap 10px * 0.79 = 7.9  -> 11px type
     imperative cap 14px * 0.79 = 11.1 -> 15px type (ratio 1.36 vs their 1.40)
   The text field is left 10 to the plate's left edge (156 - 5.5 - 44 = 106.5),
   i.e. 96px, which is 62% of the card against their 68%: both lines are nowrap
   at those sizes and neither reaches it.
   =========================================================================== */
/* RE-MEASURED THIS PASS against r01's gold card, whose two lines are both
   left-aligned at x=927 on a card starting at 886, i.e. a 41-native (12px)
   inset. Line 1 "Mission Complete" runs y 432..448 (cap 17 native -> 9px type)
   and line 2 "Get Reward!" runs y 478..504 (cap 27 -> 13px type). THE 1.6:1
   SIZE RATIO BETWEEN THE TWO LINES IS THE WHOLE REASON THE CARD READS AS A
   BUTTON, and 9:13 is 1.44 - as close as this type ladder gets without the
   caption dropping under the 8px floor. */
/* THE FIELD IS 73px AND BOTH LINES HAVE TO LIVE IN IT. The reference's own field
   is 256 native = 75px at our scale, so the geometry ports one-for-one; what does
   not port is the FACE - GameFont sets wider than the reference's, and at 13px
   with the HUD's global +0.3px tracking "Get Reward!" measured 84px and ran under
   the check plate (captured). Both lines drop the global tracking and line 2
   drops to 12px, which is still the 1.4:1 step that makes the card read as a
   button. */
#qDone { display: none; position: absolute; left: 10px; top: 10px; right: 45px; }
#qDone1 { display: block; font-size: 8.5px; letter-spacing: 0; color: var(--rwInk); line-height: 1.1; white-space: nowrap; }
#qDone2 { display: block; font-size: 12px; letter-spacing: 0; color: #fff; text-shadow: var(--ol2); line-height: 1.15; margin-top: 2px; white-space: nowrap; }

/* DECLARED AFTER .bossMode ON PURPOSE. Same specificity, so source order is the
   only thing that stops a boss fight collapsing a live reward to a 34px sliver
   the player then has to hit with a thumb. A claimable reward outranks the boss
   headline: the boss is still on screen with his own name bar. */
#qCard.claim {
  /* NO HEIGHT OVERRIDE. The 171-vs-163 reading this used to cite was taken off
     r01, which is a 300x652 downscale of a 1320x2868 capture - 4.4 native px per
     pixel, so an 8-native difference is inside its own quantisation. Re-measured
     on the full-resolution frames, IMG_5249's gold card box is 382..552 and the
     in-progress card below it is 571..741: both 171 native, exactly. The card
     BECOMES a chip in the same box, which is what every other declaration in
     this block already says; growing 2px on completion was ours, and it made the
     two cards under it jump. */
  background: linear-gradient(180deg, var(--rw1) 0%, var(--rw2) 58%, var(--rw3) 78%, var(--rw4) 100%);
  border-color: var(--rwRim);
  /* 2px near-black outer line under the rim, then the normal soft cast. It is
     off-screen on the right, which is correct - that edge is a straight cut at
     the viewport. */
  box-shadow: 0 0 0 2px var(--wo), var(--glassSh);
  /* IT ANIMATES INTO GOLD, IT DOES NOT SNAP. qcFlip washes the card white and
     squashes it flat for one frame, then springs it out to full gold - the read
     is "this just turned into a reward", which a class swap alone never gives.
     qcBreathe starts as the flip lands and is what keeps saying "press me"; it
     is listed second so it wins the transform once its delay elapses. */
  animation: qcFlip .42s cubic-bezier(.2, 1.35, .4, 1) both,
             qcBreathe 1.7s ease-in-out .42s infinite;
}
/* THE CREAM INNER TOP HIGHLIGHT. A pseudo-element and not an inset shadow
   because the measured highlight is a two-stop RAMP (#F5EBC2 -> #F6DE76) and an
   inset box-shadow can only be flat. Absolutely positioned children sit inside
   the border, so top:0 is exactly "inner". The left radius is inherited from the
   card so the lip follows the corner; the right end runs off screen with it. */
#qCard.claim::before {
  content: ''; position: absolute; left: 0; right: 0; top: 0; height: 4px;
  border-radius: 9px 0 0 0;   /* the card's own left radius, re-measured to 30 native */
  background: linear-gradient(180deg, var(--rwHi) 0%, var(--rwHi2) 100%);
  pointer-events: none; z-index: 2;
  /* THE LIP IS SQUASHED, NEVER RESIZED. Every "eaten lip" below is scaleY on
     this origin (4px * .5 = the measured 2px pressed lip) rather than a height
     change, because height animates on the layout thread and this pseudo-element
     is on screen during a 3D fight. transform-origin has to be the TOP edge or
     the band slides off the card's rim instead of shortening into it. */
  transform-origin: 50% 0;
}
/* THE REACH EXTENSION. The card is 128x50 - past any minimum target - but it
   lives at the right screen edge of a 390x844 portrait screen, which is the far
   end of a right thumb's arc. This grows the hit area 18px LEFT and 8px DOWN
   (the two directions a thumb actually arrives from) without moving a single
   pixel of the artwork. Bounds checked against its neighbours: the left edge
   reaches x=244 and the top bar ends 66px above it; the bottom reaches y=172,
   and the first preview card only exists while the list is open, in which case
   it is a readout with no tap target of its own. It exists only while .claim
   does, so the in-progress card never steals a joystick drag. */
#qCard.claim::after { content: ''; position: absolute; left: -18px; right: 0; top: -6px; bottom: -8px; }
#qCard.claim #qText, #qCard.claim .qTrack, #qCard.claim #qCount,
#qCard.claim #qReward, #qCard.claim #qTimer, #qCard.claim #qPortrait { display: none !important; }
#qCard.claim #qDone { display: block; }
/* the plate flips to the reference's silver-framed light plate */
#qCard.claim #qPlate {
  display: block; background: #F6F1E2; border-color: #9AA0A8;
  box-shadow: inset 0 0 0 1.5px #fff, 0 2px 0 rgba(120, 84, 0, .3);
}
/* inset -3px: the check OVERFLOWS its plate on all four sides, exactly as
   measured, because nothing on this HUD is clipped to its box */
#qCard.claim #qCheck { display: block; inset: -3px; }
@keyframes qcFlip {
  0%   { transform: scaleY(.84) translateX(7px); filter: brightness(2.1) saturate(.35); }
  40%  { transform: scaleY(1.07) translateX(0); filter: brightness(1.22) saturate(1); }
  72%  { transform: scaleY(.98); }
  100% { transform: none; filter: none; }
}
@keyframes qcBreathe {
  0%, 100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(255, 214, 74, 0)); }
  50%      { transform: scale(1.022); filter: drop-shadow(0 0 9px rgba(255, 214, 74, .6)); }
}
/* PRESSED. The button family's own rule, applied to a card: sink and eat the
   lip (4px -> 2px). animation:none is what lets a static pressed pose beat the
   breathing loop for as long as the finger is down.

   THIS RULE COVERS THE HELD FINGER, NOT THE NORMAL TAP, and that distinction is
   measured rather than assumed. ui.js commits the claim on POINTERDOWN (a reward
   must not wait for a release it might never get - a finger that slides off is a
   lost reward), and the quest-complete handler it triggers runs SYNCHRONOUSLY
   inside that same event, re-skinning the card to .paid before the browser has
   painted a single frame. Probed: 90ms after pointerdown the card already read
   `claim paid`, never `claim down`. So on the happy path this rule is worth zero
   frames, and the pressed pose is instead the FIRST BEAT OF qcPaid below. What
   still lands here is the case where claimQuest refuses to pay (a stale save's
   claim id, a counter that no longer covers the target): no quest-complete
   fires, nothing re-skins the card, and the player is left holding a button that
   had better look held. */
#qCard.claim.down { animation: none; transform: translateY(3px) scale(.985); filter: brightness(.93); }
#qCard.claim.down::before { transform: scaleY(.5); }

/* THE CONFIRMATION. One white flash across the whole card, the payout line
   kicked up from under the caption, and the check punched into the plate. This
   is the beat the two-step completion exists to create, so it is deliberately
   the loudest thing this card ever does - and it still finishes in 0.6s, which
   is under the banner's own 1.9s so the two never argue. */
#qCard.claim.paid { animation: qcPaid .6s cubic-bezier(.2, 1.2, .4, 1) both; }
#qCard.claim.paid::before { animation: qcLip .6s cubic-bezier(.2, 1.2, .4, 1) both; }
#qCard.claim.paid #qDone2 { animation: qcPay .6s cubic-bezier(.2, 1.5, .4, 1) both; }
#qCard.claim.paid #qCheck { animation: qcCheck .6s cubic-bezier(.2, 1.6, .4, 1) both; }
/* THE PRESS IS THE FIRST 48ms OF THE PAYOUT. The old first keyframe was the
   white flash itself, so the card went from resting to blown-out with nothing in
   between and the tap read as a light switch rather than a button: there was a
   .down pose authored above and (see its comment) it was worth zero painted
   frames, because the commit and the re-skin happen inside one pointerdown.
   Starting the payout from the SUNK pose gives the press back without costing a
   millisecond of response time - the card is already sinking on the frame the
   finger lands, and 8% of 600ms = 48ms (~3 frames at 60fps) is enough to read as
   a punch-through and short enough that nobody waits through it. The pose itself
   is copied from .down above so the two can never describe different presses. */
@keyframes qcPaid {
  0%   { transform: translateY(3px) scale(.985); filter: brightness(.93); }
  8%   { transform: scale(1.09); filter: brightness(2.4) contrast(.75); }
  28%  { transform: scale(1.03); filter: brightness(1.35); }
  100% { transform: scale(1); filter: none; }
}
/* the lip is eaten on the way down and springs back with the flash, exactly as
   it does under a held finger (4px -> 2px -> 4px) */
@keyframes qcLip {
  0%   { transform: scaleY(.5); }
  8%   { transform: scaleY(1); }
  100% { transform: scaleY(1); }
}
@keyframes qcPay {
  0%   { transform: translateY(9px) scale(.8); opacity: 0; }
  55%  { transform: translateY(0) scale(1.12); opacity: 1; }
  100% { transform: none; opacity: 1; }
}
@keyframes qcCheck {
  0%   { transform: scale(0) rotate(-26deg); }
  52%  { transform: scale(1.3) rotate(6deg); }
  100% { transform: scale(1) rotate(0); }
}

/* ============================================================================
   JOYSTICK - not in the reference, so it is built out of the reference's own
   parts: the ring is a glass plate bent into a circle, the knob is .btn.
   ============================================================================ */
#joyZone {
  position: absolute; left: 0; width: 100%;
  top: calc(60px + var(--sat)); bottom: calc(88px + var(--sab));
  z-index: 1; touch-action: none;
}
#joyBase {
  position: absolute; width: 112px; height: 112px; margin: -56px 0 0 -56px; border-radius: 50%;
  box-sizing: border-box; border: var(--rimW) solid var(--glassRim);
  background: var(--glass); box-shadow: var(--glassSh);
  opacity: 0; transition: opacity .16s; z-index: 2;
}
#joyBase.on { opacity: 1; }
#joyKnob {
  position: absolute; left: 50%; top: 50%; width: 50px; height: 50px; margin: -25px 0 0 -25px;
  border-radius: 50%; box-sizing: border-box;
  border: var(--rimW) solid #0E0D16; background: #5B5470;
  box-shadow: inset 0 2px 0 #7C749A, inset 0 -6px 0 #2E2A3E, 0 3px 7px rgba(0, 0, 0, .3);
}

/* ============================================================================
   THE DOCK - measured on r01.

   FIVE SLOTS, centres at fx 0.1587 / 0.3205 / 0.5000 / 0.6795 / 0.8413.
   Vertical anchor: the reference leaves 153px under the dock on a device with a
   102px home-indicator inset, i.e. the dock clears the inset by ~51px, which
   ports to bottom = env(safe-area-inset-bottom) + 0.017*H (14px at 844).

   THE SLOTS ARE ABSOLUTELY POSITIONED, NOT FLEXED, because the centre card is a
   different and wider component and one flex gap cannot say that.
   THE "17px outside, 12px either side of the centre" NOTE THAT USED TO SIT HERE
   WAS DESCRIBING OUR OWN OUTPUT, NOT THE REFERENCE. Re-measured on IMG_5245 /
   5248 / 5249, where the four button outlines land on pixel-identical columns in
   all three frames (121-124 / 292-295, 335-339 / 506-509, 810-813 / 980-984,
   1024-1027 / 1195-1198), the reference gaps are 40 native (11.8px) between the
   pair and 33-35 native (9.8-10.3px) either side of the card. The SLOT CENTRES
   were always right, so the whole error was the buttons being too small and the
   deficit turning into gap.

   STANDARD SLOT (1, 2, 4, 5): 175 x 182 native = 0.1326*W x 0.0635*H = 51.7 x
   53.6, slightly TALLER than wide, radius 49.5 native = 0.0375*W = 14 (a
   9-point circular fit on IMG_5249, SSE 5.7). Layers top to bottom: a near-black
   outline #37333c, a 13-native top rim highlight #716c86 (0.0714 of the button
   height = 3.8px, measured at abs x=170, rows 2545..2557 - it was a flat 2px
   inset and read as half a bevel), a flat #504e60 body, and a 25-native bottom
   shade (0.137 of the height = 7px, correct) at #292731 rather than #34333f:
   the reference lip is 1.79:1 against the face and ours was 1.56:1, which is
   the difference between a key with a side and a key with a smudge.
   THE GLYPH IS SIZED BY ITS INK, NOT BY ITS CANVAS. The reference sticker is
   91 x 104 native = 26.9 x 30.6, i.e. 0.52 x 0.57 of the button. Our rail art
   does not fill its own frame - measured alpha bbox 0.762/0.684/0.645/0.840
   across upgrades/techs/forms/gear - so a 28px box drew a 21-23px glyph, 0.46 of
   the button. A 34px box draws 26-29px, 0.53, which is the reference read.

   NO CAPTIONS. The reference's dock is icons only, and at our scale the button
   is 52x54 with a 34px icon in it - there is no caption band in that box, and
   inventing one is what made the old rail read as a toolbar rather than as a
   set of physical keys. The five glyphs are the generated rail icons and every
   one is a genre-standard read.
   ============================================================================ */
/* #dock IS ALSO THE PUBLISHED FLOOR OF THE PLAY AREA, and that is the second
   half of the npc.js hand-off (#bandFrame is the first). npc.js reads
   `#dock`'s rect TOP to decide how far down a world speech bubble may go. Left
   as tall as its own buttons that answered 0.924*H, so a bubble was free to be
   drawn through the cargo bar, the prompt slot and the hp bar - all of which
   are BELOW the play area but are not inside #dock. Stretching the box up to
   the world band's floor makes the answer true: everything from here down is
   reserved, whoever draws it.
   The box carries no ink and `pointer-events: none`, so 294px of transparent
   div over the play field costs nothing and takes no taps; the flex column with
   justify-content:flex-end is what keeps #bottomBar pinned to the bottom of it
   now that the box is taller than its content. */
#dock {
  position: absolute; left: 0; right: 0; top: var(--bWorld1); bottom: 0;
  z-index: 3; pointer-events: none;
  display: flex; flex-direction: column; justify-content: flex-end;
  padding-bottom: calc(var(--sab) + 14px);
}
#bottomBar { position: relative; width: 100%; height: 50px; flex: none; }
.wbtn {
  position: absolute; bottom: 0; width: 13.26%; height: 53px;
  border-width: 1.8px; border-radius: 14px; --bLipH: 7px; --bHiH: 3.8px;
  --bFace: #504E60; --bHi: #716C86; --bLip: #292731; --bStroke: #37333C; --bInk: #37333C;
}
/* centre minus half the width, both as fractions of the same box */
#btnUpgrades { left: calc(15.87% - 6.63%); }
#btnTechs    { left: calc(32.05% - 6.63%); }
#btnForms    { left: calc(67.95% - 6.63%); }
#btnKit      { left: calc(84.13% - 6.63%); }
/* 3px, not 4: the buttons grew and the gap between a pair is 11.4px, so a 4px
   bleed each side left 3.4px of screen that belonged to neither of them */
.wbtn::after { content: ''; position: absolute; left: -3px; right: -3px; top: -7px; bottom: -7px; }  /* 58x67 hit */
/* the icon is centred in the FACE, not in the box: a 7px bottom lip puts the
   face's own centre 3.5px above the box's, so the glyph is nudged up by twice
   that on a flex-centred child */
.wIco { width: 34px; height: 34px; margin-bottom: 6px; flex: none; filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .38)); }
.wIco svg, .wIco .pic { width: 100%; height: 100%; }
/* LOCKED, in-world language: the container is UNCHANGED - same fill, same lip,
   100% opacity - and ONLY THE GLYPH is swapped, for a padlock drawn in the
   button's own shadow colour. An engraved ghost, never a dimming. */
.wbtn.locked .wIco { color: #26232B; filter: none; }

/* ============================================================================
   SLOT 3 IS A DIFFERENT COMPONENT: THE FORGE CARD. OURS IS TRANSFORM.

   Measured: 0.1780*W x 0.0673*H (69 x 57), i.e. 23px wider and 7px taller than
   its neighbours, extending ~3.5px above AND below them on the same centre line.
   5px near-black outline (1.5px ported), radius ~34 native = 10.
     upper plate  #a6695c -> #9b6053 with a 9px lighter rim #bc7e70 inside the
                  outline (2.7px ported)
     lower panel  #63352b, joined by a concave scallop
     icon         BREAKS OUT ABOVE THE CARD by 84 native = 25px. It is the
                  tallest thing in the dock and it is what makes the slot read as
                  primary - which is the entire reason this component exists.
     x1 chip      a dark #2c2a2f pill straddling the plate/panel join
     cost line    an icon plus a number, centred
     progress bar a SOFT RECTANGLE (radius ~8 native = 2.4px, NOT a full pill -
                  the only bar in the game that is not), maroon #4d0303 track
                  with a #511510 border and a light fill from the left, with the
                  count centred on it and overflowing it vertically.

   WHAT WE PUT IN IT. Transformation is our primary verb, so it takes the primary
   slot, and its three readouts map one-for-one onto the card's three fields:
     chip       the ATK multiplier of the form this press would produce
     cost line  the aura the press needs (BAL.auraMinActivate)
     bar        the aura itself - the same 0..1 the old conic ring drew, laid
                flat - or the cooldown while one is running
   Every state class the disc had (locked / idle / ready / cd / active) still
   lands here, and the button still emits transform-pressed on pointerdown.
   ============================================================================ */
#tfBtn {
  position: absolute; left: 41.06%; width: 17.80%; top: -4px; height: 57px;
  box-sizing: border-box; border-radius: 10px;
  border: 1.5px solid #3B1E14;
  background: linear-gradient(180deg, #A6695C 0%, #9B6053 100%);
  box-shadow: inset 0 2.7px 0 #BC7E70, 0 3px 8px rgba(0, 0, 0, .34);
  transition: transform .08s, filter .2s;
}
/* the lower panel, joined by the scallop the reference cuts between the two */
#tfBtn::before {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 33px;
  background: #63352B; border-radius: 9px 9px 8.5px 8.5px;
  box-shadow: inset 0 2px 3px rgba(0, 0, 0, .35);
}
/* THE BREAKOUT. 25px above the card's own top edge, and nothing clips it. */
#tfIco {
  position: absolute; left: 50%; top: -25px; width: 46px; height: 46px; margin-left: -23px;
  z-index: 2; filter: drop-shadow(0 3px 3px rgba(0, 0, 0, .45));
}
#tfIco svg, #tfIco .pic { width: 100%; height: 100%; }
#tfChip {
  position: absolute; left: 8px; top: 14px; width: calc(100% - 16px); height: 19px; z-index: 3;
  border-radius: var(--rPill); background: #2C2A2F; box-sizing: border-box;
  display: flex; align-items: center; justify-content: center;
  font-size: var(--tCard); letter-spacing: .3px; color: #fff; text-shadow: var(--sh1);
}
#tfCost {
  position: absolute; left: 0; right: 0; top: 35px; height: 12px; z-index: 3;
  display: flex; align-items: center; justify-content: center; gap: 3px;
}
#tfCost > i { width: 12px; height: 12px; display: block; }
#tfCostV { font-size: var(--tMicro); color: #fff; text-shadow: var(--ol1); }
#tfBar {
  position: absolute; left: 5px; right: 5px; top: 48px; height: 6px; z-index: 3;
  border-radius: 3px; box-sizing: border-box;
  background: #4D0303; border: 1px solid #511510;
}
#tfFill { position: absolute; left: 0; top: 0; bottom: 0; width: 0%; border-radius: 2px; background: var(--gold); transition: width .2s; }
/* the count overflows the 6px bar, exactly as it does on a mission card */
#tfBarTxt {
  position: absolute; left: 0; right: 0; top: -5px; height: 16px; z-index: 3;
  display: flex; align-items: center; justify-content: center;
  font-size: var(--tMicro); line-height: 1; text-shadow: var(--ol1); pointer-events: none;
}
/* the spark is allowed OUTSIDE the card - nothing on this HUD is clipped */
#tfSpark { position: absolute; top: -30px; right: -4px; width: 17px; height: 17px; display: none; z-index: 4; }
#tfSpark svg { width: 100%; height: 100%; }
#tfName {
  position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); margin-bottom: 26px;
  font-size: var(--tMicro); letter-spacing: .8px; color: #fff;
  text-shadow: var(--ol1); white-space: nowrap;
}
#tfBtn.ready { animation: tfGlow 1s ease-in-out infinite; }
@keyframes tfGlow {
  0%, 100% { filter: drop-shadow(0 0 6px rgba(248, 198, 118, .55)); }
  50% { filter: drop-shadow(0 0 15px rgba(248, 198, 118, .95)); }
}
#tfBtn.ready #tfSpark { display: block; animation: tfSpin 2.4s linear infinite, tfTwinkle 2.4s ease-in-out infinite; }
@keyframes tfSpin { to { transform: rotate(360deg); } }
@keyframes tfTwinkle { 0%, 55%, 100% { opacity: 0; } 12%, 38% { opacity: 1; } }
#tfBtn.active { filter: drop-shadow(0 0 12px rgba(255, 255, 255, .55)); }
/* STATE BY HUE, NEVER BY OPACITY. Cooling down and locked keep the whole card
   and 100% opacity; only the wood loses its warmth, and locked additionally
   swaps the glyph for an engraved lock in the card's own shadow colour. */
#tfBtn.cd { background: linear-gradient(180deg, #8B8496 0%, #7C7594 100%); border-color: #3A3648; box-shadow: inset 0 2.7px 0 #A8A2BA, 0 3px 8px rgba(0, 0, 0, .34); }
#tfBtn.cd::before { background: #4A4560; }
#tfBtn.locked { background: linear-gradient(180deg, #6A5B54 0%, #5F534C 100%); }
#tfBtn.locked #tfIco { color: #3B2A22; filter: none; }
#tfBtn.down { transform: translateY(4px); }
#tfBtn.down::before { box-shadow: inset 0 3px 4px rgba(0, 0, 0, .45); }
#tfBtn.unlockPulse { animation: tfUnlock 1.5s cubic-bezier(.2, 1.2, .4, 1); }
@keyframes tfUnlock {
  0% { transform: scale(1); }
  18% { transform: scale(1.18); filter: drop-shadow(0 0 26px rgba(248, 198, 118, 1)); }
  42% { transform: scale(.97); }
  64% { transform: scale(1.08); filter: drop-shadow(0 0 16px rgba(248, 198, 118, .85)); }
  100% { transform: scale(1); }
}

/* ---- the hero's own readouts. The reference has no hero HP bar at all (it is
   a different kind of game), so this is ours: parked directly above the dock,
   centred, clear of the forge card's 25px breakout by 7px. ---- */
/* ANCHORED TO THE VITALS BAND'S FLOOR, NOT TO A PIXEL COUNT. This was
   `bottom: calc(var(--sab) + 108px)`, which is 12.8% of an 844px screen and was
   exactly right there - and 11.6% of a 932px one, which put the bar at
   0.865..0.884 with the band ending at 0.872 (measured, --w 430 --h 932). A
   fixed px offset from the bottom cannot hold a fractional band on a taller
   phone. `100% - var(--bVitals1)` is the same 108px at the design size and the
   same FRACTION everywhere else. The safe-area term stays: the whole bottom
   stack rides up on a device with a home indicator, and the bands ride with it.
   THE ELEMENT IS A SIBLING OF #dock, NOT A CHILD - see the note in js/ui.js
   buildDOM. A percentage in `bottom` resolves against the containing block, and
   #dock is now a 0.35-of-viewport publishing box, so from inside it this
   fraction measured the dock instead of the screen. #lay is full height. */
#dockStats {
  position: absolute; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--sab) + (100% - var(--bVitals1)));
  width: 56%; max-width: 220px; z-index: 3;
}
.statBar { position: relative; box-sizing: border-box; }
.statIco {
  position: absolute; left: 4px; top: 50%; margin-top: -6px; width: 12px; height: 12px; z-index: 2;
  filter: drop-shadow(0 1px 0 rgba(0, 0, 0, .5));
}
.statIco svg, .statIco .pic { width: 100%; height: 100%; }
.statTxt {
  position: absolute; left: 18px; right: 6px; top: 0; bottom: 0; z-index: 2;
  display: flex; align-items: center; justify-content: center;
  font-size: var(--tCard); letter-spacing: .3px; text-shadow: var(--ol1);
}
#hpTrack { height: var(--barHp); border-radius: var(--rPill); background: var(--hpTrack); }
#hpFill { width: 100%; background: var(--hpGreen); border-radius: var(--rPill); transition: width .22s linear; }
#hpFill.low { background: #E04F3F; }
#shFill {
  position: absolute; left: 2px; top: 2px; height: 4px; width: 0%; border-radius: 2px; z-index: 1;
  background: #fff;
}
#hpTrack.hit { animation: hpHit .18s; }
@keyframes hpHit { 50% { filter: brightness(1.7); } }

/* ============================================================================
   THE CARGO BAR - measured on IMG_5249 (resting) / 5245 (empty) / 5243 (MAX) /
   r01 (mid-pop).

   POSITION: horizontally CENTRED (measured centre x = 659.5 of 660 in every
   frame) and floating at y 0.6513..0.6733 - two thirds down, well ABOVE the
   dock and NOT attached to it. That float is the point: cargo is the spine of
   the early game and the reference gives it the middle of the screen, whereas
   ours used to be an 82px sliver sharing the dock with the hp bar, where it was
   the smallest bar on the HUD.

   TRACK  0.3098*W x 0.0220*H (121 x 19), FIXED WIDTH - it does not resize with
          its label - full-pill radius, a translucent dark slate over the world
          (measured #587945 over grass and #555363 over rock, which solves to
          about rgba(27,42,51,0.50)).
   FILL   inset ~1.5px on all sides, flat bright green #63dd35 with a marginally
          lighter top edge. No gradient, no stripes.
   LABEL  centred over the WHOLE track, "CARGO (991/1.72K)", cap 28 native =
          0.0098*H -> 11px type.
   MAX    the label collapses to "MAX", the fill goes solid orange edge to edge,
          and a warm olive stroke appears AROUND the track. That border exists in
          NO other state, which is what makes the change register without a
          flash. It is a box-shadow, not a border, so the box does not resize.
   POP    r01 caught the bar mid-animation at 1.28x its resting size about its
          own centre, so a gain scale-pops and settles.
   ============================================================================ */
/* THE CARGO BAND'S TOP EDGE *IS* THIS MEASUREMENT. 0.6513*H is where the
   reference parks its cargo track, and rather than leave that number here and
   round the band around it, the band map takes it as its own edge - so this
   anchor is `var(--bCargo0)` and the two can never drift apart. */
#cargoWrap {
  position: absolute; left: 50%; transform: translateX(-50%); top: var(--bCargo0);
  width: 30.98%; z-index: 3;
}
/* A PLATE, NOT A LOZENGE, and it is the single most obvious difference between
   the two HUDs at matched scale. --rPill on a 19px box resolves to a 9.5px
   radius, i.e. hemispherical ends. The reference's track is a soft-cornered
   RECTANGLE: on IMG_5243 (MAX, crisp stroke) and IMG_5249 (resting, no stroke)
   the top row is inset 11-12 native and full width is reached 15 rows down on a
   64-65 native track, so r = 14 native = 0.22 of the track height = 4.2px here.
   Same radius on the fill, minus its 1.5px inset, so the two curves are
   concentric instead of the fill's cap poking out of the track's. */
#cargoPill {
  position: relative; width: 100%; height: 19px; box-sizing: border-box;
  border-radius: 4.2px; overflow: hidden;
  background: rgba(27, 42, 51, .50); box-shadow: var(--glassSh);
}
#cargoFillBar {
  position: absolute; left: 1.5px; top: 1.5px; bottom: 1.5px; width: 0%;
  border-radius: 2.7px; background: #63DD35;
  box-shadow: inset 0 1.5px 0 rgba(255, 255, 255, .24);
}
#cargoText {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-size: var(--tCard); letter-spacing: .3px; text-shadow: var(--ol1);
}
/* OVERFLOW STAYS HIDDEN IN THE MAX STATE. The olive ring is a box-shadow on this
   element, so it was never the thing `overflow: visible` was protecting - all it
   did was let the fill (left: 1.5px, width: 100%) run 1.5px past the pill and
   paint over the ring's right side. Sampled on the MAX capture, device rows
   1108-1116 went #FF8A00 straight into the world with no #8E7349 between them,
   while rows 1120-1136 had the ring intact. The reference's ring is unbroken at
   every row (IMG_5243). Clipping puts the fill back inside its own track. */
#cargoPill.full { box-shadow: 0 0 0 1.6px #8E7349, var(--glassSh); }
#cargoPill.full #cargoFillBar { background: #FF8A00; }
#cargoPill.pop { animation: cargoPop .34s cubic-bezier(.2, 1.3, .4, 1); }
@keyframes cargoPop { 0% { transform: scale(1); } 34% { transform: scale(1.28); } 100% { transform: scale(1); } }
/* CHIP ROW, 12 native px (3.5) under the track on the same centre axis: what is
   in the hold RIGHT NOW, which is a different number from the wallet at the top
   of the screen (that one is what has been banked). */
#cargoChips { display: flex; align-items: center; justify-content: center; gap: 13px; margin-top: 4px; }
.cchip { display: inline-flex; align-items: center; gap: 3px; }
.cchip > i { display: block; width: 14px; height: 14px; flex: none; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .5)); }
.cchip > b { font-weight: 400; font-size: var(--tMicro); letter-spacing: .2px; text-shadow: var(--ol1); }
/* THE PROMPT SLOT, TENANT 1 OF 2. --bPromptTop (0.715*H) clears the chip row,
   which ends at 0.696*H, by 15px. #hintBub draws at the SAME anchor, and that
   is the enforcement rather than an accident: two prompts drawn at one y cannot
   stack, they can only take turns, and js/ui.js promptSlot() decides whose turn
   it is (the cargo warning outranks the FTUE hint). Captured before this pass,
   the two sat 25px apart with the objective arrow underneath them - three
   voices in one strip, and nothing that could ever have noticed. */
/* ONE SLOT, ONE COMPONENT. Sharing the anchor was only half the job: the two
   tenants of the prompt band were different objects - #hintBub wore the dense
   glass pill and this one was bare gold type on a 1px outline, so the same strip
   of screen changed shape depending on which voice held it.
   It was also the least legible thing on the HUD. Measured on the MAX capture,
   the world behind "Return to camp!" averages #8A824B against #FFD54A = 2.77:1,
   and its brightest local mode #85BE90 = 1.52:1; the 1px outline was carrying
   all of it. The reference has no centred prompt in this band at all, so there
   is nothing to copy here - this is our own slot and it now uses our own panel
   token. The GOLD STAYS: it is what separates a warning from a teaching hint. */
#cargoHint {
  position: absolute; left: 50%; transform: translateX(-50%); top: var(--bPromptTop);
  display: none; color: #FFD54A; z-index: 4;
  animation: hintBounce .7s ease-in-out infinite;
}
#cargoHint.show { display: block; }
@keyframes hintBounce { 0%, 100% { transform: translate(-50%, 0); } 50% { transform: translate(-50%, -4px); } }


/* ============================================================================
   FAMILY B - MENU SOLID. The sheet.
   Opaque, flat, zero gradient, ZERO border, soft shadow only. Four flat values
   with hard edges: hero band -> section band -> 4px divider -> list floor.
   Depth inside is a DARKER SHADE OF THE PARENT, never a stroke.
   PER-SCREEN ACCENT HUE: the construction is byte-identical across the three
   tabs and only the hue moves, which maps onto our ki tiers.
   ============================================================================ */
#scrim { position: absolute; inset: 0; background: rgba(12, 8, 22, .58); opacity: 0; transition: opacity .25s; z-index: 7; }
#scrim.open { opacity: 1; pointer-events: auto; }
#sheet {
  /* default hue = TECHS azure; the tab classes below move it */
  --hero: #4A6FB0; --band: #223F72; --div: #10203F; --floor: #1C3763;
  --nest: #16294A; --slot: #2F5289; --slotRim: #0E1F3C;
  --statPill: #D8E4F8; --statInk: #1B3763; --tint: #A9C4EC;
  /* bottom 92 = 16 (dock pad) + 64 (rail height) + 12 clear of the rail badges,
     which overhang their buttons' top corners by 11px. The sheet must not clip
     an attention badge it is the answer to. */
  position: absolute; left: 50%; bottom: calc(92px + var(--sab));
  width: min(94vw, 372px); max-height: 60%;
  transform: translate(-50%, calc(100% + 120px)); visibility: hidden;
  transition: transform .3s cubic-bezier(.25, 1.1, .35, 1), visibility 0s .3s;
  background: var(--floor); border: none; border-radius: var(--rSheet);
  box-shadow: 0 10px 28px rgba(0, 0, 0, .5);
  display: flex; flex-direction: column; z-index: 8;
}
#sheet.open {
  transform: translate(-50%, 0); pointer-events: auto; visibility: visible;
  transition: transform .3s cubic-bezier(.25, 1.1, .35, 1), visibility 0s 0s;
}
#sheet.hUp { --hero: #3E7F5C; --band: #1E4A36; --div: #0C2419; --floor: #1A4230;
             --nest: #123326; --slot: #2C6046; --slotRim: #0C2419;
             --statPill: #D8F0E2; --statInk: #16412D; --tint: #9EDCBB; }
#sheet.hFm { --hero: #6F5AB8; --band: #362A78; --div: #1B1450; --floor: #2F2570;
             --nest: #241C58; --slot: #473A93; --slotRim: #1B1450;
             --statPill: #DDDAF8; --statInk: #2E2568; --tint: #BDB2F0; }
/* GEAR = BROWN, the reference's own equipment hue (its merge screen is
   periwinkle, its equipment screen is brown, its shop is purple). It is also
   the last warm neutral none of the other three tabs occupies, so the four tabs
   now read green / azure / violet / brown with no two of them arguing. */
#sheet.hGr { --hero: #8A6238; --band: #4A331C; --div: #241708; --floor: #3E2B16;
             --nest: #2E1F0F; --slot: #6A4A28; --slotRim: #241708;
             --statPill: #F2E2CC; --statInk: #432C12; --tint: #E0BE93; }

.sheetHead {
  flex: none; position: relative; background: var(--hero);
  border-radius: var(--rSheet) var(--rSheet) 0 0; padding: 0 44px 0 8px;
}
#sheetTabs { display: flex; gap: 5px; align-items: flex-end; height: 38px; padding-top: 6px; }
.sheetTab {
  position: relative; flex: 1; height: 26px; border-radius: 9px 9px 0 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--band);                        /* nesting by shade, not by border */
  font-size: var(--tMicro); letter-spacing: .7px; color: rgba(255, 255, 255, .66);
  transition: height .12s;
}
.sheetTab::after { content: ''; position: absolute; inset: -12px -2px -6px -2px; }  /* 44px hit area */
.sheetTab.on { height: 32px; background: var(--floor); color: #fff; font-size: var(--tCard); text-shadow: var(--shSolid); }
.sheetTab.down { transform: translateY(1px); }
/* the close control is parked OUTSIDE the sheet body, over the live HUD, exactly
   as measured. Rounded square, not a circle: the two circles on the game screen
   are the transform disc and the gear, and nothing else. */
#sheetX {
  position: absolute; top: -14px; right: -8px; width: 38px; height: 34px;
  border-radius: var(--rCta); --bLipH: 4px;
  --bFace: #F6524C; --bHi: #FF8A83; --bLip: #C6332E; --bStroke: #7C1B17; --bInk: #7C1B17;
  z-index: 2;
}
#sheetX::before { content: ''; position: absolute; inset: -6px; }   /* 50px hit area */
#sheetX svg { width: 16px; height: 16px; margin-bottom: 2px; }

/* SECTION HEADER BAND + the 4px divider under it. The objective always sits
   above the list so a purchase is made against a named target. */
#sheetQuest {
  flex: none; display: flex; align-items: center; gap: 7px; height: 34px; padding: 0 10px;
  background: var(--band); border-bottom: 4px solid var(--div);
}
.sqTag {
  flex: none; font-size: var(--tMicro); letter-spacing: .8px; color: var(--tint);
}
.sqTxt {
  flex: 1; min-width: 0; font-size: var(--tCard); color: #fff; text-shadow: var(--shSolid);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sqNum { flex: none; font-size: var(--tCard); color: var(--tint); }

#sheetBody {
  overflow-y: auto; padding: 8px; -webkit-overflow-scrolling: touch;
  display: flex; flex-direction: column; gap: 6px; touch-action: pan-y;
  border-radius: 0 0 var(--rSheet) var(--rSheet);
}

/* ---- the ONE menu row. Nested panel (a darker shade of the list floor, no
   border), a LIGHTER slot for the icon with a 3px inset rim, a light stat pill
   carrying the numbers, and the commit CTA. Same four beats on every tab. ---- */
.row {
  flex: none; position: relative; display: flex; align-items: center; gap: 9px;
  background: var(--nest); border-radius: var(--rNest); padding: 7px 8px;
}
.slot {
  width: 44px; height: 44px; flex: none; border-radius: var(--rPlate); box-sizing: border-box;
  background: var(--slot); box-shadow: inset 0 0 0 3px var(--slotRim);
  display: flex; align-items: center; justify-content: center;
  color: var(--statPill);                     /* the LIGHT ghost the lock glyph inherits */
}
.slot svg { width: 26px; height: 26px; }
/* the generated stat / slot art sits in the same 26px well as the vector it
   replaced, so a missing PNG swaps back with no layout change at all */
.slot > .pic { width: 26px; height: 26px; }
.rowMid { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.rowTop { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
.rowName { font-size: var(--tCta); letter-spacing: .4px; color: #fff; text-shadow: var(--shSolid); flex: none; }
.rowLv { font-size: var(--tMicro); color: var(--tint); }
.rowLv.new { color: #97E249; }
.rowDesc {
  font-size: var(--tMicro); color: var(--tint); line-height: 1.1;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* stat pill: label left, value right, on the light detail tint */
.statPill {
  display: flex; align-items: center; gap: 5px; align-self: flex-start; max-width: 100%;
  background: var(--statPill); border-radius: var(--rPill); padding: 2px 9px;
  font-size: var(--tCard); color: var(--statInk); line-height: 1.15;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.statPill b { font-weight: 400; }
.statPill i { font-style: normal; opacity: .5; }
.statPill em { font-style: normal; color: #1C7A2E; }
.costBtn {
  flex: none; align-self: center; gap: 4px;
  height: 40px; min-width: 76px; border-radius: var(--rCta); padding: 0 10px; --bLipH: 5px;
  font-size: var(--tCta);
}
.costBtn::after { content: ''; position: absolute; left: -4px; right: -4px; top: -3px; bottom: -3px; }
.costBtn svg { width: 16px; height: 16px; }
.costBtn > .pic { width: 16px; height: 16px; flex: none; }
/* the label lives in a span so it inherits the .btn hue-locked outline */
.costBtn span { font-size: var(--tCta); }

/* form rows: the swatch is the slot's content, and EQUIPPED is a BAR, not a
   pill - a dark navy plate with a gold caption, exactly as measured. */
.swatch { width: 30px; height: 30px; border-radius: 50%; }
.formTag {
  flex: none; display: flex; align-items: center; gap: 4px;
  height: 22px; padding: 0 8px; border-radius: 4px;
  background: #141A33; color: #FFD54A; font-size: var(--tMicro); letter-spacing: .7px;
  text-shadow: var(--ol1);
}
.formTag svg { width: 12px; height: 12px; }
.formTag.dim { background: none; color: var(--tint); text-shadow: none; }
.formHint { flex: none; font-size: var(--tMicro); color: var(--tint); max-width: 104px; text-align: right; line-height: 1.15; }
.row.equipped { box-shadow: inset 0 0 0 3px var(--gold); }

/* ============================================================================
   GEAR SCREEN. ONE new component (the loadout strip) and four small ones; every
   other beat on the tab is the .row recipe above, unchanged. The rule this
   screen adds to the system is that RARITY IS A RIM, never a fill and never a
   glow: --rc is set per element in ui.js and replaces --slotRim on that piece's
   plate, so the whole list states rarity in the exact place the other tabs
   state "this is an icon slot". Nothing new is invented to carry it.
   ============================================================================ */
.eqHead {
  flex: none; display: flex; flex-direction: column; gap: 6px;
  background: var(--nest); border-radius: var(--rNest); padding: 7px 8px;
}
/* the worn set: five plates, equal width, in SLOTS order. This strip is also
   the list filter - see renderGear. */
.loadout { display: flex; gap: 6px; }
.eqSlot {
  position: relative; flex: 1 1 0; min-width: 0;
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding: 5px 0 4px; border-radius: var(--rPlate); box-sizing: border-box;
  background: var(--slot);
  box-shadow: inset 0 0 0 3px var(--slotRim);
  --rc: var(--slotRim);
}
.eqSlot::after { content: ''; position: absolute; inset: -4px -2px; }   /* reach */
.eqSlot.on { box-shadow: inset 0 0 0 3px var(--rc); }
/* selection is the SAME gold inset the equipped row uses - one language for
   "this is the one you are pointing at" across the whole sheet */
.eqSlot.sel { box-shadow: inset 0 0 0 3px var(--gold); }
.eqSlot .eqPlate { width: 26px; height: 26px; }
.eqSlot .eqPlate svg, .eqSlot .eqPlate .pic { width: 100%; height: 100%; }
/* EMPTY = the .btn.off language (desaturate, keep the build, keep the opacity),
   not the padlock language. An empty slot is not locked. */
.eqSlot:not(.on) .eqPlate svg, .eqSlot:not(.on) .eqPlate .pic { filter: grayscale(1) brightness(.72); }
.eqTag { font-size: var(--tMicro); letter-spacing: .5px; color: var(--tint); line-height: 1; }
.eqSlot.on .eqTag { color: #fff; }

/* THE SET TOTAL. What the whole loadout is currently paying, which is the
   number that has to agree with the hero's own stats. */
.eqSum { display: flex; flex-wrap: wrap; gap: 4px; }
.eqChip {
  display: flex; align-items: center; gap: 4px;
  background: var(--statPill); color: var(--statInk); border-radius: var(--rPill);
  padding: 2px 8px; font-size: var(--tMicro); line-height: 1.2; white-space: nowrap;
}
.eqChip b { font-weight: 400; color: #1C7A2E; }
.eqChip.none { background: none; color: var(--tint); padding-left: 2px; }

.eqNote { flex: none; font-size: var(--tMicro); color: var(--tint); line-height: 1.2; padding: 0 2px; }
.eqEmpty {
  flex: none; font-size: var(--tCard); color: var(--tint); line-height: 1.35;
  background: var(--nest); border-radius: var(--rNest); padding: 14px 12px; text-align: center;
}

/* inventory row extras */
.eqRow .slot { box-shadow: inset 0 0 0 3px var(--rc); }
.eqRow .rowLv.rar { color: var(--rc); letter-spacing: .6px; }
/* stack count, welded to the plate's bottom-right the way .badge welds to a
   button's top-right. Same overhang rule, opposite corner, so the two counters
   on this screen can never be mistaken for each other. */
.slot .qty {
  position: absolute; right: -5px; bottom: -4px;
  min-width: 19px; height: 17px; padding: 0 4px; box-sizing: border-box;
  border-radius: var(--rPill); background: var(--div);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--tMicro); color: #fff; text-shadow: var(--shSolid);
}
.eqRow .slot { position: relative; }
/* the CTA column. Two actions can be live at once (EQUIP and FUSE, or EQUIPPED
   and REMOVE), so the commit beat becomes a short stack instead of a single
   40px button. Same .btn, same hues, shorter. */
.rowBtns { flex: none; align-self: center; display: flex; flex-direction: column; align-items: stretch; gap: 4px; }
.rowBtns .formTag { justify-content: center; }
.eqBtn {
  height: 28px; min-width: 74px; padding: 0 8px; --bLipH: 4px;
  font-size: var(--tMicro); gap: 3px;
}
/* THE ONE PLACE THE STAT PILL IS ALLOWED TO WRAP. On the other three tabs it
   carries two short values ("14 > 18") and clipping is impossible; a gear roll
   carries two NAMED stats ("Crit Chance +4.8% . Crit Damage +19%") and the
   ellipsis was eating the second one, which is exactly the information the
   screen exists to state. Two lines beats a truncated promise.
   Block rather than flex, because a wrapped FLEX pill spreads its two children
   to the line box and leaves the separator stranded in the middle of a gap;
   inline text just flows. */
.eqRow .statPill { display: block; white-space: normal; }
.eqBtn svg { width: 13px; height: 13px; }
.eqBtn > span { font-size: var(--tMicro); letter-spacing: .5px; }

/* ============================================================================
   FEEDBACK - ONE slot, ONE queue. Dense glass so a shout stays legible over a
   busy fight, which is the same recipe the wallet uses.
   ============================================================================ */
#banner {
  position: absolute; left: 0; right: 0; top: calc(172px + var(--sat));
  display: flex; justify-content: center; z-index: 6; pointer-events: none;
}
.bnr {
  font-size: 17px; color: #fff; text-shadow: var(--ol2);
  letter-spacing: .6px; text-align: center; max-width: 68vw; box-sizing: border-box;
  padding: 7px 14px 8px; border-radius: var(--rGlass); line-height: 1.15;
  background: var(--glassDense); box-shadow: var(--glassSh);
}
.bnr.gold { color: #FFD54A; }
.bnr.lv { color: #FFF2C4; font-size: var(--tShout); letter-spacing: 1.1px; }
.bnr.red { color: #FFC9BD; }
.bnr.warn { color: #FFB08A; }
/* informational lines (tap responses) are quieter than reward shouts */
.bnr.info { font-size: var(--tCard); letter-spacing: .3px; color: #EFE8FF; text-shadow: var(--sh1); padding: 5px 12px; }
@keyframes bnrIn { from { transform: scale(0); } 60% { transform: scale(1.08); } to { transform: scale(1); } }
@keyframes bnrOut { to { opacity: 0; transform: translateY(-13px); } }

/* ============================================================================
   REWARD OVERLAY - a menu solid at full screen, with the one pure-white detail
   card the spec puts inside every dialog.
   ============================================================================ */
#rewardOv {
  position: absolute; inset: 0; z-index: 9;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  background: #2F2570;                      /* flat. No decorative gradient. */
  opacity: 0; transition: opacity .3s; visibility: hidden;
}
#rewardOv.show { opacity: 1; visibility: visible; pointer-events: auto; }
#rwdKicker { font-size: var(--tCard); letter-spacing: 2px; color: #BDB2F0; }
#rwdName { font-size: min(9vw, 34px); color: #fff; letter-spacing: 1px; margin-top: 6px; text-shadow: var(--ol3); }
#rwdRarity {
  margin-top: 9px; font-size: var(--tMicro); letter-spacing: 2px; color: #fff;
  padding: 4px 16px; border-radius: var(--rPill); background: #1B1450;
}
#rwdStage { position: relative; width: 168px; height: 168px; margin: 24px 0 0; }
#rwdRays {
  position: absolute; inset: -14px; border-radius: 50%;
  background: conic-gradient(from 0deg, rgba(255, 255, 255, .5) 0deg 5deg, rgba(255, 255, 255, 0) 5deg 30deg,
              rgba(255, 255, 255, .5) 30deg 35deg, rgba(255, 255, 255, 0) 35deg 60deg,
              rgba(255, 255, 255, .5) 60deg 65deg, rgba(255, 255, 255, 0) 65deg 90deg,
              rgba(255, 255, 255, .5) 90deg 95deg, rgba(255, 255, 255, 0) 95deg 120deg,
              rgba(255, 255, 255, .5) 120deg 125deg, rgba(255, 255, 255, 0) 125deg 150deg,
              rgba(255, 255, 255, .5) 150deg 155deg, rgba(255, 255, 255, 0) 155deg 180deg,
              rgba(255, 255, 255, .5) 180deg 185deg, rgba(255, 255, 255, 0) 185deg 210deg,
              rgba(255, 255, 255, .5) 210deg 215deg, rgba(255, 255, 255, 0) 215deg 240deg,
              rgba(255, 255, 255, .5) 240deg 245deg, rgba(255, 255, 255, 0) 245deg 270deg,
              rgba(255, 255, 255, .5) 270deg 275deg, rgba(255, 255, 255, 0) 275deg 300deg,
              rgba(255, 255, 255, .5) 300deg 305deg, rgba(255, 255, 255, 0) 305deg 330deg,
              rgba(255, 255, 255, .5) 330deg 335deg, rgba(255, 255, 255, 0) 335deg 360deg);
  -webkit-mask-image: radial-gradient(circle, #000 12%, rgba(0, 0, 0, .55) 42%, transparent 70%);
  mask-image: radial-gradient(circle, #000 12%, rgba(0, 0, 0, .55) 42%, transparent 70%);
  animation: rwdSpin 14s linear infinite;
}
@keyframes rwdSpin { to { transform: rotate(360deg); } }
#rwdGlow {
  position: absolute; inset: 26px; border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 255, 255, .85) 0%, rgba(190, 210, 255, .35) 45%, transparent 72%);
}
/* the pure-white detail card */
#rwdTile {
  position: absolute; left: 50%; top: 50%; width: 82px; height: 82px; margin: -41px 0 0 -41px;
  border-radius: var(--rNest); box-sizing: border-box;
  background: #fff; box-shadow: 0 6px 14px rgba(0, 0, 0, .4);
  display: flex; align-items: center; justify-content: center;
}
#rwdOrb { width: 50px; height: 50px; border-radius: 50%; }
#rwdTap { margin-top: 30px; font-size: var(--tCard); color: rgba(255, 255, 255, .82); }
#rewardOv.show #rwdName { animation: bnrIn .42s cubic-bezier(.2, 1.4, .4, 1) both; }
#rewardOv.show #rwdTile { animation: rwdPop .5s cubic-bezier(.2, 1.5, .4, 1) both .1s; }
@keyframes rwdPop {
  0% { transform: scale(0) rotate(-24deg); }
  62% { transform: scale(1.16) rotate(4deg); }
  100% { transform: scale(1) rotate(0); }
}
#rwdTap { animation: rwdBlink 1.3s ease-in-out infinite; }
@keyframes rwdBlink { 50% { opacity: .45; } }

/* ---- death overlay -------------------------------------------------------- */
#deathOv {
  position: absolute; inset: 0; background: rgba(22, 4, 8, .82);
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
  opacity: 0; transition: opacity .35s; z-index: 11;   /* above the reward overlay */
}
#deathOv.show { opacity: 1; pointer-events: auto; }
#deathTitle { font-size: min(11vw, 42px); color: #fff; letter-spacing: 3px; text-shadow: var(--ol3); }
#deathSub { font-size: var(--tCta); color: rgba(255, 255, 255, .85); }
#deathRing {
  position: relative; width: 84px; height: 84px; border-radius: 50%;
  background: conic-gradient(var(--gold) 0% 0%, rgba(255, 255, 255, .16) 0% 100%);
}
#deathDisc {
  position: absolute; inset: 8px; border-radius: 50%; background: #1C1218;
  display: flex; align-items: center; justify-content: center;
}
#deathNum { font-size: 28px; color: #fff; text-shadow: var(--ol2); }

/* ============================================================================
   FTUE - A POINTER, NOT A LABEL WITH A RING.

   What was here: a bare 3px light-grey outlined circle with no fill, under a
   white hand glyph carrying a 6px black stroke. Neither belonged to anything -
   the ring is a debug primitive and 6px is a weight no other glyph on this HUD
   uses (props outline at 1.8, nav glyphs at 2).

   What the reference actually does (frames f00020-f00120, read at 3-9x):
     * ONE oversized yellow arrow hung over the thing you are meant to go to
       (f00040, over the Bonfire plot) - chunky, hard near-black outline, gold
       ramp, offset dark under-body, and it bobs down the target's own column.
     * SMALL YELLOW MARKERS stamped ON the things you are meant to act on
       (f00020, a tapered "!" over each of three Sproutlings).
   Two halves of one language, and this build now speaks both: #hintArrow is the
   SAME arrow the objective layer uses (js/ui.js goldArrow(), so they cannot
   drift), and .hintMark is the "!" riding the control itself.

   The label keeps the dense-glass pill it already had - that IS a panel token
   from FAMILY A, and the reference does caption its cues ("New Menu Unlocked",
   f00050). The pill is what was right about the old build.

   THE CAPTION AND THE POINTER ARE TWO ELEMENTS NOW, and the band map is what
   forced it. As one bottom-anchored flex column, a hint that pointed at a dock
   button had to hang its 24px pill 40px above that button - measured at 390x844
   the "Buy a Power upgrade" pill occupied y 0.839..0.867 against #dockStats at
   0.851..0.872, i.e. straight through the hp bar, and at the left-hand slot
   through the objective stack as well. Between the hp bar and the dock there is
   44px of clear screen: room for the 40px pointer and nothing else.
   So the CAPTION lives in the prompt band at --bPromptTop, dead centre, in the
   same slot the cargo warning uses - one place the player learns to look - and
   only the POINTER travels to the control it names.
   ============================================================================ */
/* THE PROMPT SLOT, TENANT 2 OF 2. Same anchor as #cargoHint, deliberately: see
   the note there. Only one of the two is ever `.show` (js/ui.js applyHint). */
/* THE BOUNCE GOES ON THE CENTRED BOX, NOT ON THE PILL INSIDE IT. hintBounce is
   written as `translate(-50%, n)` because it has to preserve the -50% centring
   of whatever it animates; put on .hintPill - which is a block child with no
   centring transform of its own - that -50% is a pure leftward shift of half the
   pill's width. Captured at 390x844 with "Buy a Power upgrade": the box measured
   x 0.321..0.679 and the ink painted at 0.141..0.500. */
#hintBub {
  position: absolute; left: 50%; transform: translateX(-50%); top: var(--bPromptTop);
  z-index: 4; display: none;
  animation: hintBounce .7s ease-in-out infinite;
}
#hintBub.show { display: block; }
.hintPill {
  display: inline-block;
  background: var(--glassDense); border-radius: var(--rPill);
  padding: 5px 11px; font-size: var(--tCard); color: inherit;
  text-shadow: var(--sh1); white-space: nowrap; box-shadow: var(--glassSh);
}
/* #hintBub's own copy is white; #cargoHint sets gold on the wrapper and this
   inherits it, which is why the token declares `color: inherit` above. */
#hintBub .hintPill { color: #fff; }
/* 40px: one notch under the 44px objective arrow, because a build plot has to
   be read from across the camp and a HUD control is 60px away. Same glyph, same
   drop shadow, same jab - it is one pointer with two jobs. The arrow's tip sits
   at 90% of its own box (the path bottoms out at y=21.6 of 24), so an anchor at
   `bottom: N` puts the tip at N+4 and the jab drives it 7px further down, into
   the target.
   IT RIDES THE DOCK BAND, in the 44px strip between the foot of the hp bar
   (--bVitals1) and the top of the button row. --bArrowBot is that anchor and it
   is published from the band table, so if the dock ever moves the pointer moves
   with it. */
#hintArrow {
  position: absolute; width: 40px; height: 40px; bottom: var(--bArrowBot);
  display: none; z-index: 4; pointer-events: none;
  animation: hintJab .9s ease-in-out infinite;
}
#hintArrow.show { display: block; }
#hintArrow svg { width: 100%; height: 100%; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, .42)); }
@keyframes hintJab { 0%, 100% { transform: translate(-50%, 0); } 50% { transform: translate(-50%, 7px); } }

/* THE TARGET MARKER. Overhangs the control's TOP-LEFT corner by half its own
   width, the same overhang rule .badge uses on the top-right - which is also why
   it is on the opposite corner, so a rail button can carry an affordability
   count and a teaching marker at once without them colliding. */
.hintMark {
  position: absolute; top: -14px; left: -11px; width: 25px; height: 29px;
  display: none; z-index: 4; pointer-events: none;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .45));
}
.hintMark svg { width: 100%; height: 100%; }
.hinted .hintMark { display: block; animation: markBob .9s ease-in-out infinite; }
@keyframes markBob { 0%, 100% { transform: translateY(0) scale(1); } 50% { transform: translateY(-4px) scale(1.07); } }
/* THE TRANSFORM DISC TAKES THE MARKER ON ITS LEFT RIM, NOT ITS TOP.
   A circle's bounding-box corner is empty air, so the rail's top-left overhang
   cannot be reused here - but the obvious substitute (over the top of the rim,
   `top:-12px; left:8px`) was measured landing ON the caption: #tfName is
   `bottom:100%` + 5px, i.e. y -19..-5 in the button's own box, and a 25x29
   marker hung at top:-12 occupies -12..17. Captured at 390x844 the "!" sat
   between the N and the S and the button read "TRA?SFORM".
   The left rim is the only flank that is free: the caption is above (ends y=-5),
   the ready-state spark owns the top right (top:-10, right:-7 -> x 58..75), the
   dock starts 66px below the disc's top, and the disc is 10px off the right
   safe-area edge so nothing can sit outboard of it. top:6/left:-13 puts the
   marker at x -13..12, y 6..35: clear of the caption by 11px, clear of the spark
   by 46px, and still overlapping the rim so it reads as stuck ON the button. */
/* THE FORGE CARD TAKES THE MARKER ON ITS LEFT FLANK. The top-left corner rule
   the two rail buttons use cannot be reused here: the card's own icon breaks
   25px out ABOVE it and its caption sits above that, so anything hung off the
   top lands on one or the other. The left flank is free - the card's neighbour
   ends 12px away and the badge owns the opposite corner. */
#tfBtn .hintMark { top: 10px; left: -15px; }

/* ---- POINTER anchors. The caption no longer has any: it is always centred in
   the prompt band, for every key, which is the point of having a slot. Only the
   pointer is per-key, and its x is the dock slot it names, resolved inside #lay
   so it lands on that button at any width: the five slots sit at
   15.87 / 32.05 / 50 / 67.95 / 84.13 percent.

   ONLY TWO KEYS GET ONE.
     move / deposit / build  point at the WORLD, not at a control. 'deposit' and
       'build' already have a real world-anchored arrow over their plot
       (#objArrow) and a second one here would be two arrows disagreeing; 'move'
       has no resting joystick position to point at at all.
     transform  is dock slot 3, the forge card: 78px wider and 24px taller than
       its neighbours with its icon breaking 25px out ABOVE the row. There is no
       airspace over it that is not the icon, and it needs a pointer least - it
       is the largest object on the HUD, it wears the "!" marker on its left rim
       and it sparks when the aura is ready. js/ui.js HINT_PTR is the list. ---- */
#hintArrow.h-upgrade { left: 15.87%; }
#hintArrow.h-techs { left: 32.05%; }


/* ============================================================================
   WORLD-ANCHORED LAYERS (boss intro letterbox, build cost bubbles, objective
   arrow, off-screen boss indicator). These used to live in an injected
   stylesheet inside ui.js because css/ui.css was outside that pass's file
   ownership; they are folded in here so the HUD is ONE system with one set of
   tokens and cannot drift.
   ============================================================================ */

/* ---- boss intro letterbox. It paints UNDER the HUD (z-index 1, first child of
   #hud): a cinematic bar that hid the hp bar and the transform button for 1.9s
   of a boss opener would be a playability regression. Under the HUD it still
   crops the world, which is the whole read. ---- */
#cine { position: absolute; inset: 0; z-index: 1; pointer-events: none; display: none; }
#cine.on { display: block; }
.cineBar { position: absolute; left: 0; right: 0; height: 12%; background: #0B0718; transform: scaleY(0); }
#cineTop { top: 0; transform-origin: 50% 0; }
#cineBot { bottom: 0; transform-origin: 50% 100%; }
/* 18.4% = 0.35s of 1.9s, 81.6% = 1.55s of 1.9s. Keep in step with CARD in ui.js. */
#cine.on .cineBar { animation: cineBar 1.9s cubic-bezier(.25, .9, .3, 1) both; }
@keyframes cineBar { 0% { transform: scaleY(0); } 18.4% { transform: scaleY(1); } 81.6% { transform: scaleY(1); } 100% { transform: scaleY(0); } }
/* the copy row clears the transform disc on the right (68px at a 10px inset) */
#cineCopy {
  position: absolute; left: 0; right: 0; bottom: 13.4%; display: flex;
  align-items: flex-end; justify-content: space-between; gap: 8px; opacity: 0;
  padding: 0 calc(88px + var(--sar)) 6px calc(14px + var(--sal));
}
#cine.on #cineCopy { animation: cineCopy 1.9s ease both; }
@keyframes cineCopy {
  0% { opacity: 0; transform: translateY(10px); } 24% { opacity: 1; transform: none; }
  80% { opacity: 1; transform: none; } 95% { opacity: 0; transform: translateY(-4px); } 100% { opacity: 0; }
}
#cineName {
  font-size: 20px; line-height: 1; letter-spacing: .6px; color: #fff; text-shadow: var(--ol2);
  background: var(--glassDense); border-radius: var(--rGlass); padding: 6px 10px 7px; box-shadow: var(--glassSh);
}
/* NO ITALICS (project hard rule): the epithet is gold small caps-adjacent type */
#cineTitle { font-size: var(--tCard); color: var(--gold); text-shadow: var(--sh1); letter-spacing: .5px; padding-bottom: 6px; text-align: right; max-width: 46%; }

/* ---- build plot cost bubbles + the objective arrow ---- */
#wsLayer { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.plotBub {
  position: absolute; left: 0; top: 0; display: none; flex-direction: column;
  align-items: center; gap: 2px; transform-origin: 50% 50%; transform: translate(-50%, -50%);
}
.plotBub.on { display: flex; }
/* OUTLINE, NOT SHADOW. --sh1's own comment says it is for "small type on glass",
   and every other user of it does sit on a plate. This label does not: it floats
   directly on the terrain with nothing behind it, and the plot it names is the
   gold deposit pad - gold 9px type with a 1px soft drop over a gold disc
   measured as unreadable in capture (zz_claimpaid). --ol1 is the token this HUD
   already uses for text over the 3D world, so the label now belongs to the same
   family as the damage numbers and the cargo hint instead of to the glass. */
.pbName { display: none; font-size: var(--tMicro); color: var(--gold); text-shadow: var(--ol1); letter-spacing: .4px; white-space: nowrap; }
.plotBub.act .pbName { display: block; }
.pbCost {
  display: flex; align-items: center; gap: 4px; height: 24px; padding: 0 9px 0 5px;
  background: var(--glassDense); border-radius: var(--rPill); box-shadow: var(--glassSh);
  font-size: var(--tCard); color: #fff; text-shadow: var(--sh1);
}
.pbCost svg { width: 16px; height: 16px; flex: none; }
/* affordable = the commit hue, so "you can pay for this" reads the same on a
   plot as it does on a cost button. UNAFFORDABLE turns the NUMBER red and
   changes nothing else - the plate does not dim, fade or grey out, exactly like
   the reference forge's cost row. State is hue, never opacity. */
.plotBub.aff .pbCost { background: var(--gold); color: #40270A; text-shadow: none; }
.plotBub:not(.aff) .pbCost span { color: var(--cantAfford); }
.plotBub.raise .pbCost { animation: pbRaise .48s ease-out both; }
@keyframes pbRaise { 0% { transform: scale(1); } 38% { transform: scale(1.32); } 100% { transform: scale(.2); opacity: 0; } }
.pbStem { width: 2px; height: 10px; background: var(--wo); opacity: .8; }
/* -50%,-100%: the anchor is the arrow's TIP, so the glyph stands ABOVE the
   world point and points down into it */
#objArrow { position: absolute; left: 0; top: 0; width: 44px; height: 44px; display: none; transform: translate(-50%, -100%); }
#objArrow.on { display: block; }
#objArrow svg { width: 100%; height: 100%; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, .42)); }

/* ---- edge-clamped off-screen boss indicator ---- */
#bossPtr { position: absolute; left: 0; top: 0; z-index: 2; display: none; transform: translate(-50%, -50%); }
#bossPtr.on { display: block; }
#bossPtrArm { position: absolute; left: 50%; top: 50%; width: 17px; height: 17px; margin: -8.5px 0 0 -8.5px; }
#bossPtrArm svg { width: 100%; height: 100%; filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .42)); }
#bossPtrPlate {
  position: relative; display: flex; align-items: center; gap: 4px; height: 22px;
  padding: 0 8px 0 5px; background: var(--glassDense); border-radius: var(--rPill);
  box-shadow: var(--glassSh); font-size: var(--tCard); letter-spacing: .4px;
  color: #fff; text-shadow: var(--sh1); white-space: nowrap;
}
#bossPtrPlate svg { width: 14px; height: 14px; flex: none; }
#bossPtr.alive #bossPtrPlate { background: var(--red); animation: chipPulse .8s ease-in-out infinite; }
@keyframes chipPulse { 50% { filter: brightness(1.22); } }

/* ============================================================================
   LANDSCAPE - the same construction, re-anchored. Nothing here changes a
   recipe; it only moves things that would collide on a short screen.
   ============================================================================ */
@media (orientation: landscape) {
  /* The whole fraction-anchored layout rides #lay, so landscape only has to
     narrow that frame: 440px on a 900px-wide landscape screen leaves the top bar
     and the dock at their portrait proportions in the middle of the glass, which
     is exactly what the port wants. Everything below is a vertical fix, because
     landscape is SHORT and the vertical fractions (0.65*H for cargo, 0.53*H for
     the chest widget) compress into each other. */
  /* THE BAND EDGES THEMSELVES MOVE, and js/ui.js publishes the landscape table
     (BANDS_LAND) when innerWidth > innerHeight - so #chestW, #cargoWrap,
     #cargoHint, #hintBub, #objStack, #dockStats, #hintArrow and #dock all
     re-anchor without a single rule here, because every one of them is already
     written against a var. What used to be eight bare percentages in this block
     is now eight numbers in the table, which is the whole point of having one.
     Only things the table does not describe are left below. */
  #lay { width: min(calc(100% - var(--sal) - var(--sar)), 460px); }
  /* THE BAR HAS TO CLEAR THE RAIL, NOT SIT ON IT. Portrait has 844px of height,
     so the bar drops below the rail's top and their x ranges never meet. In
     landscape the HUD is letterboxed to a centred column and there is no room to
     drop: the bar lands at y 46..83px across the rail's own 49..135px, and the
     mission-log button was measured painting over the boss NAME, cutting
     "ROOTLORD" to "R...TLORD" and covering the left end of the hp track
     (shots/_crop_land.png). Moving it DOWN is not available - the world band
     starts at 142px. So it moves RIGHT, past the 39px rail plus its gutter, and
     gives back the width it takes so the right edge does not travel. */
  #bossBar { top: calc(46px + var(--sat)); left: 56px; width: min(62%, 194px); }
  /* 100, not 112: the tallest banner is 33px and 112 put its foot at 0.372*H
     against a columns band that ends at 0.365 - i.e. one line of shout reaching
     into the play area. Measured with --w 844 --h 390. */
  #banner { top: calc(100px + var(--sat)); }
  #objStack { gap: 7px; }
  /* 30px, not 40: landscape leaves 31px between the foot of the hp bar and the
     top of the dock, and a 40px glyph in it would sit on one or the other. */
  #hintArrow { width: 30px; height: 30px; }
  #joyZone { top: calc(56px + var(--sat)); bottom: calc(80px + var(--sab)); }
  .bnr { font-size: min(3.4vh, 17px); max-width: 54vw; }
  .bnr.lv { font-size: min(4vh, 22px); }
  #sheet { max-height: 78%; bottom: calc(84px + var(--sab)); }
  #rwdStage { width: 130px; height: 130px; margin-top: 12px; }
  #rwdTap { margin-top: 16px; }
  /* The four per-key hint anchors that used to live here are GONE, and they had
     to go rather than be re-tuned: they set `bottom` on #hintBub, which now
     also carries `top: var(--bPromptTop)`. An auto-height box with both edges
     pinned STRETCHES, so in landscape the caption pill would have been a
     280px-tall bar down the middle of the screen. The caption has one anchor in
     both orientations now (the prompt band's slot) and only the pointer is
     per-key, which is what made the second set of anchors unnecessary. */
}

/* ============================================================================
   DEV / ADMIN PANEL
   ----------------------------------------------------------------------------
   THIS SCREEN IS DELIBERATELY UGLY AND IT SHARES NOTHING WITH THE HUD.

   Every other surface in this file is built from the same measured system: the
   two panel families, the four-layer button, the gold accent, the radius
   ladder. A cheat menu that inherited any of it would be one screenshot away
   from being mistaken for a shipped screen - so this one uses monospace, hard
   1px corners, hazard stripes and a magenta nobody on the HUD is allowed to
   use. If it ever looks like it belongs, that is the bug.

   It is a BOTTOM SHEET, not a full-screen modal, and there is no scrim. The
   whole point of the panel is watching what the cheat did to the hero, so the
   top 40% of the screen stays live and un-dimmed while it is open.
   ============================================================================ */
#admin {
  position: absolute; left: 0; right: 0; bottom: 0;
  z-index: 60;                       /* over every HUD plate, under nothing */
  max-height: min(62vh, 520px);
  display: flex; flex-direction: column;
  background: #14121c;
  border-top: 2px solid #ff2fd0;
  box-shadow: 0 -8px 26px rgba(0, 0, 0, .55);
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  transform: translateY(102%);
  transition: transform .18s ease-out;   /* fast and mechanical, not a game feel */
  pointer-events: auto;
  padding-bottom: var(--sab);
}
#admin.open { transform: translateY(0); }

/* HAZARD STRIPES. The universal "you are inside the machine" sign, and the one
   piece of decoration in here that is doing real work. */
#admBar {
  position: relative; flex: none;
  padding: 8px 78px 8px 12px;
  background:
    repeating-linear-gradient(135deg, #ff2fd0 0 10px, #14121c 10px 20px);
  border-bottom: 2px solid #ff2fd0;
}
#admTitle {
  display: inline-block;
  font-size: 13px; font-weight: 700; letter-spacing: 2px; color: #fff;
  background: #14121c; padding: 2px 8px;
}
#admSub {
  display: inline-block;
  font-size: 9px; letter-spacing: .5px; color: #ffb3ee;
  background: #14121c; padding: 2px 8px; margin-top: 3px;
}
.admB.x { position: absolute; top: 8px; right: 10px; min-width: 58px; }

#admBody { overflow-y: auto; overscroll-behavior: contain; padding: 10px 12px 14px; }

/* The live readout. Wraps rather than scrolls: a truncated stat line is worse
   than a two-line one. */
#admStat {
  font-size: 10px; line-height: 1.5; color: #7dffd0;
  background: #0b0a12; border: 1px solid #24304a;
  padding: 6px 8px; margin-bottom: 10px; word-break: break-word;
}

.admSec { margin-bottom: 12px; }
.admH {
  font-size: 9px; letter-spacing: 2px; color: #ff2fd0;
  border-bottom: 1px solid #3a2340; padding-bottom: 3px; margin-bottom: 6px;
}
.admRow { display: flex; flex-wrap: wrap; gap: 6px; }

/* ONE BUTTON, and it is flat. No gradient, no bevel, no radius: a dev control
   must not read as pressable in the same language as a real one. */
.admB {
  flex: 1 1 auto; min-width: 62px;
  padding: 9px 8px;
  font-family: inherit; font-size: 11px; font-weight: 700; letter-spacing: .5px;
  text-align: center; color: #e8e6f2;
  background: #221f30; border: 1px solid #4a4560;
  cursor: pointer; user-select: none;
}
.admB.wide { flex-basis: 100%; }
/* The level steppers and their field are ONE control and must never wrap: a
   -10 that has folded onto its own line reads as a separate action. */
.admB.step { flex: 1 1 40px; min-width: 38px; padding: 9px 2px; }
.admB.go { color: #14121c; background: #ff2fd0; border-color: #ff8ae4; }
.admB.bad { color: #ffd7d7; background: #3a1414; border-color: #b83535; }
.admB.bad.armed { color: #14121c; background: #ff4040; border-color: #ffb3b3; }
.admB.down { filter: brightness(1.45); transform: translateY(1px); }

/* The level field. Native input, native caret, no HUD styling on it at all. */
#admLv {
  flex: 1 1 46px; min-width: 40px; width: 46px;
  padding: 8px 6px;
  font-family: inherit; font-size: 13px; font-weight: 700; text-align: center;
  color: #7dffd0; background: #0b0a12; border: 1px solid #4a4560;
}

/* THE RE-OPEN TAB. Only ever visible in an ?admin=1 session (the class is added
   in initAdmin), so a normal play session has no trace of the panel anywhere on
   screen. It rides up with the sheet so it can never sit under it. */
#admTab {
  position: absolute; right: 0; bottom: calc(38vh + var(--sab));
  z-index: 61; display: none;
  padding: 5px 7px;
  font-family: ui-monospace, Menlo, Consolas, monospace;
  font-size: 10px; font-weight: 700; letter-spacing: 1px;
  color: #14121c; background: #ff2fd0;
  pointer-events: auto; cursor: pointer;
}
#admTab.avail { display: block; }
#admTab.open { bottom: calc(min(62vh, 520px) + var(--sab)); }

@media (orientation: landscape) {
  #admin { max-height: 80vh; left: auto; width: min(420px, 60vw); border-left: 2px solid #ff2fd0; }
  #admTab.open { bottom: calc(80vh + var(--sab)); }
}
