/* =====================================================================
   PineBurst — forest-cozy UI theme.
   ENGINE FILE. Owned by the integrator.

   UI devs: do NOT invent new visual languages. Compose the classes in
   §4 "Component vocabulary" below. If you need a new component, it goes
   here, prefixed `pb-`, and it gets documented in ARCHITECTURE.md §12.

   Layout law:
     #game     — canvas, z 0, fills the viewport, gameplay only.
     #joystick — z 20, engine-owned, pointer-events: none.
     #overlay  — z 30, ALL DOM UI. pointer-events: none by default;
                 every .pb-screen / .pb-panel re-enables it.
     #debug-panel — z 60.
   ===================================================================== */

/* ---------- 1. Tokens ---------- */
:root {
  /* Bark & moss */
  --pb-bark-900: #0E1309;
  --pb-bark-800: #141C10;
  --pb-bark-700: #1E2A17;
  --pb-bark-600: #2A3A20;
  --pb-bark-500: #3A4E2C;
  --pb-moss-400: #6B8F3A;
  --pb-moss-300: #8FBF4A;
  --pb-moss-200: #C8E86B;

  /* Warm woods */
  --pb-wood-700: #3B2A1B;
  --pb-wood-500: #6B4A2A;
  --pb-wood-300: #B4864E;
  --pb-cream:    #F2E8D0;
  --pb-cream-dim:#C9BEA0;
  --pb-parch:    #E8DCC0;

  /* Accents */
  --pb-amber:    #EDC060;
  --pb-amber-hi: #FFD98A;
  --pb-berry:    #B03A2E;
  --pb-sky:      #4FB89A;

  /* Rarity — MUST match data/gear.js RARITY_TIERS[].color */
  --pb-r-common:    #9AA0A6;
  --pb-r-uncommon:  #7FD84C;
  --pb-r-rare:      #46B2FF;
  --pb-r-epic:      #B268FF;
  --pb-r-legendary: #FFB13D;
  --pb-r-mythic:    #FF4FA3;

  /* Semantic */
  --pb-bg:       var(--pb-bark-800);
  --pb-panel:    rgba(30, 42, 23, 0.94);
  --pb-panel-2:  rgba(20, 28, 16, 0.92);
  --pb-ink:      var(--pb-cream);
  --pb-ink-dim:  #A8AE93;
  --pb-line:     rgba(200, 232, 107, 0.20);
  --pb-line-hi:  rgba(237, 192, 96, 0.55);
  --pb-danger:   #E0533F;
  --pb-good:     #8FBF4A;

  /* Metrics */
  --pb-tap: 52px;             /* minimum thumb target */
  --pb-tap-lg: 64px;
  --pb-radius: 14px;
  --pb-radius-sm: 9px;
  --pb-gap: 12px;
  --pb-shadow: 0 6px 0 rgba(0,0,0,.35), 0 12px 28px rgba(0,0,0,.45);
  --pb-shadow-sm: 0 3px 0 rgba(0,0,0,.35);

  /* Safe areas */
  --pb-sat: env(safe-area-inset-top, 0px);
  --pb-sar: env(safe-area-inset-right, 0px);
  --pb-sab: env(safe-area-inset-bottom, 0px);
  --pb-sal: env(safe-area-inset-left, 0px);
  /* ROUND 14 — the bottom-toolbar band. `--pb-sab` (above) only covers the
     home-indicator inset (~34pt); it says nothing about Safari's own bottom
     toolbar (~another 50pt) that sits ON TOP of it when the page is laid out
     under the chrome (viewport-fit=cover). A control whose rect.bottom falls
     inside sab+50px is painted under glass the browser owns, not the page —
     taps there never arrive. `.pb-screen`'s own bottom padding (§4.1) is set
     to this token so every bottom-anchored footer across every screen clears
     the band BY CONSTRUCTION instead of each screen re-deriving its own
     magic number. Measured live pre-fix at 390x844: btn-quit (pause),
     btn-continue (result), btn-open-1 (chest), btn-reveal-done (chest
     reveal) and btn-start-level (level select) all landed within ~10px of
     the raw viewport edge — inside a real toolbar's band on-device even
     though the desktop pane's own `--pb-sab` is 0. See
     docs/qa/menus-upgrade-literacy.md §6 for the measured collision table. */
  --pb-band: calc(var(--pb-sab) + 50px);

  --pb-font: "Trebuchet MS", "Avenir Next", "Segoe UI", system-ui, -apple-system, sans-serif;
  --pb-font-num: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}

/* ---------- 2. Reset / shell ---------- */
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
/* The UA's `[hidden] { display: none }` loses to every `display:flex/grid`
   utility class in this sheet, which used to leave "hidden" panels (the boss
   bar) painted on screen. Make it win. */
[hidden] { display: none !important; }
/* IPHONE BOTTOM-BUTTON BUG (owner, 2026-09-01: "both buttons, Choose a level
   and Retry level, do not work" on an iPhone 15 Pro Max).
   index.html sets `viewport-fit=cover`, which deliberately extends the page
   UNDER Safari's chrome. `height: 100%` then sizes the UI to the LAYOUT
   viewport, which includes the strip hidden behind Safari's bottom toolbar
   (roughly 50pt) — and `env(safe-area-inset-bottom)` only covers the home
   indicator (roughly 34pt), not the toolbar. So on every screen the
   bottom-most controls were painted in a band the browser's own toolbar sits
   on top of, and a tap there never reached the page. The result screen puts
   BOTH of its buttons in exactly that band, which is why both appeared dead
   while everything higher up kept working.
   Fix: size to the VISUAL viewport instead. `--pb-vh` is written from
   window.visualViewport by main.js (authoritative, updates as the toolbar
   shows and hides); `100dvh` is the CSS-only fallback, and `100%` covers
   browsers older than dvh support. */
html, body {
  margin: 0; padding: 0; width: 100%;
  height: 100%;
  height: 100dvh;
  height: var(--pb-vh, 100dvh);
  overflow: hidden; overscroll-behavior: none;
  background: var(--pb-bark-900);
  color: var(--pb-ink);
  font-family: var(--pb-font);
  font-size: 16px; line-height: 1.4;
  /* PINCH MUST ALWAYS BE ABLE TO ZOOM BACK OUT (owner, live phone test: "the
     app zoomed in and I can't zoom out even with a page refresh"). This used
     to be `touch-action: none` on the whole document, which tells the browser
     to hand every gesture to the page, so once a page-level zoom was applied
     there was no gesture left that could undo it. The canvas below still sets
     `touch-action: none` for itself, so the joystick keeps owning its raw
     drags; only the area outside the canvas allows the browser's own
     pinch-to-zoom, which is the recovery gesture. */
  touch-action: pinch-zoom;
  -webkit-user-select: none; user-select: none;
  -webkit-text-size-adjust: 100%;
}
body { position: fixed; inset: 0; }

#app {
  position: absolute; left: 0; top: 0; right: 0; overflow: hidden;
  height: 100%;
  height: 100dvh;
  height: var(--pb-vh, 100dvh);
}

#game {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  display: block; z-index: 0;
  touch-action: none;
  background: var(--pb-bark-900);
}

#overlay {
  position: absolute; inset: 0; z-index: 30;
  pointer-events: none;
  display: block;
}

/* THE DOM UI IS TAPPABLE, EVEN THOUGH THE CANVAS IS NOT.
   `html` sets `touch-action: none` above, and it is right to: the joystick
   reads raw pointer deltas off the canvas and must never lose them to a
   browser pan/zoom gesture. But that declaration INHERITS, and #overlay's
   buttons are ordinary DOM. With `touch-action: none` the UA recognises no tap
   gesture on them at all, so whether a tap becomes a click depends on the
   compatibility-click path and dies on the slightest finger movement — which
   is precisely the loop-12 critic's unconfirmed report: "several consecutive
   click/drag gestures on a level-up modal did not visibly register... a later
   ref-targeted click did get through". A ref-targeted click is a synthetic DOM
   click that never goes through touch handling, which is exactly why that one
   worked and the real taps did not. `.pb-btn` already opted back in
   (`touch-action: manipulation`); `.pb-card` — the level-up offer cards, the
   level tiles, the gear cards — never did. Opt the whole overlay back in here
   so no future control can inherit the canvas's gesture policy by accident.
   `manipulation`, not `auto`: taps and scrolls work, double-tap-to-zoom does
   not, so there is still no 300ms delay and no accidental zoom mid-fight. */
#overlay button,
#overlay [role="button"],
.pb-card {
  touch-action: manipulation;
}

.noscript {
  position: fixed; inset: 0; display: grid; place-items: center;
  background: var(--pb-bark-900); color: var(--pb-cream); font-family: var(--pb-font);
}

/* ---------- 3. Joystick (engine-owned) ---------- */
#joystick { position: absolute; inset: 0; z-index: 20; pointer-events: none; }
#joystick[hidden] { display: none; }
#joystick-base, #joystick-knob {
  position: absolute; border-radius: 50%; transform: translate(-50%, -50%);
  will-change: transform, left, top;
}
/* The stick used to be a ~35%-opacity amber hairline on a dark green field —
   legible in a screenshot, borderline invisible on a phone in daylight. It now
   carries a solid high-contrast ring, a dark inner scrim to separate it from
   whatever is underneath, and a cream knob rather than a translucent one. */
#joystick-base {
  width: 132px; height: 132px;
  border: 4px solid rgba(255,217,138,.92);
  background: radial-gradient(circle, rgba(8,11,6,.62) 0%, rgba(8,11,6,.34) 68%, rgba(8,11,6,.06) 100%);
  box-shadow: 0 0 0 2px rgba(8,11,6,.75), 0 0 22px rgba(0,0,0,.55);
}
#joystick-knob {
  width: 62px; height: 62px;
  border: 3px solid rgba(28,20,10,.9);
  background: radial-gradient(circle at 35% 30%, #FFE9B8, #EDC060 58%, #B4864E 100%);
  box-shadow: 0 0 20px rgba(255,217,138,.6), 0 3px 8px rgba(0,0,0,.6);
}

/* LOCAL ART BATCH 3 (2026-10-10): painted joystick, ability frame and reveal
   back. src/ui/uiArt.js puts `pb-art-<name>` on <html> only once the file is
   decoded, so these rules never show a half-loaded image and the CSS above stays
   the fallback. Sizes, positions and pointer behaviour are unchanged. */
.pb-art-joystickBase #joystick-base {
  border: 0; background: url(assets/ui/joystick-base@2x.webp) center / 100% 100% no-repeat; box-shadow: none;
  filter: drop-shadow(0 0 10px rgba(0,0,0,.5));
}
.pb-art-joystickKnob #joystick-knob {
  border: 0; background: url(assets/ui/joystick-knob@2x.webp) center / 100% 100% no-repeat; box-shadow: none;
  filter: drop-shadow(0 3px 6px rgba(0,0,0,.6));
}

/* =====================================================================
   4. COMPONENT VOCABULARY — this is the shared UI kit.
   ===================================================================== */

/* 4.1 Screen: a full-bleed overlay page. One per mounted screen module.
   Bottom padding is `--pb-band` (defined above with the other safe-area
   tokens) — not the bare `--pb-sab` any screen used to compute on its own —
   so every screen's last row clears Safari's bottom toolbar band by
   construction. Previously this was `calc(var(--pb-sab) + 16px)`, which is
   why the last row of pause, result, chest and level select all landed
   within ~10px of the raw edge (ROUND 14, see the `--pb-band` comment). */
.pb-screen {
  position: absolute; inset: 0;
  pointer-events: auto;
  display: flex; flex-direction: column;
  padding:
    calc(var(--pb-sat) + 10px)
    calc(var(--pb-sar) + 12px)
    var(--pb-band)
    calc(var(--pb-sal) + 12px);
  background:
    radial-gradient(120% 80% at 50% 0%, rgba(107,143,58,.16), transparent 60%),
    linear-gradient(180deg, rgba(14,19,9,.92), rgba(14,19,9,.97));
  animation: pb-screen-in .18s ease-out;
  overflow: hidden;
}
/* Transparent screen: HUD-style, lets the canvas show through. */
.pb-screen--clear { background: none; pointer-events: none; }

/* Boot splash — full-bleed crew painting, once per page load. */
.pb-splash { padding: 0 !important; background: #0c120a; overflow: hidden; }
.pb-splash__art {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: contain;
  object-position: center;
  pointer-events: none;
  background: #0c120a;
}
@media (orientation: landscape) and (min-width: 720px) {
  .pb-splash__art { object-fit: contain; }
}
.pb-splash__cue {
  position: absolute; left: 0; right: 0;
  bottom: max(28px, calc(var(--pb-sab) + 16px));
  margin: 0; text-align: center;
  font: 600 15px/1.3 ui-rounded, "Trebuchet MS", sans-serif;
  letter-spacing: .08em; text-transform: uppercase;
  color: #F4E4C4; text-shadow: 0 2px 8px #0c120a;
  pointer-events: none;
}
.pb-screen--dim   { background: rgba(8,11,6,.72); backdrop-filter: blur(2px); }

/* Screen entry must NEVER animate opacity from 0: a browser that freezes the
   animation timeline (a backgrounded tab, a throttled automation pane, a device
   in low-power mode) would leave the whole screen stuck at 15% opacity with no
   way to recover. Entry animations may move content; they may not hide it. */
@keyframes pb-screen-in { from { transform: translateY(6px); } to { transform: none; } }
/* ENTRY ANIMATIONS ARE MOTION-ONLY, BY RULE.
   None of these may animate opacity from 0. A frozen animation timeline (a
   backgrounded tab, an automation pane, a low-power device) pins an element at
   its current keyframe forever, so any entry that starts transparent can leave
   real content permanently invisible. Movement and scale read as an entrance
   just as well and cannot fail closed. Opacity animation is reserved for purely
   decorative layers (motes, confetti, the blink hint) that nothing depends on. */
@keyframes pb-rise { from { transform: translateY(14px); } to { transform: none; } }
@keyframes pb-pop  { from { transform: scale(.86); } to { transform: scale(1); } }
/* ROUND 41 (fast-s1 grievance 9): pb-pop replaces `transform`, so an element
   centred with translate(-50%) lost its centring for the whole pop. On a
   430 px phone the STREAK LOST card sat 67 px right of centre and 31 px low,
   and the Burst Cache toast 90 px right, which at 3x is most of their life.
   These two variants keep each element's own centring translate. */
@keyframes pb-pop-x  { from { transform: translateX(-50%) scale(.86); } to { transform: translateX(-50%) scale(1); } }
@keyframes pb-pop-xy { from { transform: translate(-50%,-50%) scale(.86); } to { transform: translate(-50%,-50%) scale(1); } }

/* 4.2 Header / title bar */
.pb-header {
  display: flex; align-items: center; gap: var(--pb-gap);
  flex: 0 0 auto; margin-bottom: 10px; min-height: var(--pb-tap);
}
.pb-title {
  font-size: clamp(20px, 5.2vw, 30px); font-weight: 800; margin: 0;
  letter-spacing: .5px; color: var(--pb-amber);
  text-shadow: 0 2px 0 rgba(0,0,0,.5);
}
.pb-subtitle { font-size: 13px; color: var(--pb-ink-dim); margin: 2px 0 0; font-weight: 500; }
.pb-spacer { flex: 1 1 auto; }

/* 4.3 Scrollable body */
.pb-body {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  -webkit-overflow-scrolling: touch; touch-action: pan-y;
  padding-right: 2px;
}
/* Vertically centre short bodies without breaking tall scrolling ones:
   `margin:auto` collapses to 0 the moment the content overflows. */
.pb-screen > .pb-body { display: flex; flex-direction: column; }
.pb-screen > .pb-body > *:first-child { margin-top: auto; }
.pb-screen > .pb-body > *:last-child { margin-bottom: auto; }
.pb-body::-webkit-scrollbar { width: 6px; }
.pb-body::-webkit-scrollbar-thumb { background: var(--pb-bark-500); border-radius: 3px; }

/* 4.4 Footer action bar */
.pb-footer {
  flex: 0 0 auto; display: flex; gap: var(--pb-gap);
  padding-top: 10px; align-items: stretch;
}
/* An action bar is one line by contract — wrapping a single icon button onto
   its own row reads as a layout break, not as a responsive adaptation. */
.pb-footer > .pb-row { flex-wrap: nowrap; }

/* 4.5 Panel */
.pb-panel {
  pointer-events: auto;
  background: var(--pb-panel);
  border: 2px solid var(--pb-line);
  border-radius: var(--pb-radius);
  box-shadow: var(--pb-shadow);
  padding: 14px;
}
.pb-panel--flat { box-shadow: none; background: var(--pb-panel-2); }

/* 4.6 Buttons — all thumb-sized */
.pb-btn {
  pointer-events: auto;
  display: inline-flex; align-items: center; justify-content: center;
  gap: 8px; min-height: var(--pb-tap); padding: 10px 18px;
  font-family: inherit; font-size: 16px; font-weight: 700; letter-spacing: .3px;
  color: var(--pb-cream);
  background: linear-gradient(180deg, var(--pb-bark-500), var(--pb-bark-700));
  border: 2px solid var(--pb-line);
  border-radius: var(--pb-radius-sm);
  box-shadow: var(--pb-shadow-sm);
  cursor: pointer; text-align: center;
  transition: transform .06s ease-out, filter .12s ease-out;
  touch-action: manipulation;
}
.pb-btn:active { transform: translateY(3px); box-shadow: none; filter: brightness(1.1); }
.pb-btn:focus-visible { outline: 3px solid var(--pb-amber); outline-offset: 2px; }
.pb-btn[disabled], .pb-btn[aria-disabled="true"] {
  opacity: .42; cursor: not-allowed; filter: grayscale(.6); transform: none;
}
.pb-btn--primary {
  min-height: var(--pb-tap-lg); font-size: 19px;
  color: #2A1C08;
  background: linear-gradient(180deg, var(--pb-amber-hi), var(--pb-amber) 55%, #C79A3E);
  border-color: rgba(255,255,255,.35);
  text-shadow: 0 1px 0 rgba(255,255,255,.35);
}
.pb-btn--danger { background: linear-gradient(180deg, #C7503C, #7E2B1E); border-color: rgba(255,180,160,.3); }
/* Armed = one more tap wipes the save. Loud on purpose: the old version showed
   its armed state only in a toast, so players could not tell it had responded. */
.pb-btn--armed {
  background: linear-gradient(180deg, #F0663F, #A83218);
  border-color: #FFD8C0;
  color: #FFF6F0;
  animation: pb-armed-pulse 1s ease-in-out infinite;
}
@keyframes pb-armed-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(255,120,80,.55); }
  50%      { box-shadow: 0 0 0 6px rgba(255,120,80,0); }
}
@media (prefers-reduced-motion: reduce) { .pb-btn--armed { animation: none; } }
.pb-btn--ghost  { background: rgba(30,42,23,.6); border-color: var(--pb-line); }
.pb-btn--block  { width: 100%; }
/* min-width:0 lets a grow button shrink below its icon+label intrinsic width,
   so a four-button row still fits one line on a 375px phone instead of
   wrapping the last icon onto a row of its own. */
.pb-btn--grow   { flex: 1 1 0; min-width: 0; }
.pb-btn--icon   { min-width: var(--pb-tap); padding: 8px; font-size: 20px; }
/* 2026-10-09: the painted sound horn is drawn at 40 px (see ui/menus.js btn-mute); 4px padding + the 2px border keep the button the same 52px square. */
.pb-btn--icon.pb-btn--mute { padding: 4px; flex: 0 0 auto; }
/* ROUND 14 tester finding — 44px cleared the brief's 44px floor but missed
   docs/ARCHITECTURE.md §12's own stricter site-wide `--pb-tap` minimum (52px,
   48px under 380px wide) that every other `.pb-btn` already honours (§4.6
   above). `--sm` is what gear's Equip/Merge/Level row (`btn-merge` etc,
   src/ui/gearScreen.js mkBtn) uses, so it was the one row of primary buttons
   on the whole site under the site's own floor. */
.pb-btn--sm     { min-height: 52px; font-size: 14px; padding: 6px 12px; }
@media (max-width: 380px) {
  .pb-btn--sm   { min-height: 48px; }
}

/* 4.6b TEXT-LINK BUTTONS (round 7 items 9, 26) — for an action that must stay
   reachable but should not carry the same visual weight as a screen's real
   primary/secondary actions: "Abandon run" underneath pause's Resume/Settings,
   "Erase all progress" at rest underneath Settings' toggles. Still a real
   `.pb-btn` (same tap-target floor, touch-action, focus ring), just with the
   brick chrome stripped so it reads as a quiet line of text, not a fourth
   button competing for the eye. `.pb-text-danger` is a standalone colour
   utility — combine it with `.pb-btn--link` for a quiet warning, or with the
   full `.pb-btn--danger` brick (as the erase button's ARMED state still does,
   JS-toggled) when the moment genuinely calls for loud. */
.pb-btn--link {
  background: none; border: none; box-shadow: none;
  min-height: 44px; padding: 8px 10px; font-size: 14px; font-weight: 700;
  color: var(--pb-ink-dim); text-decoration: underline; text-underline-offset: 3px;
}
.pb-btn--link:active { transform: none; filter: brightness(1.35); box-shadow: none; }
.pb-text-danger { color: var(--pb-danger); }

/* 4.6c A labelled row for a continuous control (Settings' Volume slider) —
   same tap-target floor as every other settings row, so it sits at the same
   height as the On/Off toggles immediately above and below it. */
.pb-row--setting { min-height: var(--pb-tap); }
.pb-range {
  flex: 1 1 auto; min-width: 80px; height: 28px;
  accent-color: var(--pb-amber);
  background: transparent;
}

/* 4.7 Grid / list */
.pb-grid { display: grid; gap: var(--pb-gap); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.pb-grid--2 { grid-template-columns: repeat(2, 1fr); }
.pb-grid--3 { grid-template-columns: repeat(3, 1fr); }
.pb-row { display: flex; gap: var(--pb-gap); align-items: center; flex-wrap: wrap; }
.pb-col { display: flex; flex-direction: column; gap: 8px; }

/* 4.8 Card (level tile, upgrade card, gear item, chest reveal) */
.pb-card {
  pointer-events: auto; position: relative;
  display: flex; flex-direction: column; gap: 6px;
  min-height: var(--pb-tap-lg); padding: 12px;
  background: linear-gradient(180deg, rgba(58,78,44,.75), rgba(20,28,16,.9));
  border: 2px solid var(--pb-line); border-radius: var(--pb-radius);
  box-shadow: var(--pb-shadow-sm);
  cursor: pointer; text-align: left; color: var(--pb-ink);
  font-family: inherit; font-size: 15px;
  transition: transform .08s ease-out, border-color .12s;
  animation: pb-rise .2s ease-out;
}
.pb-card:active { transform: translateY(2px) scale(.99); }
.pb-card[data-locked="true"] { opacity: .45; filter: grayscale(.75); cursor: not-allowed; }
.pb-card__name { font-weight: 800; font-size: 15px; }
.pb-card__desc { font-size: 12.5px; color: var(--pb-ink-dim); line-height: 1.35; }
.pb-card__tag  { font-size: 11px; text-transform: uppercase; letter-spacing: .8px; color: var(--pb-amber); font-weight: 700; }
/* ROUND 13 (docs/qa/menus-upgrade-literacy.md §2.4) — the star/rank-up STEP
   line under a card's name: "★1 → ★2" / "Rank 3 → Rank 4". A from-value and
   a to-value, not a bare "Longsling ★2", so the player reads that they
   already own the from. Offer-card only (weapon-new/passive-new/utility
   never render one — offerStepLine() in hud.js returns null for those). */
.pb-card__step {
  font-size: 12px; font-weight: 700; letter-spacing: .3px;
  color: var(--pb-amber); font-family: var(--pb-font-num);
}
/* ROUND 13 (§2.3) — the slot-cost micro-line. Never hidden: the only
   irreversible decision on this screen is spending a run slot. `--warn`
   matches the strip's own warm-red LAST SLOT signal so the two never
   disagree in colour either. */
.pb-card__cost {
  font-size: 10px; font-weight: 700; letter-spacing: .2px;
  color: var(--pb-ink-dim);
}
.pb-card__cost--warn { color: #FF9E7A; }
/* "one more…" evolution-ready card */
.pb-card--golden {
  border-color: var(--pb-amber); box-shadow: 0 0 18px rgba(237,192,96,.45), var(--pb-shadow-sm);
  background: linear-gradient(180deg, rgba(120,92,34,.75), rgba(28,22,10,.92));
}

/* ROUND 15 FIX — the Gear screen's OWN "a better piece is waiting" mark.
   ui/gearScreen.js used to reuse .pb-card--golden (this exact amber frame)
   for a slot holding an unequipped piece and nothing equipped, which is the
   Weapon Stage overlay's single most loaded signal ("this pick evolves a
   weapon") bleeding into the between-run kit screen — the one crossing the
   brief says A and B must never share. Cool blue, corner badge only, no
   full-card gradient wash: reads as "go look at this slot", not "evolve". */
.pb-card--slot-ready {
  border-color: #6FA8FF !important;
  box-shadow: 0 0 10px rgba(111,168,255,.35), var(--pb-shadow-sm);
}
.pb-slotReady__badge {
  position: absolute; top: -8px; right: -7px; line-height: 1;
  font-size: 9px; font-weight: 900; letter-spacing: .2px;
  padding: 2px 4px; border-radius: 5px;
  background: linear-gradient(180deg, #9CC8FF, #4E86D9); color: #041226;
  border: 1px solid rgba(220,238,255,.9); box-shadow: 0 0 6px rgba(111,168,255,.6);
}

/* 4.8b THE EVO ROW — the on-card evolution recipe (round 4).

   PineBurst's copy of Survivor.io's "EVO:" line: a weapon card prints the
   PASSIVE icon it needs, a passive card prints the WEAPON icon(s) it unlocks,
   and both name the payoff. It sits at the FOOT of the card, separated by a
   hairline, so it never competes with the card's own name/stat block — it is a
   footnote until it is a finish line, at which point `data-evo` turns it gold
   and pulses it in step with the card's existing .pb-card--golden treatment.

   Three states are styled; a fourth ('done') is never rendered — hud.js drops
   the row entirely once the recipe is spent, so an evolved weapon's card stops
   asking for a passive the player already has. */
.pb-card__evo {
  display: flex; align-items: center; flex-wrap: wrap; gap: 5px;
  margin-top: auto; padding-top: 6px;
  border-top: 1px solid rgba(242,232,208,.16);
  font-size: 11px; line-height: 1.15; min-width: 0;
  /* ROUND 13 BUGFIX (docs/qa/menus-upgrade-literacy.md §4.1) — THE actual
     cause of "the EVO row paints outside its own card, on every card, at
     every phone width." `.pb-card--offer` sets `align-items: center`
     (centering the icon/tag/name), which for a flex child means its CROSS
     size (width, in this column layout) is computed via CSS's fit-content
     formula — and fit-content's floor is the child's min-content, which for
     `.pb-evo__text`'s `white-space: nowrap` sentence IS its full unwrapped
     width. So on a 375-430px phone the row rendered at its natural ~130px
     content width and got CENTERED, overflowing 20-29px past both edges —
     symmetric spill, worst on the golden card because the evolve-ready
     sentence is the longest text on the overlay. `align-self: stretch`
     overrides the parent's center alignment for this one child only and
     forces it to the card's actual width (a definite length, not
     content-based), so `min-width: 0` above + the text's own
     `overflow:hidden`/`text-overflow:ellipsis` (below) finally have a real
     box to clip against. Verified: card and EVO-row rects match exactly at
     390x844, 375x812 and 430x932. */
  align-self: stretch; width: 100%;
}
.pb-evo__tag {
  flex: none; font-weight: 900; font-size: 9.5px; letter-spacing: .8px;
  padding: 2px 5px; border-radius: 4px;
  background: rgba(126,214,110,.18); color: var(--pb-leaf, #7FD84C);
  border: 1px solid rgba(126,214,110,.42);
}
.pb-evo__icon { flex: none; }
/* ROUND 14 tester finding — `white-space: nowrap` + ellipsis was clipping
   the evolution TARGET NAME on every narrow phone ("Wildflower Nectar ->
   Fo..."), which is the exact string the brief calls out as needing to stay
   readable — the recipe line is worthless if the payoff name is cut. The
   row (`.pb-card__evo`, above) already wraps and stretches to the card's
   real width (round 13's `align-self: stretch` fix), so letting the text
   itself wrap onto a second line — instead of clipping it on one — costs
   only a few px of card height, which the now-`auto` card row (§4.19)
   absorbs the same way it absorbs a longer desc. */
.pb-evo__text {
  min-width: 0; overflow: hidden; white-space: normal; overflow-wrap: break-word;
  color: var(--pb-ink-dim); font-weight: 700;
}
/* One pick away, or already met: the whole row goes amber and breathes. */
.pb-card__evo[data-evo="armed"],
.pb-card__evo[data-evo="ready"] {
  border-top-color: rgba(237,192,96,.5);
  animation: pb-evo-pulse 1.1s ease-in-out infinite;
}
.pb-card__evo[data-evo="armed"] .pb-evo__tag,
.pb-card__evo[data-evo="ready"] .pb-evo__tag {
  background: rgba(237,192,96,.26); color: #FFDD9A; border-color: rgba(255,217,138,.75);
}
.pb-card__evo[data-evo="armed"] .pb-evo__text,
.pb-card__evo[data-evo="ready"] .pb-evo__text { color: #FFE3A8; }
@keyframes pb-evo-pulse { 0%,100% { opacity: .82; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .pb-card__evo[data-evo="armed"], .pb-card__evo[data-evo="ready"] { animation: none; opacity: 1; }
}

/* ROUND 6 — THE EVO CROSS-HIGHLIGHT. A different claim than armed/ready above
   ("this card's own recipe is close") — this says "the OTHER half of this
   recipe is already in your loadout right now", which can be true on a
   brand-new offer whose own state is still 'recipe' (evoCrossPartner() in
   ui/hud.js checks presence only, not the star/level gate). Teal, not amber,
   so the two signals never read as the same thing; the matching loadout-strip
   slot below glows in the same teal so the two highlights read as one pair. */
.pb-card__evo--cross { border-top-color: rgba(79,184,154,.55); }
.pb-evo__cross {
  flex-basis: 100%; margin-top: 1px;
  font-size: 10.5px; font-weight: 800; letter-spacing: .2px;
  color: var(--pb-sky);
}

/* 4.9 Rarity — apply .pb-r-<tier> to any .pb-card / .pb-chip / .pb-panel */
.pb-r-common    { --pb-r: var(--pb-r-common); }
.pb-r-uncommon  { --pb-r: var(--pb-r-uncommon); }
.pb-r-rare      { --pb-r: var(--pb-r-rare); }
.pb-r-epic      { --pb-r: var(--pb-r-epic); }
.pb-r-legendary { --pb-r: var(--pb-r-legendary); }
.pb-r-mythic    { --pb-r: var(--pb-r-mythic); }
[class*="pb-r-"] { border-color: var(--pb-r, var(--pb-line)); }
[class*="pb-r-"].pb-card { box-shadow: 0 0 14px color-mix(in srgb, var(--pb-r) 40%, transparent), var(--pb-shadow-sm); }

/* ROUND 13 SIGNAL SEPARATION (docs/qa/menus-upgrade-literacy.md §3) — "one
   claim, one shape; colour becomes redundancy, never the signal." Live at
   375x812 a `pb-r-legendary` card's border was `#FFB13D` and a
   `.pb-card--golden` (evolve-ready) card's border was `var(--pb-amber)` —
   two different claims (rarity of the offer vs. one pick from an evolution)
   reading as the same highlight. Scoped to `.pb-card--offer` only: rarity
   goes back to a neutral border + a 4px stripe down the leading edge (the
   art tint already carries the rarity colour; this is redundancy, not the
   only signal), and the outer glow is suppressed so it never competes with
   the golden frame below. Gear/level tiles/chest reveal still use the plain
   [class*="pb-r-"] rule above — untouched. */
.pb-card--offer[class*="pb-r-"] {
  border-color: var(--pb-line);
  box-shadow: var(--pb-shadow-sm);
}
.pb-card--offer[class*="pb-r-"]::after {
  content: ''; position: absolute; left: 0; top: 8px; bottom: 8px; width: 4px;
  border-radius: 3px; background: var(--pb-r, transparent);
}
/* EVOLVE-READY keeps the frame it already had (`.pb-card--golden` above) —
   this re-asserts it over the neutral-rarity rule just above (same
   specificity; declared after, so it wins the tie) so a legendary weapon
   one star from evolving is never mistaken for "just a rare pick". */
.pb-card--offer.pb-card--golden {
  border-color: var(--pb-amber);
  box-shadow: 0 0 18px rgba(237,192,96,.45), var(--pb-shadow-sm);
}
.pb-rarity-text,
.pb-chip.pb-rarity-text { color: var(--pb-r, var(--pb-ink)); font-weight: 800; text-transform: capitalize; }

/* 4.10 Chips, badges, currency */
.pb-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 10px; border-radius: 999px; font-size: 13px; font-weight: 700;
  background: rgba(14,19,9,.7); border: 1.5px solid var(--pb-line); color: var(--pb-ink);
}
.pb-chip__num { font-family: var(--pb-font-num); color: var(--pb-amber); }
.pb-badge {
  position: absolute; top: -7px; right: -7px; min-width: 22px; height: 22px;
  display: grid; place-items: center; padding: 0 5px; border-radius: 11px;
  background: var(--pb-berry); color: #fff; font-size: 12px; font-weight: 800;
  border: 2px solid var(--pb-bark-800);
}
.pb-stars { color: var(--pb-amber); letter-spacing: 1px; font-size: 13px; }

/* 4.11 Bars (hp / xp / boss) */
.pb-bar {
  position: relative; width: 100%; height: 14px; border-radius: 7px; overflow: hidden;
  background: rgba(8,11,6,.8); border: 1.5px solid var(--pb-line);
}
.pb-bar__fill { position: absolute; inset: 0; width: 0%; transform-origin: left center; transition: width .12s linear; border-radius: 6px; }
.pb-bar--hp   .pb-bar__fill { background: linear-gradient(180deg, #9BD25A, #4E7A28); }
/* GRIEVANCE FIX (docs/qa/USER-FEEDBACK.md item 5): this bar fills with the
   Amber the player is collecting, and used to be an unrelated blue — a
   labelled-but-mismatched bar is still not self-explanatory. Now it is the
   XP gem's own light/deep colours (actors.js PICKUP_ART.xp: body #FFB300 /
   deep #B85E00), so the bar visibly matches the gems flying into it. */
.pb-bar--xp   .pb-bar__fill { background: linear-gradient(180deg, #FFCE1A, #B85E00); }
.pb-bar--boss .pb-bar__fill { background: linear-gradient(180deg, #FF7A5C, #8E2418); }
.pb-bar--slim { height: 8px; }
.pb-hud__xpwrap { display: flex; flex-direction: column; gap: 2px; }
.pb-hud__xprow { display: flex; align-items: baseline; gap: 6px; }
/* ROUND 10 (owner directive C — "confirm the AMBER caption is legible at
   430px in a live run"). Verified live at 430x932 with PB.startLevel(1): the
   label is never hidden, never shrunk by a phone media query, opacity 1,
   visible — but at 10px it was the SMALLEST type in the whole HUD, under the
   15px stat row directly above it and under the 11px WEAPONS/PASSIVES rack
   labels beside it, on the one caption the round-10 tutorial spends three
   lines teaching. 11.5px/12px measured at 430px: the label box grows 37.5px ->
   43px inside a 346px row that is mostly the flex spacer, so nothing wraps,
   nothing reflows, and the meter row's height is unchanged. */
.pb-hud__xplabel {
  font-size: 11.5px; font-weight: 800; letter-spacing: 1.2px;
  color: #FFCE1A; text-shadow: 0 1px 3px rgba(0,0,0,.95);
}
.pb-hud__xpcount {
  font-family: var(--pb-font-num); font-size: 12px; font-weight: 700;
  color: rgba(242,232,208,.85); font-variant-numeric: tabular-nums;
}

/* The 2.5x Elite Warden that stands in as a level's finale (40 of 50 levels
   have no dedicated boss) borrows the boss bar's slot but must not read as a
   real boss — gauntlet-twitch-1.md §6(d): a full boss bar and name for a
   540hp elite is "an ominous name, an ominous bar, and then the level just
   ends" with no fight behind it. Smaller bar, dimmer amber (not boss-red),
   smaller name text; the "ELITE ·" kicker itself is set in ui/hud.js. */
.pb-col--elite-finale .pb-bar--boss { height: 9px; }
.pb-col--elite-finale .pb-bar--boss .pb-bar__fill { background: linear-gradient(180deg, #FFC168, #A66A1E); }
.pb-col--elite-finale [data-testid="hud-boss-name"] { font-size: 12px; opacity: .88; }

/* Off-screen boss/elite indicator: a chevron pinned to camera.viewW/viewH's
   own coordinate space (hud.js positions it with a raw translate/rotate, not
   flex/grid, so it can point anywhere on the rim) — see hud.js for why.
   GAUNTLET TWITCH ROUND 2: "roughly 12px, and rendered behind the PASSIVES
   0/6 tray... a six-pixel speck under the HUD." Bigger box, an explicit
   z-index above every other HUD element (so nothing can paint over it
   again regardless of DOM order elsewhere), a stronger pulse (scale, not
   just opacity), and a distance label — see hud.js for the two-part
   glyph/distance split that keeps the number upright while the glyph
   itself rotates to point at the boss. */
.pb-boss-arrow {
  position: absolute; left: 0; top: 0; width: 44px; height: 44px;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
  pointer-events: none; will-change: transform; z-index: 45;
}
.pb-boss-arrow__glyph {
  font-size: 30px; line-height: 1; color: #FF6A52;
  text-shadow: 0 0 9px rgba(0,0,0,.9), 0 1px 3px rgba(0,0,0,.95), 0 0 14px rgba(255,106,82,.6);
  animation: pb-boss-arrow-pulse 1.1s ease-in-out infinite;
}
.pb-boss-arrow__dist {
  font-family: var(--pb-font-num); font-size: 11px; font-weight: 800; color: #FFD9CF;
  text-shadow: 0 1px 3px rgba(0,0,0,.95); letter-spacing: .3px;
}
.pb-boss-arrow[hidden] { display: none; }
@keyframes pb-boss-arrow-pulse {
  0%, 100% { opacity: .68; transform: scale(1); }
  50%      { opacity: 1;   transform: scale(1.22); }
}

/* ROUND 5 TWITCH MAJOR M6 — "Every boss in the game gets its name in small
   plain text and a thin HP line, both crammed top-left underneath the
   player's own HP and amber bars... Boss encounters need to announce
   themselves." Its own centred banner, well clear of the player's compact
   stat column, with a name big enough to read as a title and a bar wide
   enough to read as an event. `pb-col` (base layout) and the testids are
   unchanged — see hud.js — so this is presentation only.

   ROUND 13 (owner: "hard to see its health, put it near the top of the
   screen ALWAYS") — ROUND 6's fix below (top: calc(var(--pb-sat) + 66px))
   read as "pushed below both rows of .pb-hud__top" but was never actually
   measured against that block's real rendered height. It measured live —
   both at 430x932 and at 800x450 — as ~72px tall (one stat row + the HP bar
   + the AMBER label/count row + the AMBER bar itself), 6-14px MORE than the
   66px this offset budgeted, so the boss name row was rendering PARTLY
   UNDER the AMBER bar on every device, every boss fight — not a rare
   narrow-viewport corner case, a permanent one. That is what "hard to see
   its health" actually was.
   Fix: `--pb-top-h` is written by hud.js's `syncTopHeight()` from
   `topBlock.getBoundingClientRect().height` — the player stat block's REAL
   size — at mount, on the next animation frame, and on every `resize`. This
   banner's `top` now reads that variable, so its position is DERIVED from
   the block it must clear rather than a constant that silently goes stale
   the next time that block's own content changes. The `84px` fallback below
   only covers the one frame before hud.js's first measurement lands.

   ROUND 21 (docs/design/streaks.md) — `+ var(--pb-meter-h, 0px)` added: the
   new top-centre streak meter (`.pb-meter`) sits directly under this SAME
   stat block, so a boss fight landing mid-streak would otherwise draw this
   banner right on top of the meter. `--pb-meter-h` (hud.js's
   syncMeterHeight()) is the meter's own real height, 0px whenever it is
   hidden — a run that never touches a streak sees this exact same position
   it always has. */
.pb-bossbar {
  position: absolute; left: 50%;
  top: calc(var(--pb-sat) + var(--pb-top-h, 84px) + var(--pb-meter-h, 0px) + 20px);
  transform: translateX(-50%); width: min(78vw, 460px); min-width: 220px;
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 7px 16px 10px; border-radius: 12px;
  /* DELIVERABLE 1 — "enough contrast to read over a bright or busy
     background." The name/bar used to rest directly on the game canvas with
     only a text-shadow for separation, which is fine over Rotwood Hollow's
     usual dark palette and not fine over a bright arena or a dense swarm. A
     dark scrim behind the whole banner guarantees the same contrast no
     matter what is on screen underneath it, the same technique .pb-chip and
     every card already use. */
  background: rgba(8,11,6,.6);
  box-shadow: 0 3px 10px rgba(0,0,0,.4);
  pointer-events: none;
  z-index: 5;
}
.pb-bossbar[hidden] { display: none; }
.pb-bossbar__namerow {
  display: flex; align-items: baseline; justify-content: center; gap: 10px;
  max-width: 100%;
}
.pb-bossbar__name {
  font-family: var(--pb-font-display, var(--pb-font-num));
  font-size: 17px; font-weight: 800; letter-spacing: 1.6px; text-transform: uppercase;
  color: #FFD9CF; text-shadow: 0 0 10px rgba(255,90,60,.55), 0 2px 4px rgba(0,0,0,.9);
  /* A long boss name must never push the % readout out of the row or wrap
     the banner taller — it shrinks and ellipsizes; the number stays put. */
  min-width: 0; flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* DELIVERABLE 1 — "a visible percentage ... of health remaining." The bar's
   own fill width told this only to a player already watching it drain in
   real time; this is the hard number, same row as the name so the banner's
   height is unchanged. */
.pb-bossbar__pct {
  font-family: var(--pb-font-num); font-size: 15px; font-weight: 800;
  color: #FFD9CF; text-shadow: 0 2px 4px rgba(0,0,0,.9);
  font-variant-numeric: tabular-nums; flex: 0 0 auto;
}
.pb-bossbar__bar {
  width: 100%; height: 15px; border-radius: 8px;
  border-color: #FF7A5C; box-shadow: 0 0 14px rgba(255,90,60,.4), inset 0 1px 2px rgba(0,0,0,.6);
}
/* The 40-of-50-levels finale-elite stand-in keeps its deliberately smaller,
   dimmer treatment (gauntlet-twitch-1.md §6(d)) even in the new banner slot. */
.pb-col--elite-finale.pb-bossbar { width: min(60vw, 340px); }
.pb-col--elite-finale .pb-bossbar__name { font-size: 13px; opacity: .88; letter-spacing: 1px; }
.pb-col--elite-finale .pb-bossbar__pct { font-size: 12px; opacity: .88; }
.pb-col--elite-finale .pb-bossbar__bar { box-shadow: none; border-color: var(--pb-line); }
/* BUG B5 — the finale-elite variant narrows the bar to min(60vw, 340px) and
   the name's own ellipsis (`.pb-bossbar__name`, styles.css:762) then clips a
   long name like "Moss-Antler Stag Beetle" (23 chars) to "MOSS-ANTLER S…". Let
   the NAME wrap to two lines instead of widening the bar; a real boss's bar
   is full width and its names do fit, so `.pb-bossbar__name`'s own ellipsis
   stays untouched for that case. `.pb-bossbar__pct` (flex: 0 0 auto) is left
   alone so it stays on the first line — that is the requirement the
   ellipsis was protecting (styles.css:760-761). */
.pb-col--elite-finale .pb-bossbar__name {
  white-space: normal; overflow: visible; text-overflow: clip; line-height: 1.05;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
}

/* 4.12 HUD */
.pb-hud { position: absolute; inset: 0; pointer-events: none; }

/* ROUND 28 (owner, docs/qa/round28/levelup-fit.md) — "delete the [weapons
   and slots] on the bottom of the weapon stage select screen to make more
   real estate." Toggled from ui/hud.js off `run.phase === 'levelup'` (see
   its own comment at the levelupChrome block): the in-run loadout rack
   (`.pb-hud__loadout` — `.pb-rack`, `hud-slots`) and the FPS meter
   (`.pb-hud__fps`, hud-fps) are the two bottom-anchored HUD pieces sitting
   directly behind the Weapon Stage overlay's own loadout strip, and both
   just go `display:none` for as long as the overlay covers them — they
   reappear the instant `run.phase` leaves 'levelup' since the class comes
   straight off that live check every frame, never a one-shot toggle.
   ROUND 30 — `.pb-bossbar` (bug B2) joins the levelup half of this rule, and
   the whole trio repeats under `.pb-hud--paused` (bug B3, contract C2, class
   toggled by ui/hud.js's A1 off `run.phase === 'paused'`). Both bugs exist
   because `.pb-hud__fps` (z-index: 6, styles.css:3802) and `.pb-bossbar`
   (z-index: 5, styles.css:749) live inside the HUD root, which establishes
   no stacking context, so they compete directly with a modal mounted as a
   later sibling instead of losing to it automatically. This is the same
   technique round 28 chose over a z-index war; neither z-index value
   changes here. */
.pb-hud--levelup .pb-hud__loadout,
.pb-hud--levelup .pb-hud__fps,
.pb-hud--levelup .pb-bossbar,
.pb-hud--paused  .pb-hud__loadout,
.pb-hud--paused  .pb-hud__fps,
.pb-hud--paused  .pb-bossbar {
  display: none;
}

/* ROUND 7 ITEM 2 (tap freeze) — while pause, the upgrade cards or the revive
   offer is up (ui/hud.js toggles this off run.phase === 'paused'/'levelup'),
   NOTHING in the HUD accepts a pointer event, full stop. `.pb-hud__ability`
   and `.pb-hud__corner` are the only two descendants that normally opt back
   into pointer-events (see their own rules below); this blanket `*` rule is
   deliberately broader than "just those two" so a future HUD control that
   forgets to check for this state is still safe by construction rather than
   by remembering to add it to a list. See ui/hud.js's own comment at the
   frozen/showAbility block for why a targeted z-index war is what caused the
   bug this replaces (the ability button's z-index escaping its non-stacking-
   context parent and painting above whatever modal main.js had just mounted
   as a later sibling of hud-root). */
.pb-hud--frozen,
.pb-hud--frozen * {
  pointer-events: none !important;
}

/* Anchored left, with room reserved on the right for the pause button. Capped
   on desktop so the health bar is not a 1200px-wide loading bar.
   ROUND 7 ITEM 13 — the reserved gap was 58px for a corner button whose own
   box (right:10px + min-width var(--pb-tap)=52px) reaches 62px from the edge,
   so the health/amber bars' full-width fill used to run 4px UNDER the pause
   button along their whole right edge, every viewport, every frame — "the
   pause button sits on the health bar." 74px clears the button with an 12px
   margin to spare. */
.pb-hud__top {
  position: absolute;
  left: calc(var(--pb-sal) + 10px);
  right: calc(var(--pb-sar) + 74px);
  top: calc(var(--pb-sat) + 8px); display: flex; flex-direction: column; gap: 5px;
}
@media (min-width: 900px) { .pb-hud__top { right: auto; width: 640px; } }

/* ROUND 7 ITEM 22 — left-handed mirrors the whole HUD chrome, not only the
   joystick (which is a floating start-anywhere control with no fixed side to
   mirror in the first place, and is engine-owned, outside this file). Pause
   moves top-left, the ability button moves bottom-left, and the weapon/
   passive rack moves to the bottom-right so it stays clear of the ability
   button's new corner — a true diagonal mirror of the three pieces of chrome
   this file actually places. Toggled from ui/hud.js off
   app.save.settings.leftHanded. */
.pb-hud--left .pb-hud__top {
  left: calc(var(--pb-sal) + 74px); right: calc(var(--pb-sar) + 10px);
}
@media (min-width: 900px) { .pb-hud--left .pb-hud__top { left: auto; right: calc(var(--pb-sar) + 10px); width: 640px; } }
.pb-hud--left .pb-hud__corner { right: auto; left: calc(var(--pb-sal) + 10px); }
.pb-hud--left .pb-hud__ability { right: auto; left: calc(var(--pb-sal) + 12px); }
.pb-hud--left .pb-hud__loadout { left: auto; right: calc(var(--pb-sar) + 10px); }
.pb-hud__row { display: flex; align-items: center; gap: 8px; }
.pb-hud__stat { font-family: var(--pb-font-num); font-size: 15px; font-weight: 700; text-shadow: 0 2px 3px rgba(0,0,0,.8); font-variant-numeric: tabular-nums; letter-spacing: .2px; }
/* The loadout rack: all 5 weapon and all 6 passive slots, always drawn, empty
   ones as dashed outlines. The empty slots are the point — they are what tells
   the player mid-run that there are four more weapons to find. */
.pb-hud__loadout {
  position: absolute; left: calc(var(--pb-sal) + 10px); bottom: calc(var(--pb-sab) + 10px);
  display: flex; flex-direction: column; gap: 6px; max-width: 72vw;
}
.pb-rack { display: flex; flex-direction: column; gap: 3px; }
.pb-rack__label {
  font-size: 11px; font-weight: 800; letter-spacing: 1.4px;
  color: rgba(242,232,208,.8); text-shadow: 0 1px 3px rgba(0,0,0,.95);
}
.pb-rack__count { font-family: var(--pb-font-num); color: var(--pb-amber); font-variant-numeric: tabular-nums; }
.pb-hud__rack { display: flex; gap: 6px; flex-wrap: wrap; }
.pb-hud__slot {
  position: relative;
  width: 40px; height: 40px; border-radius: 9px; display: grid; place-items: center;
  background: rgba(14,19,9,.72); border: 2px solid var(--pb-r, var(--pb-line)); font-size: 17px;
}
.pb-hud__slot .pb-stars {
  position: absolute; bottom: -2px; right: 2px; font-size: 9px; line-height: 1;
  text-shadow: 0 1px 2px rgba(0,0,0,.9);
}
.pb-hud__slot--empty {
  background: rgba(8,11,6,.42);
  border-style: dashed; border-color: rgba(242,232,208,.28);
}
.pb-slot__plus { font-size: 15px; font-weight: 800; line-height: 1; color: rgba(242,232,208,.34); }
.pb-hud__pslot {
  position: relative;
  width: 28px; height: 28px; border-radius: 999px; display: grid; place-items: center;
  background: rgba(14,19,9,.72); border: 2px solid var(--pb-line); font-size: 13px;
}
.pb-hud__pslot .pb-chip__num {
  position: absolute; bottom: -3px; right: -2px; font-size: 9px; line-height: 1;
  text-shadow: 0 1px 2px rgba(0,0,0,.9);
}
/* ROUND 17 FIX (reviewer item 7) — "Rank 4" replaces the old "R4" (ui/hud.js),
   the same single form every card sentence and this tile's own title
   attribute already use. A 28px circular tile can't hold 6 characters at the
   old 9px size, so the badge shrinks and is allowed to spill past the
   tile's own edge (it is an absolutely-positioned corner mark already, not
   clipped content) rather than being abbreviated back to a second form. */
.pb-hud__pslot .pb-chip__num--rank {
  font-size: 6.5px; bottom: -4px; right: -7px; white-space: nowrap;
}
@media (max-width: 420px) {
  .pb-hud__slot { width: 34px; height: 34px; }
  .pb-hud__pslot { width: 24px; height: 24px; }
}
.pb-hud__slot.pb-card--golden,
.pb-hud__pslot.pb-card--golden { animation: pb-slot-ready 1.1s ease-in-out infinite; }
@keyframes pb-slot-ready {
  0%,100% { box-shadow: 0 0 8px rgba(237,192,96,.5); }
  50%     { box-shadow: 0 0 20px rgba(255,217,138,.95); }
}

/* 4.12a-bis THE SLOT EVO BADGE — the recipe, pinned to the tile.

   The level-up card is where a recipe is CHOSEN; the HUD rack is where it is
   TRACKED. A weapon tile wears a miniature of the passive it still wants; a
   passive tile wears miniatures of the weapon(s) it is the key to. Corner-
   pinned and half-overlapping the tile so it reads as an annotation on the
   slot rather than a second slot, and drop-shadowed so it survives over the
   bright arena underneath. */
.pb-slot__evo {
  position: absolute; top: -5px; left: -5px;
  display: flex; align-items: center; gap: 1px;
  padding: 1px; border-radius: 999px;
  background: rgba(10,14,7,.92);
  border: 1.5px solid rgba(126,214,110,.55);
  box-shadow: 0 1px 3px rgba(0,0,0,.85);
  line-height: 0; pointer-events: none;
}
.pb-slot__evo--passive { top: -4px; left: -6px; border-color: rgba(126,214,110,.5); }
.pb-slot__evo[data-evo="armed"],
.pb-slot__evo[data-evo="ready"] {
  border-color: #FFD98A;
  box-shadow: 0 0 8px rgba(255,217,138,.9), 0 1px 3px rgba(0,0,0,.85);
  animation: pb-slot-ready 1.1s ease-in-out infinite;
}
/* An evolved weapon's tile stops asking for anything and starts announcing. */
.pb-slot__evomark {
  position: absolute; top: -6px; left: -5px;
  font-size: 7.5px; font-weight: 900; letter-spacing: .5px;
  padding: 1px 3px; border-radius: 4px;
  background: linear-gradient(180deg, #FFDD9A, #E0A63C); color: #241802;
  border: 1px solid rgba(255,240,200,.9);
  box-shadow: 0 0 9px rgba(255,209,120,.75);
  pointer-events: none;
}
.pb-hud__slot--evolved {
  border-color: #FFD98A;
  box-shadow: 0 0 12px rgba(255,209,120,.55), inset 0 0 10px rgba(255,209,120,.22);
}
@media (prefers-reduced-motion: reduce) {
  .pb-hud__slot.pb-card--golden, .pb-hud__pslot.pb-card--golden,
  .pb-slot__evo[data-evo="armed"], .pb-slot__evo[data-evo="ready"] { animation: none; }
}
.pb-hud__corner {
  position: absolute; right: calc(var(--pb-sar) + 10px); top: calc(var(--pb-sat) + 8px);
  display: flex; gap: 8px; pointer-events: auto;
}
/* Low-HP danger vignette — builds tension before the death screen instead of
   only reacting to it. `low` pulses slow and faint; `critical` pulses fast
   and heavy, mirroring the streak escalation below. */
.pb-danger { position: absolute; inset: 0; pointer-events: none; z-index: 4;
  box-shadow: inset 0 0 min(34vw, 260px) rgba(200,24,16,.65);
}
.pb-danger[data-tier="low"]      { animation: pb-danger-low 1.8s ease-in-out infinite; }
.pb-danger[data-tier="critical"] { animation: pb-danger-critical .85s ease-in-out infinite; }
@keyframes pb-danger-low      { 0%,100% { opacity: .16; } 50% { opacity: .4; } }
@keyframes pb-danger-critical { 0%,100% { opacity: .35; } 50% { opacity: .78; } }

/* 4.12b Battle companion (docs/design-allies-proposal.md, Option B).

   THREE COMPONENTS, ONE IDEA. Each companion owns a colour (their CAST hex's
   ability accent), passed in as `--pb-ally` by the JS that mounts the element
   rather than baked into seven CSS classes — so growing the roster is a data
   change, never a stylesheet change. All three read that one variable.

   `.pb-ability` is the first non-movement control this game has shipped, so it
   is sized like one: 76px (the spec's minimum tap target is 52px, primary
   actions 64px), bottom-right, diagonally opposite the loadout rack, and the
   only pointer-events-enabled thing in that corner — the floating joystick can
   be started anywhere else on the canvas and must not be stolen by HUD chrome
   that happens to be under a thumb. */
.pb-hud__ability {
  position: absolute; right: calc(var(--pb-sar) + 12px); bottom: calc(var(--pb-sab) + 14px);
  pointer-events: auto; z-index: 6;
}
.pb-ability {
  position: relative; display: grid; place-items: center;
  width: 76px; height: 76px; padding: 0; border-radius: 50%;
  background: radial-gradient(circle at 50% 38%, rgba(30,40,20,.94), rgba(10,14,7,.96));
  border: 2px solid var(--pb-ally, var(--pb-amber));
  color: var(--pb-parch); cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  box-shadow: 0 4px 14px rgba(0,0,0,.55);
  transition: transform .1s ease-out, box-shadow .18s ease-out;
}
/* Bigger under a thumb. `data-touch` is set from input.usingTouch, so this
   keys off ACTUAL touch use rather than a viewport-width guess. */
.pb-hud__ability[data-touch="true"] .pb-ability { width: 84px; height: 84px; }
.pb-ability:active { transform: scale(.94); }
.pb-ability[disabled] { cursor: default; opacity: .8; }
/* GRIEVANCE 11 (owner, round15, verbatim): "The companion power up button in
   the bottom right should pulse harder. Keep the different colors per
   companion, but it needs to grab attention because it is too easy to forget
   to press it." MEASURED before this pass: the old ready animation only ever
   moved box-shadow blur (10px -> 26px) over 1.5s — no scale change at all, so
   the button never actually MOVED, which is exactly why it read as gentle
   rather than urgent. Now: a real scale pulse (1.0 -> 1.12) at a faster 0.9s
   rhythm, still in the companion's own --pb-ally colour, plus a one-time
   expanding ring "flash" the instant the button BECOMES ready. The flash
   needs no new DOM: [data-ready="true"]::after only starts matching the
   moment ui/hud.js flips the attribute false->true, which is enough on its
   own to (re)start a fresh, non-`infinite` animation from frame 0 — the same
   "toggle an attribute to fire a one-shot" trick `pb-slot-ready` above uses
   for a continuous loop, used here for exactly one iteration. */
.pb-ability[data-ready="true"] { animation: pb-ability-ready .9s ease-in-out infinite; }
@keyframes pb-ability-ready {
  0%,100% { transform: scale(1);    box-shadow: 0 4px 14px rgba(0,0,0,.55), 0 0 10px var(--pb-ally, #EDC060); }
  50%     { transform: scale(1.12); box-shadow: 0 4px 14px rgba(0,0,0,.55), 0 0 30px 4px var(--pb-ally, #FFD98A); }
}
/* The one-time READY flash. A separate layer (::after) rather than a second
   value piled onto the button's own box-shadow/transform — two concurrent
   animations touching the SAME property do not blend predictably, and this
   one must play exactly once (no `infinite`) and then get out of the way of
   the continuous pulse above. */
.pb-ability[data-ready="true"]::after {
  content: ''; position: absolute; inset: -10px; border-radius: 50%;
  border: 3px solid var(--pb-ally, #FFD98A); pointer-events: none;
  animation: pb-ability-flash .6s cubic-bezier(.16,1,.3,1);
}
@keyframes pb-ability-flash {
  0%   { opacity: .95; transform: scale(.72); }
  100% { opacity: 0;   transform: scale(1.55); }
}
/* Motion-sensitive: no scale pulse, no ring burst — a fixed glow still marks
   "charged" without anything moving or flashing. */
@media (prefers-reduced-motion: reduce) {
  .pb-ability[data-ready="true"] {
    animation: none;
    box-shadow: 0 4px 14px rgba(0,0,0,.55), 0 0 22px var(--pb-ally, #FFD98A);
  }
  .pb-ability[data-ready="true"]::after { content: none; animation: none; }
}
/* Painted frame (batch 3). The ally colour still shows in the dial, the ready pulse and the flash ring. */
.pb-art-abilityButton .pb-ability {
  background: url(assets/ui/ability-button@2x.webp) center / 100% 100% no-repeat;
  border-color: transparent; box-shadow: 0 4px 14px rgba(0,0,0,.45);
}
.pb-ability__dial { position: absolute; inset: 3px; width: auto; height: auto; }
.pb-ability__track { stroke: rgba(242,232,208,.16); }
.pb-ability__fill  { stroke: var(--pb-ally, var(--pb-amber)); transition: stroke-dashoffset .08s linear; }
.pb-ability__face  { display: grid; place-items: center; line-height: 0; }
.pb-ability[data-ready="false"] .pb-ability__face { opacity: .5; }
/* The seconds-remaining readout sits ON the dimmed portrait while charging. */
.pb-ability__cd {
  position: absolute; font-family: var(--pb-font-num); font-size: 21px; font-weight: 800;
  color: var(--pb-parch); text-shadow: 0 2px 4px rgba(0,0,0,.95); font-variant-numeric: tabular-nums;
  pointer-events: none;
}
.pb-ability__key {
  position: absolute; bottom: -3px; left: 50%; transform: translateX(-50%);
  font-size: 9.5px; font-weight: 800; letter-spacing: 1.1px; white-space: nowrap;
  color: rgba(242,232,208,.72); text-shadow: 0 1px 3px rgba(0,0,0,.95);
  background: rgba(10,14,7,.9); border-radius: 999px; padding: 1px 7px;
}

/* The arrival flourish's DOM half — 2.4s at the top of a run, centre-top, the
   same "the simulation owns the timer, this only mirrors it" contract the
   frenzy cue and the boss name card use. */
.pb-allybanner {
  position: absolute; left: 50%; transform: translateX(-50%);
  top: calc(var(--pb-sat) + 128px); z-index: 5; pointer-events: none;
  display: flex; flex-direction: column; align-items: center; gap: 1px;
  padding: 6px 18px; border-radius: 18px; max-width: min(88vw, 360px);
  background: rgba(10,14,7,.86); border: 2px solid var(--pb-ally, var(--pb-amber));
  opacity: 0;
}
.pb-allybanner.is-on { animation: pb-ally-in 4.2s ease-out forwards; }
.pb-allybanner__name {
  font-size: 16px; font-weight: 800; letter-spacing: 1.6px;
  color: var(--pb-ally, var(--pb-amber)); text-shadow: 0 2px 4px rgba(0,0,0,.95);
}
.pb-allybanner__sub {
  font-size: 11px; letter-spacing: .3px; color: rgba(242,232,208,.82);
  text-align: center; white-space: normal;
}
/* Motion-only entry, per the rule at the top of §4: never animate from
   opacity 0 to visible on a timeline a throttled tab can freeze — this one is
   allowed because the element is REMOVED by its own JS countdown, so a frozen
   animation cannot strand an invisible banner on screen. */
@keyframes pb-ally-in {
  0%   { opacity: 0; transform: translateX(-50%) translateY(-10px); }
  10%  { opacity: 1; transform: translateX(-50%) translateY(0); }
  80%  { opacity: 1; }
  100% { opacity: 0; }
}

/* ROUND 22 GRIEVANCE (item 8, owner verbatim): "I still don't understand
   what the circle does and it was never explained to me." The `hazel-snare-
   tip` banner (ui/hud.js) — a 3s, once-per-save plain-language explainer for
   Bramble Snare's ring, mirroring `run.scratch.hazelSnareTip`. Sits BELOW
   `.pb-allybanner` above (its own centre-top slot, 128px down) rather than
   sharing it: the two CAN legitimately be on screen in the same window (a
   player who casts the active in the first few seconds of a run), and this
   one carries a full sentence, not a short name, so it gets its own row and
   a wider max-width. Same "simulation owns the countdown, JS-driven removal"
   opacity-animation exception `.pb-allybanner` above documents — this one is
   just as safe for the same reason: `hazelSnareTipEl.hidden` is toggled from
   `run.scratch.hazelSnareTip.t`, a JS-owned countdown, not the CSS timeline
   alone, so a frozen tab cannot strand it visible OR invisible. */
.pb-hazelsnaretip {
  position: absolute; left: 50%; transform: translateX(-50%);
  top: calc(var(--pb-sat) + 176px); z-index: 5; pointer-events: none;
  padding: 7px 16px; border-radius: 14px; max-width: min(90vw, 400px);
  background: rgba(10,14,7,.88); border: 2px solid #FFD98A;
  color: #FFD98A; font-size: 12.5px; font-weight: 700; line-height: 1.4;
  text-align: center; text-shadow: 0 1px 3px rgba(0,0,0,.9);
  opacity: 0;
}
.pb-hazelsnaretip.is-on { animation: pb-hazelsnaretip-in 3s ease-out forwards; }
@keyframes pb-hazelsnaretip-in {
  0%   { opacity: 0; transform: translateX(-50%) translateY(-8px); }
  8%   { opacity: 1; transform: translateX(-50%) translateY(0); }
  86%  { opacity: 1; }
  100% { opacity: 0; }
}

/* The pre-run companion picker (ui/levelSelect.js). A sheet grid
   (`pb-companionSheet__grid`, levelSelect.js:336) — "Alone" plus the seven
   friends — so the whole roster, locked included, is visible on a 375px
   phone without a second screen. */
.pb-companion {
  flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: 2px;
  width: 64px; min-height: 70px; padding: 4px 2px; cursor: pointer;
  background: rgba(14,19,9,.72); border: 2px solid var(--pb-line); border-radius: 11px;
  color: var(--pb-parch);
}
.pb-companion__art { position: relative; line-height: 0; }
.pb-companion__solo {
  display: grid; place-items: center; width: 34px; height: 34px;
  font-size: 20px; color: var(--pb-ink-dim);
}
.pb-companion__name {
  /* ROUND3 TWITCH: names truncated at 854x480 ("Clove…", "Chestna…") — a
     fixed nowrap span in a 58px tile has no room for "Chestnut". Wrapping to
     up to two lines at a slightly smaller size always shows the whole name,
     at every viewport, instead of clipping it. */
  font-size: 9px; font-weight: 700; letter-spacing: .2px; white-space: normal;
  overflow-wrap: break-word; text-align: center; line-height: 1.15; max-width: 60px;
}
.pb-companion[data-selected="true"] {
  border-color: var(--pb-amber); background: rgba(60,48,16,.85);
  box-shadow: 0 0 12px rgba(237,192,96,.45);
}
.pb-companion--locked { opacity: .45; }
.pb-companion__lock {
  position: absolute; right: -3px; bottom: -3px; font-size: 11px;
  text-shadow: 0 1px 2px rgba(0,0,0,.95);
}
/* 4.13 Dialogue */
.pb-dialogue {
  position: absolute; inset: 0; pointer-events: auto;
  display: flex; flex-direction: column; justify-content: flex-end;
  padding: calc(var(--pb-sat) + 12px) calc(var(--pb-sar) + 12px) calc(var(--pb-sab) + 16px) calc(var(--pb-sal) + 12px);
  cursor: pointer;
}
.pb-dialogue__box {
  background: linear-gradient(180deg, rgba(46,36,22,.97), rgba(24,18,10,.98));
  border: 3px solid var(--pb-wood-300); border-radius: var(--pb-radius);
  box-shadow: var(--pb-shadow); padding: 14px 16px 16px; min-height: 132px;
  animation: pb-rise .18s ease-out;
}
.pb-dialogue__speaker { color: var(--pb-amber); font-weight: 800; font-size: 17px; margin-bottom: 6px; }
.pb-dialogue__text { font-size: 16.5px; line-height: 1.5; color: var(--pb-parch); min-height: 3.2em; }
.pb-dialogue__hint {
  margin-top: 10px; font-size: 12px; color: var(--pb-ink-dim); text-align: right;
  animation: pb-blink 1.4s ease-in-out infinite;
}
@keyframes pb-blink { 0%,100% { opacity:.35 } 50% { opacity:1 } }

/* 4.14 Level-up cards (3-up, responsive to portrait phones)
   ROUND 7 ITEM 21 + OWNER REPORT ("on my iPhone 15 Pro Max I can't see all
   the choices during level upgrades and weapon selection") — the level-up
   overlay IS the weapon-selection screen during a run; there is no second
   screen for it, so this one fix answers both.
   Below, three columns run at every width, not just >=720px. A single
   stacked column used to be the phone layout, and `.pb-levelup`'s own
   `height:100%` + `justify-content:center` meant an overflow got clipped
   EQUALLY off the top and bottom (screen.pb-screen is overflow:hidden by
   layout law, §4.1) — three tall cards of data-driven, variable-length copy
   plus the loadout strip plus Reroll had no reliable way to all fit a
   phone's height, which is exactly the menu designer's live repro ("Card 1
   is a scroll... Thornlash's name and type were invisible"). Three narrow
   columns side by side make each card short enough, by construction, that
   nothing above ever has to be tall — width was always the axis a phone in
   portrait has more of. `.pb-levelup__cards` is a flex CHILD here (grid
   itself, but sized by flex:1 against `.pb-levelup`'s own flex column) so it
   claims exactly whatever height is left after the heading, the loadout
   strip and Reroll, and `grid-auto-rows: 1fr` + each card's own `height:100%`
   (below, §4.19) make every card fill that claimed height exactly — nothing
   left to overflow, at any content length, on any screen. */
.pb-levelup { display: flex; flex-direction: column; gap: 10px; justify-content: flex-start; height: 100%; }
.pb-levelup__cards {
  display: grid; gap: 8px; grid-template-columns: repeat(3, 1fr); grid-auto-rows: 1fr;
  flex: 1 1 auto; min-height: 0;
}
@media (min-width: 720px) { .pb-levelup__cards { gap: 10px; } }

/* 4.15 Misc */
.pb-empty { padding: 28px 16px; text-align: center; color: var(--pb-ink-dim); font-size: 14px; }
.pb-hr { height: 2px; background: var(--pb-line); border: 0; margin: 12px 0; border-radius: 1px; }
.pb-kv { display: flex; justify-content: space-between; gap: 10px; font-size: 13.5px; padding: 3px 0; }
.pb-kv__k { color: var(--pb-ink-dim); }
.pb-kv__v { font-family: var(--pb-font-num); font-weight: 700; }
.pb-odds__caption { font-size: 11.5px; color: var(--pb-ink-dim); margin: 2px 0 6px; }
/* ROUND 7 PLAN ITEM 27 — "cache odds render as bars with '1 in N' alongside
   percentages." One row per tier: the rarity label, a track that fills
   proportionally to that tier's own share (in the tier's own rarity colour,
   so the bar and the label agree without a legend), then the number pair.
   `pb-kv`'s two-column layout (label/value only) doesn't have a slot for the
   middle bar, so this is its own row rather than a `pb-kv` variant. */
.pb-odds-row { display: flex; align-items: center; gap: 10px; font-size: 13.5px; padding: 4px 0; }
.pb-odds-row .pb-kv__k { flex: 0 0 78px; }
.pb-odds-row .pb-kv__v { flex: 0 0 auto; white-space: nowrap; }
.pb-odds-row__track {
  flex: 1 1 auto; height: 9px; border-radius: 5px; overflow: hidden;
  background: rgba(8,11,6,.7); border: 1px solid var(--pb-line);
}
.pb-odds-row__fill { display: block; height: 100%; border-radius: 4px; min-width: 2px; }
.pb-odds__pity {
  margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--pb-line);
  font-size: 12.5px; color: var(--pb-parch);
}
.pb-toast {
  position: absolute; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--pb-sab) + 90px); z-index: 50; pointer-events: none;
  background: rgba(14,19,9,.94); border: 2px solid var(--pb-line-hi); border-radius: 999px;
  padding: 9px 18px; font-size: 14px; font-weight: 700; animation: pb-rise .2s ease-out;
}

/* One-time onboarding explainer (docs/qa/USER-FEEDBACK.md item 5, "I don't
   get amber... it was never explained"). Bigger and slower than a toast —
   it carries two sentences the first time a mechanic matters — but still
   non-blocking: no backdrop, no phase change, a tap or its own timer clears
   it, and the run plays on underneath exactly like a toast does.

   ROUND 7 ITEM 1: was top-anchored, which is how it ended up sitting on top
   of the boss name card and the top HUD row. Bottom-center, above the
   loadout rack (.pb-hud__loadout: bottom-anchored, up to two stacked
   .pb-rack rows — weapons then passives — each capable of wrapping to a
   second line of 40px slots on a narrow phone; 190px clears that worst case
   with room to spare) is where the combined report's "fixed" description
   puts it, and main.js's calloutBlocked()/dismissCallout() pair are what
   actually keep it off every OTHER screen now — this offset only has to
   answer for the one screen (run) it can still legally appear on. */
.pb-callout {
  position: absolute; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--pb-sab) + 190px); z-index: 55; pointer-events: auto;
  max-width: min(360px, 86vw); cursor: pointer;
  background: linear-gradient(180deg, rgba(58,44,14,.97), rgba(20,15,6,.98));
  border: 2px solid var(--pb-amber); border-radius: 14px;
  padding: 12px 16px; box-shadow: 0 10px 28px rgba(0,0,0,.55);
  animation: pb-callout-in .3s cubic-bezier(.2,.9,.3,1.2);
}
.pb-callout__title {
  font-size: 12px; font-weight: 800; letter-spacing: 1.4px; text-transform: uppercase;
  color: var(--pb-amber); margin-bottom: 4px;
}
.pb-callout__body { font-size: 13.5px; line-height: 1.42; color: var(--pb-parch); }
@keyframes pb-callout-in {
  from { opacity: 0; transform: translate(-50%, -10px) scale(.94); }
  to   { opacity: 1; transform: translate(-50%, 0) scale(1); }
}

/* ITEM 3 (owner, 2026-09-05) — the level-1 first-run tutorial sheet
   (ui/hud.js buildTutorialSheet()/positionTutorialSheet()). Unlike
   `.pb-callout` above, the run behind this IS frozen (systems/run.js
   openTutorialPrompt()), so this reuses that banner's own look but adds a
   real OK button (data-testid tutorial-ok) instead of auto-dismissing — the
   sheet's `bottom` is written per-frame from JS, never CSS, since it has to
   dodge the player's own live screen position rather than sit at one fixed
   offset. */
.pb-tutorial {
  position: absolute; left: 50%; transform: translateX(-50%);
  z-index: 58; pointer-events: auto;
  width: min(400px, 90vw); display: flex; flex-direction: column; gap: 10px;
  background: linear-gradient(180deg, rgba(58,44,14,.98), rgba(20,15,6,.99));
  border: 2px solid var(--pb-amber); border-radius: 16px;
  padding: 16px 18px; box-shadow: 0 12px 30px rgba(0,0,0,.6);
  animation: pb-callout-in .3s cubic-bezier(.2,.9,.3,1.2);
}
.pb-tutorial__title {
  font-size: 13px; font-weight: 800; letter-spacing: 1.4px; text-transform: uppercase;
  color: var(--pb-amber);
}
.pb-tutorial__body { font-size: 14px; line-height: 1.45; color: var(--pb-parch); }
.pb-tutorial__ok { align-self: stretch; }

.pb-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* =====================================================================
   4.16 ICONS — the shared vector icon set (src/ui/icons.js).
   Replaces every OS emoji that used to stand in for an item glyph.
   ===================================================================== */
.pb-icon { display: block; flex: 0 0 auto; overflow: visible; }
.pb-chip .pb-icon, .pb-btn .pb-icon { display: inline-block; vertical-align: -3px; }
.pb-icon--art {
  position: relative; overflow: hidden;
  width: var(--pb-icon-size, 30px); height: var(--pb-icon-size, 30px);
}
/* ROUND 27 §1.1 — a painted key wears a NEUTRAL PLACEHOLDER while its painting
   loads, never the procedural glyph. `.pb-icon__fallback` (the drawn weapon
   that used to sit under every still) is gone from src/ui/icons.js and from
   here with it: the only two things a `.pb-icon--art` can show are
   `.pb-icon__ph` and the real `.pb-icon__art`. */
.pb-icon--art .pb-icon__ph { position: absolute; inset: 0; width: 100%; height: 100%; }
.pb-icon--art.is-art .pb-icon__ph { visibility: hidden; }
.pb-icon--art.is-art.has-ring .pb-icon__ph { visibility: visible; }
.pb-icon--art.has-ring .pb-icon__art { width: 84%; height: 84%; margin: 8%; }
.pb-icon--art .pb-icon__art {
  position: relative; z-index: 1; width: 100%; height: 100%;
  object-fit: contain; display: block; pointer-events: none;
}
.pb-card__art {
  display: grid; place-items: center; margin-bottom: 2px;
  filter: drop-shadow(0 3px 5px rgba(0,0,0,.5));
}
.pb-card__art--chest .pb-icon { animation: pb-idle-bob 3.4s ease-in-out infinite; }
@keyframes pb-idle-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }

/* Star rows — always three pips, lit or dim, so a card never looks unfinished */
.pb-stars--row { display: inline-flex; gap: 2px; align-items: center; }
.pb-stars--row .is-dim { opacity: .22; filter: grayscale(1); }
.pb-stars--row .is-lit { filter: drop-shadow(0 0 4px rgba(255,217,138,.7)); }

/* 4.17 Level tiles */
.pb-card--level { padding-top: 10px; }
.pb-card__thumb {
  position: relative; display: block; width: 100%;
  border-radius: 8px; overflow: hidden; line-height: 0;
  box-shadow: inset 0 0 0 1.5px rgba(0,0,0,.45);
}
.pb-card__thumb .pb-icon--biome { width: 100%; height: 56px; }
@media (max-width: 380px) { .pb-card__thumb .pb-icon--biome { height: 44px; } }
.pb-card__thumbmark {
  position: absolute; right: 4px; top: 4px; line-height: 0;
  filter: drop-shadow(0 1px 2px rgba(0,0,0,.8));
}
.pb-card--level[data-boss="true"] { border-color: var(--pb-amber); }

/* Readiness chip on the level detail panel */
.pb-power[data-ready="ok"]   { border-color: var(--pb-r-uncommon); color: var(--pb-r-uncommon); }
.pb-power[data-ready="warn"] { border-color: var(--pb-r-legendary); color: var(--pb-r-legendary); }
.pb-power[data-ready="bad"]  { border-color: var(--pb-danger); color: #FF8A78; }

/* Difficulty-spike cue, right on the tile (docs/qa/loop-10 — the readiness
   mismatch used to be invisible until a card was selected, so a far
   under-geared level looked identical to a comfortable one while scrolling).
   Opposite corner from the boss skull so the two never collide. */
.pb-card__danger {
  position: absolute; left: 4px; top: 4px; line-height: 1; z-index: 1;
  background: color-mix(in srgb, var(--pb-danger) 82%, black 8%);
  color: #FFE2DB; border: 1.5px solid rgba(255,255,255,.35);
  border-radius: 999px; padding: 2px 6px; font-size: 10px; font-weight: 800;
  letter-spacing: .2px; box-shadow: 0 2px 5px rgba(0,0,0,.5);
  animation: pb-danger-pulse 1.8s ease-in-out infinite;
}
@keyframes pb-danger-pulse {
  0%, 100% { box-shadow: 0 2px 5px rgba(0,0,0,.5), 0 0 0 0 rgba(224,83,63,.55); }
  50%      { box-shadow: 0 2px 5px rgba(0,0,0,.5), 0 0 0 5px rgba(224,83,63,0); }
}
@media (prefers-reduced-motion: reduce) { .pb-card__danger { animation: none; } }

/* Reward preview row (coins/gems this level pays out) — every tile sells the
   trip, not just the ones you've already picked (loop-09/loop-06 critic:
   "no reward preview on the card"). */
.pb-card__reward {
  display: flex; align-items: center; justify-content: center; gap: 3px;
  font-size: 10.5px; color: var(--pb-ink-dim); font-family: var(--pb-font-num);
  margin-top: 1px;
}
.pb-card__reward .pb-icon { margin-left: 3px; }
.pb-card__reward .pb-icon:first-child { margin-left: 0; }

/* Chest card jackpot badge (ui/chestScreen.js) — computed off each chest's
   own live `weights`, never a hardcoded chest id. Opposite corner from the
   cost tag so the two never collide. */
.pb-card__toptier {
  position: absolute; right: 4px; top: 4px; line-height: 1; z-index: 1;
  background: linear-gradient(135deg, var(--pb-r-legendary), var(--pb-r-mythic));
  color: #2A0F1C; border: 1.5px solid rgba(255,255,255,.45);
  border-radius: 999px; padding: 2px 7px; font-size: 9.5px; font-weight: 800;
  letter-spacing: .3px; text-transform: uppercase; box-shadow: 0 2px 5px rgba(0,0,0,.5);
}

/* 4.18 Gear slot summary */
.pb-card--slot { align-items: stretch; }
.pb-card--slot[data-owned="0"] { opacity: .72; }
.pb-badge--new { right: auto; left: -7px; background: var(--pb-r-uncommon); color: #10240A; }

/* 4.19 Level-up offer cards */
.pb-card--offer { align-items: center; text-align: center; padding-top: 14px; }
.pb-card--offer .pb-card__desc { text-align: center; }
.pb-card--offer { animation: pb-card-in .32s cubic-bezier(.2,.9,.3,1.25); }
@keyframes pb-card-in { from { transform: translateY(22px) scale(.9); } to { transform: none; } }

/* ROUND 7 ITEM 21 — the card FILLS its grid row exactly (see §4.14's comment
   on .pb-levelup__cards for the whole mechanism) rather than growing to fit
   its own content, so a long roll's copy can never be what pushes Reroll or
   another card off a short phone. `.pb-card__desc` is the one flexible piece
   inside it — grow to use any slack, but ALSO free to shrink below its own
   content height (min-height:0 overrides the flex default that would
   otherwise refuse to) with overflow hidden as the backstop, so the art icon,
   the WEAPON/PASSIVE tag and the name — the three things a "panicked
   player's scan order" (menu designer) actually reads first — are the pieces
   that are never the ones clipped. */
/* ROUND 13 BUGFIX (docs/qa/menus-upgrade-literacy.md §4.1) — "the EVO row
   paints outside its own card, on every card, at every phone width." Root
   cause: `.pb-card--offer` is a CSS GRID item (`.pb-levelup__cards`), and a
   grid item's default `min-width` is `auto` — the browser refuses to shrink
   it below its own MIN-CONTENT width. `.pb-evo__text` sets
   `white-space: nowrap` (styles.css, the EVO row) so its min-content width
   is its FULL unwrapped text width, which routinely exceeds a 1fr card
   track on a 375-430px phone. The card was overflowing its own grid cell by
   20-29px per side — invisible on desktop (wide tracks, short text never
   hits the limit) and worst on the golden card, because the evolve-ready
   sentence is the longest text on the overlay. `min-width: 0` is the
   standard grid/flex fix: it lets the card (and everything inside it,
   `.pb-card__evo`'s own `min-width: 0` already does its half) shrink to the
   track's actual width, so `.pb-evo__text`'s `overflow:hidden` +
   `text-overflow:ellipsis` (already set) finally has a real box to clip
   against instead of an unconstrained one. Verified: card and EVO-row
   bounding rects now match exactly at 390x844, 375x812 and 430x932. */
.pb-card--offer {
  height: 100%; min-height: 0; min-width: 0; overflow: hidden;
}
.pb-card--offer .pb-card__art,
.pb-card--offer .pb-card__tag,
.pb-card--offer .pb-card__name,
.pb-card--offer .pb-card__step,
.pb-card--offer .pb-card__cost {
  flex-shrink: 0;
}
.pb-card--offer .pb-card__desc {
  flex: 1 1 auto; min-height: 0; overflow: hidden;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 6;
}
.pb-card--offer .pb-card__evo { flex-shrink: 0; }

/* ROUND 12 OWNER DIRECTIVE — "the picture of the weapon choices needs to be
   much bigger so the player can pick even if they didn't read the fine
   print" + "make the text a bit bigger too". The icon becomes the dominant
   element of the card at every width (not only inside the phone-compaction
   band below), up from its previous 40px default; name/desc grow ~15-20%
   off their previous (generic .pb-card__name/.pb-card__desc-inherited)
   sizes of 15px/12.5px. Scoped to `.pb-card--offer` only — gear/level tiles/
   chest reveal reuse `.pb-card__name`/`.pb-card__desc` for their own copy
   and must not inherit a level-up-specific size. */
.pb-card--offer .pb-card__art .pb-icon { width: 56px; height: 56px; }
.pb-card--offer .pb-card__name { font-size: 18px; }
.pb-card--offer .pb-card__desc { font-size: 14.5px; }

/* Phone compaction (OWNER: "on my iPhone 15 Pro Max I can't see all the
   choices during level upgrades and weapon selection" — the upgrade cards
   above ARE the run's weapon-selection screen, so this is the whole fix for
   both). Shorter description (clamped, not truncated — the full text still
   lives in the card's aria-label, ui/hud.js), a smaller icon and tighter
   padding throughout. Measured live at both sizes the plan names, 390x844
   and 430x932 (docs/qa/round7-phonehud.md); applied at the whole phone band
   below rather than pinned to those two exact pixels so nothing between them
   falls through uncompacted. */
@media (max-width: 480px) {
  .pb-levelup { gap: 6px; }
  .pb-levelup__head { gap: 3px; }
  .pb-levelup__head .pb-title { font-size: 18px; }
  .pb-levelup__subtitle { font-size: 11px; }
  /* ROUND 12 — "the weapon choices need to be shrunk down vertically; right
     now they are a bit too vertically tall." The cards used to STRETCH to
     fill whatever height `.pb-levelup`'s flex column had left after the
     heading/loadout strip/reroll button (`grid-auto-rows: 1fr` above +
     `.pb-card--offer { height: 100% }`, §4.19) — on a 932px-tall phone that
     is a lot of leftover height, so three short cards' worth of copy got
     stretched into three TALL columns of mostly padding. Sizing the grid row
     to its own content instead (`auto` rows, `align-content: start` so the
     row is not then stretched back out to fill the container) lets each
     card be exactly as tall as its own (now much bigger) icon + two-line
     description need — the "room to spare" the owner asked for is just
     whatever `.pb-levelup` doesn't use below the cards, left blank. */
  /* `flex: 0 0 auto` here too — the grid container itself still had the base
     rule's `flex: 1 1 auto` (§4.14 above), so even with the ROW sized to
     content (grid-auto-rows/align-content, just above) the CONTAINER kept
     stretching to fill the leftover column height and left its own blank
     space trapped between the cards and Reroll, stranding the button far
     down the screen with a dead gap above it. Sizing the container to its
     content too puts Reroll immediately under the cards, where the leftover
     "room to spare" reads as spare room at the bottom of the screen instead
     of a gap that looks like something broke. */
  /* ROUND 14 tester finding — with the description clamp raised (below) to
     stop cutting sentences mid-word, some data-driven cards (a stacked
     "Now: X. Next: Y" passive-star line especially) run tall enough that a
     fixed `flex: 0 0 auto` row pushed Reroll below the visual viewport on a
     short phone (measured live at 375x730: Reroll bottom 764px against a
     730px viewport). `flex: 1 1 auto` + `overflow-y: auto` makes THIS
     container — not the whole screen — the one that gives: it still sizes
     to its own short content by default (`align-content: start` keeps the
     grid rows from stretching into any leftover space, so nothing changes
     for a normal hand), but a hand tall enough to exceed what's left after
     the pinned heading/strip/Reroll (all `flex-shrink: 0` below) now scrolls
     internally instead of shoving Reroll off the bottom edge — exactly the
     "vertical scroll of the card column, heading and strip pinned" case the
     brief's ergonomics floor explicitly allows. */
  /* ROUND 17 (superseded — see ROUND 28) used `minmax(auto, 1fr)` — an
     'auto' minimum keeps a track from ever shrinking below its content's own
     height, so a hand whose cards' natural content ran taller than the
     leftover flex space forced this container ITSELF taller than what
     `.pb-levelup` had left, and `overflow-y: auto` (removed) turned that
     into the owner's ROUND 28 complaint: a scroll needed just to re-read a
     card's own EVO line. `minmax(0, 1fr)` has no content-based floor at
     all — the single row is always exactly this container's own
     flex-allocated height, full stop — which is what makes it safe to drop
     the scroll entirely: every card's fixed-height budget (§4.19 above) is
     sized to fit inside that, not the other way around. */
  .pb-levelup__cards {
    gap: 5px; grid-auto-rows: minmax(0, 1fr); align-content: stretch; align-items: stretch;
    flex: 1 1 auto; min-height: 0; overflow: hidden;
    /* Real gap above Reroll, not just the `.pb-levelup` flex gap — same
       reviewer-item-12 rationale as before, trimmed further in the ROUND 28
       short-viewport tier below where every pixel is budgeted. */
    margin-bottom: 8px;
  }
  .pb-levelup__head, .pb-loadout-strip, .pb-levelup__ready-note, .pb-levelup [data-testid="btn-reroll"] {
    flex-shrink: 0;
  }
  /* ROUND 14 tester finding — `overflow: hidden` (base rule, §4.19 above)
     zeroes out a grid item's "automatic minimum size" per spec (an element
     with non-visible overflow contributes 0, not its content height, to a
     `grid-auto-rows: auto` track's sizing pass). With the hard desc clamp
     gone (above — the source text is too variable for any fixed clamp to
     survive) the row's real content height depends entirely on that
     contribution, so `overflow: hidden` here was quietly under-sizing every
     row by however tall the desc actually rendered — measured live at
     375x730 as a 3-66px shortfall between a desc's own content height and
     the box it was given, which read as the desc's last line bleeding into
     the cost line below it. `overflow: visible` lets the row measure (and
     grid `align-items: stretch`, default, then apply) its real content
     height; nothing about mobile card layout depends on the clip any more
     now that nothing inside is clamped. */
  /* ROUND 15/18 (superseded — see ROUND 28 below) used to give this a fixed
     `min-height: 428px` floor, sized to the tallest golden card at 375px
     wide. That floor was a FLOOR ONLY — the grid row's `minmax(auto, 1fr)`
     (§4.14) still stretches to fill whatever `.pb-levelup__cards` gets from
     the flex column above it, so on a viewport shorter than ~800px the
     stretched row came out SHORTER than 428px and the card's own natural
     content (icon+tag+name+desc+cost+evo) rendered at its full unclamped
     height regardless — exactly what `min-height` cannot prevent, since it
     sets a lower bound, not a cap. `.pb-levelup__cards`'s `overflow-y: auto`
     (removed just above) was the fallback for that overshoot, which is
     precisely the owner's ROUND 28 complaint: "they roll 10-20% down into
     the reroll menu and you have to scroll down to re-read the Evo recipe."
     ROUND 28 FIX — drop the floor entirely. `height: 100%` (not `auto`)
     makes the card fill EXACTLY whatever the grid row computes (which is
     itself exactly `.pb-levelup__cards`'s own flex-allocated height, now
     that the row's track is `minmax(0, 1fr)` — see §4.14), and the card's
     own children are re-budgeted below (desc `-webkit-line-clamp`'ed rather
     than left `overflow: visible`/block) so that fixed sum always fits
     inside whatever height a real phone hands it, tall or short, with
     nothing left over to scroll. */
  .pb-card--offer { height: 100%; min-height: 0; padding: 7px 5px 6px; gap: 4px; overflow: hidden; }
  .pb-card--offer .pb-card__art { margin-bottom: 0; }
  /* "The picture of the weapon choices needs to be much bigger so the player
     can pick even if they didn't read the fine print" — 68px measured live
     at 430x932 (docs/qa/round12 verification below), well past the owner's
     64px floor and now the single biggest element on the card, up from 28px. */
  .pb-card--offer .pb-card__art .pb-icon { width: 68px; height: 68px; }
  .pb-card--offer .pb-card__tag { font-size: 9px; padding: 1px 6px; }
  /* "Make the text a bit bigger too" — name/desc ~15-20% over their previous
     phone sizes (12.5->15, 10->11.5). Description is capped at two lines
     everywhere in this band (was 4, then 3 on a short phone below) per the
     owner's ask — the icon carries the pick now, this is just enough copy to
     confirm it, not a wall of fine print to read under pressure. */
  .pb-card--offer .pb-card__name { font-size: 15px; line-height: 1.15; }
  .pb-card--offer .pb-card__step { font-size: 10.5px; }
  /* ROUND 14 (superseded — see ROUND 28) removed mobile's line-clamp outright
     because SOME card's desc always outgrows any fixed line count, and let
     `.pb-levelup__cards` scroll to the excess instead. ROUND 28 FIX: that
     scroll is exactly the owner's complaint ("you have to scroll down to
     re-read the Evo recipe") — a clamp that truncates a rare long sentence
     with an ellipsis is the lesser defect next to a card whose own EVO row
     reads as pushed off-screen on EVERY hand. `-webkit-line-clamp: 4` caps
     the desc at a known, fixed height (`flex: 0 0 auto` below still means
     this box is exactly that height, never more, never less — same
     shrink-proofing rationale as before, just with a real ceiling now
     instead of an unclamped one) so the sum of every fixed piece on the
     card (icon+tag+name+step+this+cost+evo) is a number that can be, and at
     every one of the five viewports docs/qa/round28/levelup-fit.md verifies
     against, IS, budgeted to fit inside whatever height the card actually
     gets — nothing left over to bleed onto Reroll or hide behind a scroll. */
  .pb-card--offer .pb-card__desc {
    font-size: 11.5px; line-height: 1.22;
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4;
    overflow: hidden; min-height: 0;
    flex: 0 0 auto;
  }
  .pb-card--offer .pb-card__cost { font-size: 9px; margin-top: 2px; padding-top: 4px; border-top: 1px solid rgba(242,232,208,.14); }
  .pb-card--offer .pb-card__evo { font-size: 8.5px; padding-top: 4px; gap: 3px; }
  .pb-card--offer .pb-card__evo .pb-evo__tag { font-size: 7.5px; padding: 1px 4px; }
  .pb-card--offer .pb-card__evo .pb-evo__icon { width: 12px; height: 12px; }
  /* ROUND 28 — a defensive ceiling, not a normal-case control. evoRow()
     (ui/hud.js) builds this sentence from live weapon/passive NAMES, which
     this file has no fixed length for (a two-partner passive's "either X or
     Y" phrasing runs longest); §4.19's card sizing above budgets for the
     common 1-3 line case, and without a cap here a name long enough to wrap
     further could still push past the card's own bottom edge the same way
     `.pb-card__desc`'s clamp already prevents for the description. 4 lines
     matches the desc cap just above; the short-viewport tier below tightens
     it further to match that tier's own smaller budget. */
  .pb-card--offer .pb-card__evo .pb-evo__text {
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; overflow: hidden;
  }
  .pb-card--offer[data-offer-type^="passive"]::before { font-size: 7.5px; padding: 2px 5px; top: 5px; left: 5px; }
  /* ROUND 14 tester finding — 44px cleared the brief's own 44px floor but
     missed docs/ARCHITECTURE.md §12's stricter site-wide minimum, `--pb-tap`
     (52px, 48px under 380px). Every other `.pb-btn` on the site already
     honours that token (§4.2 above); Reroll was the one primary button that
     had been pinned to a smaller literal px value here and never caught up. */
  .pb-levelup [data-testid="btn-reroll"] { min-height: 52px; font-size: 14px; padding: 6px 14px; }
}
@media (max-width: 380px) {
  .pb-levelup [data-testid="btn-reroll"] { min-height: 48px; }
}
/* ROUND 28 (owner, docs/qa/round28/levelup-fit.md) — a shorter phone
   (390x664, 375x667, 375x548 in the brief's own five-viewport list) has
   less to give than the 700-800px band below spends on a bigger icon and a
   4th desc line, so this tier trims every piece ABOVE and INSIDE a card
   instead: the loadout strip's own legend (`.pb-loadout-strip__caption`,
   styles.css §4.19d's own short-height rules) drops out entirely — the
   strip's tiles and their READY/PICK/EVO tags still teach the same three
   signals without it — and the card spends what that frees on a smaller
   icon (still clear of the 44px floor), a tighter internal gap, and one
   fewer clamped desc line so icon+tag+name+desc+cost+evo keeps fitting the
   smaller row this tier's own height leaves for it. */
@media (max-width: 480px) and (max-height: 700px) {
  .pb-levelup { gap: 4px; }
  .pb-levelup__cards { margin-bottom: 6px; }
  .pb-card--offer { padding: 6px 5px 5px; gap: 3px; }
  .pb-card--offer .pb-card__art .pb-icon { width: 52px; height: 52px; }
  /* 2 lines, not 3 — measured live at 375x548: a card whose EVO sentence
     happens to name a longer partner (data-driven, e.g. "Cheek Pouch")
     wraps to 4 lines instead of the usual 2-3, and at 3 desc lines that
     pushed the last word of the EVO row 4.7px past the card's own bottom
     edge (clipped by `overflow: hidden`, §4.19 above). Trading one desc
     line for that margin, plus the `.pb-evo__text` clamp just below as a
     second backstop, keeps the EVO row — the thing this whole round is
     about — intact for every offer the pool can roll, not just the common
     ones this round happened to test with. */
  .pb-card--offer .pb-card__desc { font-size: 12px; line-height: 1.2; -webkit-line-clamp: 2; }
  .pb-card--offer .pb-card__evo { padding-top: 3px; }
  .pb-card--offer .pb-card__evo .pb-evo__text { -webkit-line-clamp: 3; }
}

/* ROUND 15/18 (superseded — see ROUND 28 note on §4.19's `.pb-card--offer`
   above) used a `min-height: max(30vh, 428px)` FLOOR here to spend a tall
   phone's own leftover space on the cards rather than leaving it blank below
   them. §4.14's `.pb-levelup__cards` row is now `minmax(0, 1fr)` — no
   content floor at all, just "fill whatever this container has" — so a
   tall phone (390x844, 430x932: both clear 800px) already gets a tall row
   for free, no explicit min-height needed to claim it; this tier now only
   spends that extra height on bigger type, same as before. */
@media (max-width: 480px) and (min-height: 800px) {
  .pb-card--offer { padding: 12px 7px 10px; gap: 7px; }
  .pb-card--offer .pb-card__art .pb-icon { width: 76px; height: 76px; }
  .pb-card--offer .pb-card__desc { font-size: 12.5px; line-height: 1.28; -webkit-line-clamp: 6; }
}

/* 4.19b PASSIVE vs WEAPON clarity (docs/qa/USER-FEEDBACK.md item 4).
   "Whittling Knife" draws an actual bladed-knife icon (correct — it IS a
   whittling knife in the fiction) that out-reads the small caption saying
   this is not a weapon you swing. Two independent, redundant signals fix
   the comprehension gap without touching the flavour art: a pill-style tag
   (not just amber text sitting on the card background) so the WEAPON/PASSIVE
   word itself is easier to spot, plus a persistent corner ribbon on every
   passive card that reads PASSIVE regardless of whether the tag text below
   the icon gets read at all. Scoped to `--offer` + `data-offer-type` (level-up
   cards only) so gear/level-select/chest screens, which reuse .pb-card__tag
   for unrelated captions, are untouched. */
.pb-card--offer .pb-card__tag {
  padding: 2px 8px; border-radius: 999px; background: rgba(8,11,6,.4);
}
.pb-card--offer[data-offer-type^="passive"] {
  border-color: color-mix(in srgb, #8A6FD8 55%, var(--pb-line));
}
/* ROUND 14 tester finding — this ribbon used to be var(--pb-amber), the
   exact colour of "ready to evolve" (.pb-card--golden / the EVO row's
   armed/ready gold, both above). A Survivor.io thumb reads any gold pill on
   a card as "this one is special," so a plain passive-new card wearing gold
   got misread as evolve-ready. Purple instead — the same accent the
   passive-card border already uses a few lines up, so the ribbon reads as
   "this IS a passive," not "this is about to evolve." */
.pb-card--offer[data-offer-type^="passive"]::before {
  content: 'PASSIVE'; position: absolute; top: 8px; left: 8px; z-index: 2;
  font-size: 9px; font-weight: 800; letter-spacing: .5px; line-height: 1;
  color: #F2E8D0; background: #8A6FD8;
  padding: 3px 6px; border-radius: 999px; box-shadow: 0 1px 3px rgba(0,0,0,.5);
}

/* 4.19a The commit beat.
   Clicking a card used to report the pick instantly, so a banked second
   level-up re-opened the overlay one frame later and the whole thing read as a
   modal that would not close (loop-09 tester). The choice now visibly RESOLVES:
   the taken card flares and swells, the two rejects fall away, and only then
   does the next set deal in. ~240ms — long enough to register, short enough
   that it never feels like waiting. See ui/hud.js mountLevelUp(). */
.pb-levelup__head {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
}
/* ROUND 13 (docs/qa/menus-upgrade-literacy.md §2.1) — the one-line teach
   under the locked "Weapon Stage N!" heading: this is the 1-of-3 in-run
   pick for weapons AND passives, this run only, not the next campaign
   Level. Static; never rewritten per hand, so no animation needed. */
.pb-levelup__subtitle {
  margin: -2px 0 0; font-size: 12.5px; color: var(--pb-ink-dim);
  text-align: center; max-width: 340px; line-height: 1.3;
}
/* ROUND 17 FIX (reviewer item 1) — the one line that answers "what is
   evolving?" for a player who never draws a golden card this hand. Quieter
   than the subtitle (smaller, more muted) since it is a standing rule, not
   this hand's own instruction, but still on one line at 390px wide. */
.pb-levelup__evolve-rule {
  margin: 0; font-size: 9.5px; color: var(--pb-ink-dim);
  text-align: center; max-width: 360px; line-height: 1.3;
  opacity: .85;
}
/* ROUND 17 FIX (reviewer item 2, defect 3) — the "recipe complete" callout.
   Amber, not neutral: unlike the evolve-rule line above it is actionable
   RIGHT NOW (go find a Heartwood Bloom or Golden Acorn Chest), so it reads
   with the same urgency the strip's own READY tag and the HUD rack's
   pb-card--golden pulse already carry. */
.pb-levelup__ready-note {
  margin: 0; font-size: 12px; font-weight: 700; color: var(--pb-amber);
  text-align: center; max-width: 360px; line-height: 1.3;
}
/* ROUND 17 FIX (reviewer item 11) — levelup-combo is gone (see ui/hud.js);
   the queue chip is now the only thing in this row, so it no longer needs
   to plan for a side-by-side neighbour. Left as a row (not removed) since a
   future second chip may still want the same centring. */
.pb-levelup__chips {
  display: flex; flex-direction: row; flex-wrap: wrap;
  justify-content: center; align-items: center; gap: 8px;
}
.pb-levelup__queue {
  animation: pb-queue-in .5s cubic-bezier(.2,.9,.3,1.3) backwards;
  border-color: color-mix(in srgb, var(--pb-amber) 55%, transparent);
  color: var(--pb-amber); font-weight: 700; letter-spacing: .4px;
}
@keyframes pb-queue-in {
  from { transform: translateY(-8px) scale(.8); opacity: 0; }
  to   { transform: none; opacity: 1; }
}
.pb-card--picked {
  animation: pb-card-picked .24s cubic-bezier(.2,.7,.3,1) forwards !important;
  pointer-events: none; z-index: 2;
}
@keyframes pb-card-picked {
  0%   { transform: none; }
  40%  { transform: scale(1.09); box-shadow: 0 0 0 3px var(--pb-amber), 0 0 34px var(--pb-amber); }
  100% { transform: scale(1.03); box-shadow: 0 0 0 2px var(--pb-amber), 0 0 20px color-mix(in srgb, var(--pb-amber) 60%, transparent); }
}
.pb-card--dropped {
  animation: pb-card-dropped .22s ease-in forwards !important;
  pointer-events: none;
}
@keyframes pb-card-dropped {
  to { transform: translateY(16px) scale(.9); opacity: 0; }
}

/* 4.19b THE MULTI-PICK RUN (loop-12 critic: "level-up modals fire roughly
   every 5-10 real seconds, each a full-screen interrupt with no batching").
   Banked level-ups now resolve inside ONE overlay (ui/hud.js redeal()), so the
   overlay has to sell the re-deal as a fresh hand rather than as a repaint.
   `pb-levelup__cards--in` is re-applied per hand and staggers the cards in;
   the combo pip counts the run of picks the player is on. */
.pb-levelup__cards--in > .pb-card--offer {
  animation: pb-card-in .34s cubic-bezier(.2,.9,.3,1.25) backwards;
}
/* 4.19c THE END BEAT (loop-12 tester, MAJOR — "HUD displayed negative HP while
   gameplay continued"). systems/run.js endRun() freezes the sim and main.js
   holds the result screen back ~900ms so the death lands; the canvas keeps
   presenting the last frame under a live-looking HUD for all of it, which is
   what got read as a softlock. State the outcome the instant it is decided. */
.pb-endbeat {
  position: absolute; inset: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 6px;
  pointer-events: none; z-index: 40; text-align: center;
  animation: pb-endbeat-in .42s cubic-bezier(.15,.8,.3,1) backwards;
}
.pb-endbeat::before {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(120% 90% at 50% 50%, transparent 28%, rgba(8,11,6,.86) 100%);
}
.pb-endbeat__word {
  position: relative; font-size: clamp(38px, 13vw, 76px); font-weight: 900;
  letter-spacing: 6px; line-height: 1;
  text-shadow: 0 4px 26px rgba(0,0,0,.9);
}
.pb-endbeat__sub {
  position: relative; font-size: 13px; letter-spacing: 1.4px;
  color: var(--pb-ink-dim, #B7C4A3); text-shadow: 0 2px 10px rgba(0,0,0,.9);
}
.pb-endbeat--lost .pb-endbeat__word { color: #FF8A78; }
.pb-endbeat--won  .pb-endbeat__word { color: var(--pb-amber); }
@keyframes pb-endbeat-in {
  from { opacity: 0; letter-spacing: 22px; }
  to   { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .pb-card--picked, .pb-card--dropped, .pb-levelup__queue,
  .pb-endbeat, .pb-levelup__cards--in > .pb-card--offer,
  .pb-loadout-strip__slot--hot, .pb-loadout-strip__slot--last,
  .pb-loadout-strip__slot--star {
    animation-duration: .01ms !important;
  }
}

/* 4.19d THE LEVEL-UP LOADOUT STRIP (round 6 owner directive: "it needs to
   show what slots you have filled right now and what level each slot is, so
   you can see how many empty slots you have left, and you should be able to
   see when a passive offered to you may evolve something you have slotted
   already"). All 6 weapon + 6 passive slots, drawn every hand — filled ones
   included — using the in-run HUD rack's own visual language (§4.12: icon +
   star/level + rarity border on a filled slot, a dashed outline on an empty
   one) so a player who already reads that rack mid-fight reads this one on
   sight. It sits as a normal flex sibling between the heading and the three
   cards (ui/hud.js mountLevelUp) — never absolutely positioned, so it can
   only ever push the cards down, not cover them — and every dimension below
   is tuned so that push stays small even on a 360-375px phone (verified live,
   docs/qa/round6-levelup-ui.md). */
.pb-loadout-strip {
  display: flex; flex-direction: column; gap: 5px;
  padding: 7px 10px; border-radius: 10px;
  background: rgba(10,14,7,.55); border: 1.5px solid var(--pb-line);
  width: 100%; max-width: 560px; margin: 0 auto; flex: 0 0 auto;
}
/* ROUND 13 (docs/qa/menus-upgrade-literacy.md §2.5) — the strip is now the
   legend the cards point at: one caption for the whole strip, then each
   row's own label moved from the row's LEFT EDGE onto its own full-width
   line above its slots. Measured live at 375x812: the old side label ate
   70px + 8px gap, leaving 253px for six 52px weapon tiles that need 332px —
   slot 6 wrapped onto a second line in BOTH rows and the strip stood 214px
   tall, 26% of the viewport, above the cards. A full-width label gives the
   tiles the whole 331px, six across on one line, with no tile shrunk. */
.pb-loadout-strip__caption {
  font-size: 9.5px; font-weight: 700; letter-spacing: .3px;
  color: rgba(242,232,208,.62); line-height: 1.25;
}
.pb-loadout-strip__row { display: flex; flex-direction: column; gap: 4px; }
.pb-loadout-strip__label {
  flex: 0 0 auto; display: flex; align-items: center; gap: 5px;
  font-size: 10px; font-weight: 800; letter-spacing: 1px;
  color: rgba(242,232,208,.75); width: 100%;
}
/* ROUND 18 FIX (reviewer item 3): every bottom-center tile chip (hotTag/EVO,
   starTag/PICK, readyTag/READY, lastTag/LAST — all four share the identical
   `bottom: -9px` position below) is absolutely positioned, so it takes no
   layout space of its own — the row below used to start right after the
   TILE's 48/52px box, 3-4px past where the tile's own bottom edge sits,
   which is well short of where a -9px chip's own bottom edge lands. This
   reserves that clearance as real layout height on the row itself (not the
   next row's problem to solve) so no chip — on the WEAPONS row, into the
   PASSIVES row's label; on the PASSIVES row, into whatever the strip's own
   bottom padding doesn't already cover — can ever reach a sibling row. */
.pb-loadout-strip__slots { display: flex; gap: 4px; flex-wrap: wrap; flex: 1 1 auto; padding-bottom: 10px; }
/* ROUND 12 OWNER DIRECTIVE — "the weapons and passives that the character
   currently has need to be much bigger... clearly readable... rarity color
   visible." Slot + icon sizes roughly doubled from their pre-round12 values
   (26/22px slots, 15/11px icons — see docs/qa/backups/2026-09-02-before-
   round12/styles.css for the exact prior numbers); the small corner badges
   (star/level count, MAIN/EVO/LAST tags below) scale up with them so nothing
   reads as a tiny leftover pinned to an oversized tile. */
.pb-loadout-strip__slot {
  position: relative; flex: 0 0 auto;
  width: 52px; height: 52px; border-radius: 14px; display: grid; place-items: center;
  background: rgba(14,19,9,.72); border: 2px solid var(--pb-line);
}
/* ROUND 16 FIX (reviewer item 6): a tester saw six tiles ringed six
   different colours (gold MAIN inset, blue PICK, teal EVO, red-dashed LAST,
   plus a full rarity-coloured border on every filled weapon tile) against
   one legend entry (the strip caption, item 5). The rarity border becomes a
   thin leading-edge stripe here — same demotion the offer cards already got
   (.pb-card--offer[class*="pb-r-"] above) — so the only full RINGS left on
   this screen are the three the caption actually names: dashed empty, blue
   PICK, teal EVO. Scoped to weapon tiles only — passive tiles never carry a
   pb-r-* class (buildLoadoutStrip only rarity-classes weapon slots). */
.pb-loadout-strip__slot[class*="pb-r-"] { border-color: var(--pb-line); }
.pb-loadout-strip__slot[class*="pb-r-"]::before {
  content: ''; position: absolute; left: 2px; top: 6px; bottom: 6px; width: 3px;
  border-radius: 2px; background: var(--pb-r, transparent); pointer-events: none;
}
.pb-loadout-strip__slot--passive { border-radius: 999px; width: 44px; height: 44px; }
.pb-loadout-strip__slot .pb-icon { width: 30px; height: 30px; }
.pb-loadout-strip__slot--passive .pb-icon { width: 22px; height: 22px; }
.pb-loadout-strip__slot .pb-stars,
.pb-loadout-strip__slot .pb-chip__num {
  position: absolute; bottom: -5px; right: -3px; font-size: 13px; line-height: 1;
  text-shadow: 0 1px 2px rgba(0,0,0,.9);
}
/* ROUND 17 FIX (reviewer item 7) — same "Rank N" single form as the HUD
   rack's passive tile above; the 44px strip tile has a little more room so
   the shrink is smaller (10px vs. the rack's 6.5px). */
.pb-loadout-strip__slot .pb-chip__num--rank {
  font-size: 10px; right: -6px;
}
.pb-loadout-strip__slot--empty {
  background: rgba(8,11,6,.4); border-style: dashed; border-color: rgba(242,232,208,.28);
}
.pb-loadout-strip__slot--empty .pb-slot__plus { font-size: 20px; }

/* Main weapon — RUN_SLOTS.mainWeapon, WeaponInstance.isMain: the one slot the
   run always started with. A quiet inset amber ring plus a tiny corner tag,
   not a loud treatment — it is a permanent fact about the slot, not an event. */
.pb-loadout-strip__slot--main { box-shadow: 0 0 0 1.5px rgba(255,217,138,.55) inset; }
/* ROUND 16 FIX (reviewer item 4): -8px used to poke this tag up past the
   slot's own top edge and into the 4px row gap, reaching the WEAPONS row
   label's own line above it. -1px keeps the badge sitting ON the tile's
   corner (still clearly a corner badge, not flush inside it) without
   crossing into the label's line. */
.pb-loadout-strip__mainTag {
  position: absolute; top: -1px; left: -1px; line-height: 1;
  font-size: 9px; font-weight: 900; letter-spacing: .2px;
  padding: 2px 4px; border-radius: 5px;
  background: linear-gradient(180deg, #FFDD9A, #E0A63C); color: #241802;
  border: 1px solid rgba(255,240,200,.9); box-shadow: 0 0 6px rgba(255,209,120,.7);
}

/* THE EVO CROSS-HIGHLIGHT's loadout-strip half — see .pb-card__evo--cross
   above for the card-side twin and why this is teal, not the amber the HUD
   rack already uses for "this weapon itself is one step from evolving"
   (.pb-hud__slot.pb-card--golden / .pb-slot__evo[data-evo="armed"/"ready"]).
   Reusing amber here would make two different claims look like one signal. */
.pb-loadout-strip__slot--hot {
  border-color: var(--pb-sky) !important;
  animation: pb-loadout-hot 1.1s ease-in-out infinite;
  /* ROUND 6 TWITCH MINOR #9 — "I could not see the highlight in my own
     800x450 screenshots... either make the strip 2-3x larger during a
     cross-highlight, or animate it." A 2px border-colour change on a 26px
     slot is a few pixels of colour shift once downscaled for a 720p/480p
     stream. `transform` doesn't touch layout (siblings don't reflow), so
     this pops the hot slot visibly above its neighbours without shoving the
     rest of the strip around.
     ROUND 13 CORRECTION (docs/qa/menus-upgrade-literacy.md §3) — the prior
     scale(1.55) measured live at 375x812 inflated a 48px tile to 75x75px,
     covering its own row's PASSIVES 1/6 label by 9px, the neighbouring tile
     by 10px and the wrapped 6th tile by 48x10px with the EVO tag painted
     across it — the signal was eating the legend it points at. 1.15 plus a
     solid ring (the box-shadow's `0 0 0 Npx` spread, not just blur) keeps
     the pop without the collision; the strip label move to its own line
     (§2.5, above) also means there is no longer a wrapped row for it to
     cover. Display-only — strip tiles take no taps, so this was always a
     legibility fix, never a dead-tap fix. */
  position: relative; z-index: 3; transform: scale(1.15);
}
@keyframes pb-loadout-hot {
  0%, 100% { box-shadow: 0 0 0 2px var(--pb-sky), 0 0 7px rgba(79,184,154,.55); }
  50%      { box-shadow: 0 0 0 3px var(--pb-sky), 0 0 20px rgba(79,184,154,1); }
}
/* The text anchor the --hot glow was missing — same pattern as
   .pb-loadout-strip__lastTag just below, teal instead of warm-red so the two
   callouts never read as the same signal (see the composition-rule comment
   above .pb-loadout-strip__slot--hot). */
.pb-loadout-strip__hotTag {
  position: absolute; bottom: -9px; left: 50%; transform: translateX(-50%);
  font-size: 8px; font-weight: 900; letter-spacing: .2px; line-height: 1;
  padding: 1px 3px; border-radius: 3px; white-space: nowrap;
  background: var(--pb-sky); color: #04201A;
}

/* THE STAR-UP POINTER (ROUND 14 tester finding — "the loadout strip never
   points at the tile a STAR UP card would change"). A third, distinct claim
   from the two above: not "this is the OTHER half of a recipe" (teal --hot)
   and not "evolution is one pick away" (gold, .pb-card--golden) but "one of
   the cards on the table right now grows THIS tile you already hold." Blue,
   so all three read as different signals at a glance. Never applied to a
   slot that is also --hot — an evolve-partner claim always outranks a plain
   star-up on the same tile (ui/hud.js buildLoadoutStrip). */
.pb-loadout-strip__slot--star {
  border-color: #6FA8FF !important;
  animation: pb-loadout-star 1.1s ease-in-out infinite;
  position: relative; z-index: 3;
}
@keyframes pb-loadout-star {
  0%, 100% { box-shadow: 0 0 0 2px #6FA8FF, 0 0 7px rgba(111,168,255,.5); }
  50%      { box-shadow: 0 0 0 3px #6FA8FF, 0 0 16px rgba(111,168,255,.9); }
}
.pb-loadout-strip__starTag {
  position: absolute; bottom: -9px; left: 50%; transform: translateX(-50%);
  font-size: 8px; font-weight: 900; letter-spacing: .2px; line-height: 1;
  padding: 1px 3px; border-radius: 3px; white-space: nowrap;
  background: #6FA8FF; color: #071A33;
}
/* ROUND 15 FIX (reviewer-measured 8.7x6px overlap): the PICK chip above sits
   centred under the tile, which crosses paths with the tile's own star count
   (.pb-stars / .pb-chip__num, bottom-right corner) on exactly the tile this
   card's whole claim is about — the pointer was eating the from-value it
   points at. Move the star count to the top-right corner, clear of the
   bottom chip and clear of --main's top-left MAIN tag.
   ROUND 18 FIX (reviewer item 3): this only ever covered --star. --hot's EVO
   pointer and --evoready's READY pill sit at the exact same `bottom: -9px`
   spot (starTag/hotTag/readyTag/lastTag all share it) and so hit the exact
   same collision, unfixed — measured live, the READY pill covering both the
   weapon's own ★ count AND the PASSIVES row label below it, and the EVO
   pointer on a held passive covering its own Rank N count. Same claim
   (a bottom chip needs the count badge out of its way), same fix, now
   shared by all three states instead of --star alone.
   `top` also tightened from -6px to -1px here (reviewer item 3, continued):
   -6px was already poking a couple px into the 4px row-label gap above (a
   latent, sub-pixel case of the exact MAIN-tag-vs-label bug ROUND 16 fixed
   for the opposite corner — never reproduced before because --star was the
   only state that ever moved a badge up here); extending this rule to --hot
   and --evoready made it land on the wider "Rank N" badge too, a measured
   5px real overlap with the PASSIVES label. -1px matches the clearance
   `.pb-loadout-strip__mainTag` already uses for the same 4px gap. */
.pb-loadout-strip__slot--star .pb-stars,
.pb-loadout-strip__slot--star .pb-chip__num,
.pb-loadout-strip__slot--hot .pb-stars,
.pb-loadout-strip__slot--hot .pb-chip__num,
.pb-loadout-strip__slot--evoready .pb-stars,
.pb-loadout-strip__slot--evoready .pb-chip__num {
  bottom: auto; top: -1px; right: -3px;
}
/* ROUND 17 FIX (reviewer item 4, reproduced live: 5.7x8.0px overlap at all
   four viewports) — the move above lands the star count top-right, which is
   clear of --main's MAIN tag (top-LEFT) on almost every tile, EXCEPT the one
   tile that is BOTH --main and --star at once: the run's starting main
   weapon on the very first Weapon Stage of every new game, where it is also
   this hand's STAR UP target. MAIN's own width (31.7px measured, on a 48px
   tile) reaches far enough right to clip the star badge's left edge. Shrink
   MAIN specifically for that one combination — the far more common --main-
   only and --star-only tiles are untouched.
   ROUND 18 FIX (reviewer item 3): --main also combines with --hot and, most
   visibly, with --evoready — the run's starting main weapon is exactly the
   one every evolution recipe finishes on, so "main weapon, fully armed" is
   not a rare pairing. Same MAIN-width-vs-badge clash, same shrink, extended
   to both. */
.pb-loadout-strip__slot--main.pb-loadout-strip__slot--star .pb-loadout-strip__mainTag,
.pb-loadout-strip__slot--main.pb-loadout-strip__slot--hot .pb-loadout-strip__mainTag,
.pb-loadout-strip__slot--main.pb-loadout-strip__slot--evoready .pb-loadout-strip__mainTag {
  font-size: 7px; padding: 1px 3px; letter-spacing: 0;
}
.pb-loadout-strip__slot--main.pb-loadout-strip__slot--star .pb-stars,
.pb-loadout-strip__slot--main.pb-loadout-strip__slot--star .pb-chip__num,
.pb-loadout-strip__slot--main.pb-loadout-strip__slot--hot .pb-stars,
.pb-loadout-strip__slot--main.pb-loadout-strip__slot--hot .pb-chip__num,
.pb-loadout-strip__slot--main.pb-loadout-strip__slot--evoready .pb-stars,
.pb-loadout-strip__slot--main.pb-loadout-strip__slot--evoready .pb-chip__num {
  right: -5px;
}

/* ROUND 17 FIX (reviewer item 2, defect 3) — THE EVO-READY STRIP TAG. A
   weapon whose recipe is fully complete (run.scratch.evolutionReady) gets
   its own amber-gold treatment on the loadout strip — same colour language
   as the HUD rack's pb-card--golden pulse and the level-up card's own
   golden frame, so "this is one grant away" reads as the SAME claim on
   every screen it appears. Outranks --hot/--star (ui/hud.js
   buildLoadoutStrip) since a completed recipe is a louder fact than an
   in-progress one. */
.pb-loadout-strip__slot--evoready {
  border-color: var(--pb-amber) !important;
  animation: pb-slot-ready 1.1s ease-in-out infinite;
  position: relative; z-index: 3;
}
.pb-loadout-strip__readyTag {
  position: absolute; bottom: -9px; left: 50%; transform: translateX(-50%);
  font-size: 8px; font-weight: 900; letter-spacing: .2px; line-height: 1;
  padding: 1px 3px; border-radius: 3px; white-space: nowrap;
  background: var(--pb-amber); color: #241802;
}

/* THE LAST-SLOT CALLOUT: the one empty slot left in a row, when at least one
   of the three offered cards is the `*-new` type that would spend it. A
   pulsing warm-red ring on the slot itself (visible even before a player
   reads any label) plus a tiny "LAST" ribbon, and — since a 21px slot ribbon
   is a lot to ask a thumb-scrolling player to notice — a matching "LAST SLOT"
   chip up on the row label too. */
.pb-loadout-strip__slot--last {
  border-color: #FF9E7A !important; animation: pb-loadout-last 1.1s ease-in-out infinite;
}
@keyframes pb-loadout-last {
  0%, 100% { box-shadow: 0 0 6px rgba(255,120,80,.5); }
  50%      { box-shadow: 0 0 15px rgba(255,120,80,.9); }
}
.pb-loadout-strip__lastTag {
  position: absolute; bottom: -9px; left: 50%; transform: translateX(-50%);
  font-size: 8px; font-weight: 900; letter-spacing: .2px; line-height: 1;
  padding: 1px 3px; border-radius: 3px; white-space: nowrap;
  background: #FF7A5C; color: #2A0A04;
}
.pb-loadout-strip__count--last { color: #FF9E7A; }
.pb-loadout-strip__lastChip {
  font-size: 8.5px; font-weight: 900; letter-spacing: .3px; white-space: nowrap;
  color: #FF9E7A; background: rgba(255,122,92,.16);
  border: 1px solid rgba(255,158,122,.5); border-radius: 999px; padding: 1px 5px;
}

/* MOBILE: compress, don't clip. The level-up screen (.pb-screen) is
   overflow:hidden by layout law (§4.1) — nothing here may push the reroll
   button or the cards themselves off the bottom edge on a short phone
   viewport, so every dimension shrinks together rather than wrapping onto a
   third row. flex-wrap on .pb-loadout-strip__slots (above) is the fallback
   if a row ever still doesn't fit at these sizes (e.g. a very narrow foldable
   in portrait) — 12 slots' worth of gap+width still clears 320px here. */
@media (max-width: 480px) {
  .pb-loadout-strip { padding: 5px 8px; gap: 3px; }
  .pb-loadout-strip__caption { font-size: 8.5px; }
  .pb-loadout-strip__row { gap: 3px; }
  .pb-loadout-strip__label { font-size: 9px; }
  .pb-loadout-strip__slots { gap: 3px; }
  /* ROUND 7 ITEM 21 — the plan's own acceptance number: "the loadout strip
     (slots >= 24px)". This used to shrink to 20/17px here, which is what a
     390-430px phone actually got (both target widths fall in this query) —
     under the floor on every device the item was written for. 24px on both
     shapes; the previously-larger weapon/passive size difference collapses
     to make that floor unambiguous rather than shaving the smaller one back
     under 24 to preserve a size gap. */
  /* ROUND 12 — doubled again from the 24px phone floor round 7 set (comment
     above), to match the owner's "roughly double" ask for the base sizes. */
  .pb-loadout-strip__slot { width: 48px; height: 48px; border-radius: 12px; }
  .pb-loadout-strip__slot--passive { width: 48px; height: 48px; }
  .pb-loadout-strip__slot .pb-icon { width: 28px; height: 28px; }
  .pb-loadout-strip__slot--passive .pb-icon { width: 22px; height: 22px; }
  .pb-loadout-strip__lastChip { display: none; } /* the pulsing slot ribbon still carries the message */
}
/* Very short viewports (landscape phones, older small devices) — padding and
   gaps still compress here, but slot SIZE no longer does: shrinking it
   further is exactly the >=24px floor above, and the level-up cards
   themselves no longer need the height back (they fill whatever `flex:1`
   space is left — §4.14 — rather than pushing the strip to shrink for room),
   so there is nothing left this used to buy.
   ROUND 28 (owner, docs/qa/round28/levelup-fit.md) — the caption ("LOADOUT —
   + is empty... READY is a weapon whose recipe is complete.") is the one
   piece of the strip that DOES still cost real height here (two lines at
   this width) for a legend the tiles' own PICK/EVO/READY tags already spell
   out one at a time; dropped entirely on a short phone so that height goes
   to the cards instead, where the brief's own "roll into the reroll menu"
   complaint actually lives. */
@media (max-height: 700px) {
  .pb-loadout-strip { padding: 4px 8px; gap: 2px; }
  .pb-loadout-strip__caption { display: none; }
}
@media (max-height: 660px) {
  .pb-loadout-strip { padding: 3px 7px; gap: 1px; }
  .pb-loadout-strip__row { gap: 4px; }
}

/* =====================================================================
   4.19d "MORE BELOW" SCROLL AFFORDANCE (loop-12 critic, gear screen).
   A fade edge on the scroll container plus a tappable chevron, both driven
   from real scroll metrics by ui/gearScreen.js syncScrollCue() — they only
   appear while there IS unseen content, and vanish at the end of the list.
   ===================================================================== */
/* A flex:1 wrapper around a `.pb-scrollable` body PLUS its `.pb-scrollcue`,
   for any screen (ui/menus.js mountResult) where the scrolling region is
   NOT the last flex child — i.e. a `.pb-footer` still follows it. The cue is
   `position:absolute`, so it needs a positioned ancestor whose box exactly
   matches the body's own visible viewport; putting that ancestor role on the
   body element itself would work only when nothing else shares its parent,
   which breaks the instant a footer is a sibling (the cue would then anchor
   to the footer's edge, not the body's). ui/gearScreen.js never needed this
   because that screen has no footer at all. */
.pb-scroll-wrap { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; position: relative; }
.pb-scrollable { position: relative; }
.pb-scrollable--more {
  -webkit-mask-image: linear-gradient(180deg, #000 0, #000 calc(100% - 34px), transparent 100%);
          mask-image: linear-gradient(180deg, #000 0, #000 calc(100% - 34px), transparent 100%);
}
/* ROUND 14 tester finding — `bottom: 10px` was a literal offset, not
   safe-area aware, so its rect landed inside the brief's forbidden bottom
   band (env(safe-area-inset-bottom) + ~50px, the `--pb-band` token defined
   at :root above) on a real iPhone's Safari toolbar; measured live at
   834/844 in emulation where --pb-sab reads 0. `--pb-band` already bakes in
   the +50px floor even when the safe-area inset itself is 0, so anchoring to
   it (plus this control's own 8px clearance) clears the band on both a
   notched device and this emulator alike. */
.pb-scrollcue {
  position: absolute; left: 50%; bottom: calc(var(--pb-band) + 8px); transform: translateX(-50%);
  z-index: 5; width: 44px; height: 44px; border-radius: 50%;
  display: grid; place-items: center; cursor: pointer;
  font-size: 17px; line-height: 1; padding: 0;
  color: var(--pb-amber);
  background: color-mix(in srgb, #0E1309 84%, transparent);
  border: 1.5px solid color-mix(in srgb, var(--pb-amber) 55%, transparent);
  box-shadow: 0 4px 14px rgba(0,0,0,.55);
  animation: pb-scrollcue-nudge 1.6s ease-in-out infinite;
}
.pb-scrollcue:hover { background: color-mix(in srgb, var(--pb-amber) 22%, #0E1309); }
@keyframes pb-scrollcue-nudge {
  0%, 100% { transform: translateX(-50%) translateY(0); }
  50%      { transform: translateX(-50%) translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
  .pb-scrollcue { animation: none; }
}

/* =====================================================================
   4.20 MAIN MENU HERO — fills what used to be ~40% of empty screen.
   ===================================================================== */
.pb-hero {
  position: relative; flex: 1 1 auto; min-height: 190px;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding-bottom: 8px; overflow: hidden;
}
/* Scale the hero with the viewport so a tall phone gets a presence, not a
   132px sticker floating in the same void the art was added to fill. */
.pb-hero__art .pb-icon { width: clamp(120px, 36vw, 200px); height: auto; }
.pb-hero__glow {
  position: absolute; left: 50%; bottom: 18%; width: 260px; height: 260px;
  transform: translateX(-50%); border-radius: 50%; pointer-events: none;
  background: radial-gradient(circle, rgba(237,192,96,.30), rgba(107,143,58,.12) 45%, transparent 72%);
  animation: pb-hero-breathe 5.5s ease-in-out infinite;
}
@keyframes pb-hero-breathe { 0%,100% { transform: translateX(-50%) scale(1); opacity: .85; } 50% { transform: translateX(-50%) scale(1.09); opacity: 1; } }
/* The mascot's idle is driven from the game loop (ui/menus.js tick + the
   ScreenHandle.tick router hook), NOT from a keyframe: a CSS animation
   timeline freezes in a throttled or backgrounded document, which is exactly
   how QA caught two pixel-identical screenshots of this element twenty minutes
   apart. Nothing here may animate the mascot. */
.pb-hero__art { position: relative; }
.pb-hero__art .pb-icon { filter: drop-shadow(0 10px 14px rgba(0,0,0,.55)); }
.pb-hero__rig { will-change: transform; }
/* No transform-origin / transform-box rules here on purpose: every rig part is
   posed with an SVG `transform` ATTRIBUTE in user-space units (rotate(a cx cy),
   or an explicit translate/scale/translate sandwich), so its pivot travels with
   the markup instead of depending on a CSS box model that differs per engine. */
.pb-hero__plinth {
  width: 168px; height: 16px; border-radius: 50%; margin-top: -6px;
  background: radial-gradient(ellipse at center, rgba(0,0,0,.55), transparent 72%);
}
.pb-hero__line { margin: 4px 0 0; font-size: 13px; color: var(--pb-ink-dim); font-weight: 600; }

/* THE HERO FOOTER (loop-12 critic: ~500px of empty gradient on a 375x812
   phone). A live free-cache-key countdown plus a rotating tip, both real data
   from systems/meta.js and both driven off the router's tick. See ui/menus.js.
   `margin-top:auto` pins them to the BOTTOM of the flexed hero, so they close
   the gap above the Play button rather than just floating in the middle of it. */
.pb-hero__foot {
  position: relative; z-index: 1; margin-top: auto; width: 100%;
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  /* ROUND 22 GRIEVANCE (item 9) — `margin-top:auto` collapses to 0 the moment
     the hero's total content (art + plinth + this footer) no longer fits the
     flexed `.pb-hero` box (its own overflow:hidden then clips top AND bottom
     symmetrically-ish per the flex shrink algorithm, measured live at
     375x812: the rig SVG's own rect started ~17px above `.pb-hero`'s top —
     i.e. UNDER the stats panel above it — while this footer's own rect ran
     ~8px past `.pb-hero`'s bottom). A fixed `padding-top` is a floor that
     holds even in that collapsed case, so the Next-up line can never render
     flush against — or above — the art's own bottom edge again. */
  padding-top: 6px;
}
.pb-hero__key {
  position: relative; z-index: 1; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  min-height: 44px; box-sizing: border-box;
  padding: 7px 14px; border-radius: 999px; font: inherit;
  font-size: 12.5px; font-weight: 700; letter-spacing: .2px;
  color: var(--pb-amber);
  background: color-mix(in srgb, #0E1309 66%, transparent);
  border: 1.5px solid color-mix(in srgb, var(--pb-amber) 40%, transparent);
  box-shadow: 0 3px 12px rgba(0,0,0,.45);
}
.pb-hero__key:hover { border-color: color-mix(in srgb, var(--pb-amber) 75%, transparent); }
.pb-hero__key[data-full] {
  color: var(--pb-amber-hi, #FFD98A);
  border-color: color-mix(in srgb, var(--pb-amber) 85%, transparent);
  animation: pb-key-ready 2.4s ease-in-out infinite;
}
@keyframes pb-key-ready {
  0%, 100% { box-shadow: 0 3px 12px rgba(0,0,0,.45); }
  50%      { box-shadow: 0 3px 12px rgba(0,0,0,.45), 0 0 18px color-mix(in srgb, var(--pb-amber) 55%, transparent); }
}
.pb-hero__tip {
  position: relative; z-index: 1; margin: 8px 12px 2px; max-width: 34ch;
  font-size: 11.5px; line-height: 1.45; text-align: center;
  color: var(--pb-ink-dim); opacity: .85;
  /* ROUND 22 — capped at 2 lines (was unbounded, up to 3 on the longer tips,
     the single biggest item in the footer's own height budget). See the
     `.pb-hero__foot` comment above: this is one of the trims that gets the
     footer's natural height back under what `.pb-hero` actually has to give
     it at 375x812, the size where it used to overflow. -webkit-line-clamp
     is supported by every engine this game ships to (Safari/Chrome/Firefox
     all on Blink/WebKit-derived clamp support for years); overflow:hidden is
     the same property `.pb-hero` itself already relies on, so a 3rd line
     just ellipses instead of clipping mid-glyph. */
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
}

/* ROUND 5 TWITCH s18 — "the title screen is silent... nothing invites [the
   click the AudioContext needs]." A quiet, self-dismissing invitation next
   to the mute button, shown only before audio unlocks (ui/menus.js). */
.pb-audiohint {
  font-size: 11px; font-weight: 700; color: var(--pb-amber);
  letter-spacing: .3px; white-space: nowrap; opacity: .9;
  animation: pb-audiohint-pulse 1.8s ease-in-out infinite;
}
@keyframes pb-audiohint-pulse {
  0%, 100% { opacity: .55; }
  50%      { opacity: 1; }
}
/* On a phone the hero owns a third of the screen, so the mascot should own it
   properly instead of sitting small in the middle of a void.
   ROUND 22 GRIEVANCE (item 9, owner verbatim: "GUI panels still cover the
   squirrel graphic in the center... fix it for real") — this used to BUMP
   the art up to clamp(150px, 46vw, 220px) on exactly the narrow viewports
   where `.pb-hero` has the least room to give it (measured live at 375x812:
   a 173x173 rig, 17px taller than the box had free after the stats panel
   and the footer below it both claimed their own space — see the
   `.pb-hero__foot`/`.pb-hero__tip` comments above for the rest of that
   budget). Trimmed to clamp(130px, 38vw, 190px) — still bigger than the
   pre-480px base rule so the mascot keeps its "owns a third of the screen"
   presence, just not bigger than the box below it can actually afford. */
@media (max-width: 480px) {
  .pb-hero__art .pb-icon { width: clamp(130px, 38vw, 190px); }
  .pb-hero__tip { font-size: 12px; max-width: 30ch; }
}
@media (prefers-reduced-motion: reduce) {
  .pb-hero__key[data-full] { animation: none; }
}
.pb-hero__motes { position: absolute; inset: 0; pointer-events: none; }
.pb-hero__motes i {
  position: absolute; left: var(--x); bottom: -10px;
  width: 5px; height: 5px; border-radius: 50%; background: var(--pb-amber-hi);
  opacity: 0; animation: pb-mote 7s linear infinite; animation-delay: var(--d);
  box-shadow: 0 0 8px rgba(255,217,138,.8);
}
@keyframes pb-mote {
  0%   { opacity: 0; transform: translateY(0) translateX(0) scale(.6); }
  12%  { opacity: .9; }
  80%  { opacity: .5; }
  100% { opacity: 0; transform: translateY(-190px) translateX(22px) scale(1); }
}

/* =====================================================================
   4.21 HUD — ROUND 21 kill-streak meter, tier-up banner, centre cue, boss
   name card (docs/design/streaks.md). The old top-right "pb-streak" corner
   badge (right: calc(var(--pb-sar) + 12px)) is retired outright — streaks.md
   §0 names its position as one of the three measured faults: "the right
   corner at +64px sits under the thumb shadow on a phone and outside the
   player's foveal path." Replaced by `.pb-meter`, top-centre under the
   level timer.
   ===================================================================== */
@keyframes pb-streak-throb { 0%,100% { transform: scale(1); } 50% { transform: scale(1.08); } }

/* THE METER — streaks.md §4 "Visibility": top-centre, directly under the
   level timer, 210x46px (checked at 390px wide: 54% of screen width, so
   `min(210px, 54vw)` keeps the same proportion narrower than that).
   streaks.md gives the offset as the literal `top: calc(var(--pb-sat) +
   44px)` — DELIBERATELY NOT used as-is here: measured live, `.pb-hud__top`
   (the player's own stat row + HP bar + AMBER bar, right above this) renders
   at ~72-84px tall in this build (see `.pb-bossbar`'s own ROUND 13 comment
   for that exact measurement history), so a flat 44px would sit the meter
   PARTWAY THROUGH that block — the identical "guessed constant, never
   measured against the block's real height" bug ROUND 13 already fixed once
   for the boss bar, and streaks.md §5 test 16 explicitly requires "does not
   overlap hud-level or the Amber bar." Uses the SAME `--pb-top-h` technique
   instead. BUGFIX (found live at 390px, test 16): `--pb-top-h` is
   `.pb-hud__top`'s own HEIGHT only (hud.js syncTopHeight() measures
   `topBlock.getBoundingClientRect().height`) — but `.pb-hud__top` itself
   starts `top: calc(var(--pb-sat) + 8px)`, 8px below this element's own
   `0`-based origin, so a naive `var(--pb-sat) + var(--pb-top-h) + <gap>`
   UNDERSHOOTS the block's true bottom edge by exactly that 8px and clips
   into the AMBER bar (measured: meter top 77.8px vs the amber bar's own
   bottom at 79.8px, a real 2px overlap). `14px` below is that missing 8px
   folded into the intended 6px "directly under" gap (8 + 6), tighter than
   the boss bar's own 20px. `--pb-meter-h` (published by hud.js's
   syncMeterHeight()) is this element's OWN real height, read by
   `.pb-bossbar` below so the two top-centre elements can never draw on top
   of each other either. */
.pb-meter {
  position: absolute; top: calc(var(--pb-sat) + var(--pb-top-h, 84px) + 14px); left: 50%; transform: translateX(-50%);
  width: min(210px, 54vw); min-height: 46px; box-sizing: border-box;
  padding: 5px 10px 6px; border-radius: 10px;
  background: rgba(10,14,7,.88); box-shadow: inset 0 0 0 1px rgba(255,255,255,.14), 0 4px 14px rgba(0,0,0,.4);
  pointer-events: none; text-shadow: 0 1px 3px rgba(0,0,0,.9);
  transition: opacity .35s ease-out;
}
.pb-meter__row1, .pb-meter__row2 { display: flex; align-items: baseline; justify-content: space-between; }
.pb-meter__tier {
  font-size: 13px; font-weight: 800; letter-spacing: 2px; text-transform: uppercase;
  color: var(--pb-amber); line-height: 1.2;
}
.pb-meter__kills {
  font-family: var(--pb-font-num); font-weight: 800; font-size: 22px; color: var(--pb-cream);
  font-variant-numeric: tabular-nums; line-height: 1.15;
}
.pb-meter__hold {
  font-family: var(--pb-font-num); font-weight: 700; font-size: 12px; color: var(--pb-cream);
  opacity: .82; align-self: flex-end;
}
.pb-meter__coinchip {
  font-family: var(--pb-font-num); font-weight: 800; font-size: 12px; line-height: 1; color: #241802;
  background: linear-gradient(180deg, var(--pb-amber-hi), var(--pb-amber));
  border-radius: 6px; padding: 1px 6px;
}
/* ROUND 24 (round24-ux.md §2.6) — wraps the coin chip AND the new "?" info
   button as row 1's single right-hand flex item, so `.pb-meter__row1`'s own
   `justify-content: space-between` still yields exactly two items (tier
   name / this wrapper) regardless of whether the chip is showing. `align-
   self: center` here replaces the coin chip's own former `align-self:
   center` (moot now that its direct parent is this wrapper, not row1). */
.pb-meter__row1right { display: flex; align-items: center; gap: 5px; align-self: center; }
/* The "?" — ONE `pointer-events: auto` hole in the pill's otherwise `none`
   surface (round24-ux.md §2.6: any other live region here would swallow the
   joystick's own pointerdown, bound on the canvas beneath). Sized to match
   the tier name's own 15.6px line-height so row 1 — and therefore the
   pill's total measured height — does not grow; the real tap target is the
   `::before` below, taken OUT of flow (`position: absolute`) so padding it
   to ~44x44px costs nothing in layout, the same trade the ability button's
   own oversized visual footprint already makes for an easy thumb target. */
.pb-meter__info {
  position: relative; pointer-events: auto; cursor: pointer;
  appearance: none; background: none; border: none; padding: 0; margin: 0;
  width: 15px; height: 15.6px; line-height: 15.6px; text-align: center;
  font-family: inherit; font-size: 12px; font-weight: 800; color: var(--pb-cream);
  opacity: .72; text-shadow: 0 1px 3px rgba(0,0,0,.9);
}
.pb-meter__info::before { content: ''; position: absolute; inset: -15px; }
.pb-meter__info:active { opacity: 1; }
/* ROUND 21 FIX ITEM 9 FOLLOW-UP (found during this pass's own live verification,
   not by the review) — without an explicit line-height the chip inherited the
   page's own ~1.4 default, giving it a taller box (18.8px) than the tier-name
   text next to it in `.pb-meter__row1` (15.6px at line-height 1.2), which
   silently grew ROW1's own flex-line height to match the taller child —
   `.pb-meter`'s TOTAL measured height was 63.09px at 375px wide whenever a
   coin-chip is showing (every tier 4+), 3.2px over streaks.md §4/the review's
   own "height <= 60px with the subtitle hidden" bar, even though the same
   pill measured a clean 59.89px at tier 3 (no chip). `line-height: 1` drops
   the chip's own box back to ~14px (12px font + the existing 1px/1px
   padding), letting the tier-name (15.6px) govern row1 again — reverified
   live: 375px wide, tier 4, subtitle hidden, pill height back to 59.89px. */
.pb-meter__track {
  position: relative; height: 4px; margin-top: 4px; border-radius: 2px;
  background: rgba(255,255,255,.16); overflow: visible;
}
.pb-meter__fill {
  position: relative; height: 100%; width: 0%; border-radius: 2px;
  background: var(--pb-amber); transition: width .2s ease-out, background-color .2s;
}
/* The 2px leading cap, in the NEXT tier's colour (streaks.md §4) — one rule
   per CURRENT tier (0..7), so it only ever needs the tier that is ABOUT to
   be reached, never a live JS colour lookup. No cap at tiers 7 (a text
   countdown replaces the bar) or 8 (no further "next tier" exists). */
.pb-meter__fill::after {
  content: ''; position: absolute; top: 0; right: 0; bottom: 0; width: 2px; border-radius: 1px;
}
.pb-meter__fill[data-tier="0"]::after { background: #FFC24A; }
.pb-meter__fill[data-tier="1"]::after { background: #FFD064; }
.pb-meter__fill[data-tier="2"]::after { background: #FFE08A; }
.pb-meter__fill[data-tier="3"]::after { background: #FFEE9E; }
.pb-meter__fill[data-tier="4"]::after { background: #E8F08A; }
.pb-meter__fill[data-tier="5"]::after { background: #C8E86B; }
.pb-meter__fill[data-tier="6"]::after { background: #A9E85A; }
.pb-meter__fill[data-tier="7"]::after, .pb-meter__fill[data-tier="8"]::after { content: none; }
/* THE COLOUR LAW (streaks.md §2) — amber -> gold -> chartreuse, applied to
   both the tier-name text and the progress fill together so the two always
   agree at a glance. Keep byte-for-byte identical to systems/vfx.js's
   STREAK_TIER_COLORS if either ever moves. */
.pb-meter[data-tier="1"] .pb-meter__tier, .pb-meter__fill[data-tier="1"] { color: #FFC24A; background: #FFC24A; }
.pb-meter[data-tier="2"] .pb-meter__tier, .pb-meter__fill[data-tier="2"] { color: #FFD064; background: #FFD064; }
.pb-meter[data-tier="3"] .pb-meter__tier, .pb-meter__fill[data-tier="3"] { color: #FFE08A; background: #FFE08A; }
.pb-meter[data-tier="4"] .pb-meter__tier, .pb-meter__fill[data-tier="4"] { color: #FFEE9E; background: #FFEE9E; }
.pb-meter[data-tier="5"] .pb-meter__tier, .pb-meter__fill[data-tier="5"] { color: #E8F08A; background: #E8F08A; }
.pb-meter[data-tier="6"] .pb-meter__tier, .pb-meter__fill[data-tier="6"] { color: #C8E86B; background: #C8E86B; }
.pb-meter[data-tier="7"] .pb-meter__tier, .pb-meter__fill[data-tier="7"] { color: #A9E85A; background: #A9E85A; }
.pb-meter[data-tier="8"] .pb-meter__tier, .pb-meter__fill[data-tier="8"] {
  color: #8FF06A; background: #8FF06A; animation: pb-streak-throb .6s ease-in-out infinite;
}
/* Tier 7 Pinecrack — "the meter stops showing progress and starts counting
   down: 50 TO PINEBURST." The fill itself stays lit full-width as a plain
   backdrop; the countdown floats just above it so 12px+ text is never
   squeezed into a 4px-tall bar. */
.pb-meter__fill[data-tier="7"] {
  position: absolute; top: -13px; left: 50%; transform: translateX(-50%);
  width: auto !important; height: auto; white-space: nowrap; background: none;
  font-size: 12px; font-weight: 800; letter-spacing: .5px;
}
/* ROUND 24 (round24-ux.md §2.4) — OVERRULES streaks.md §4's boxed, timed
   subtitle: this is now the pill's PERSISTENT third row, visible for as
   long as the tier holds. The old card styling (own background, inset ring,
   6/8px padding, border-radius) cost 12px of vertical padding and 16px of
   horizontal width for no legibility gain once there is no longer a single
   sentence to frame — the pill already has its own background — so what is
   left is a flex row of up to three short spans plus a hairline separating
   it from the bar above. `white-space: nowrap` + `justify-content: space-
   between` (no `·` glyphs — measured live: three interpunct separators cost
   23px at tier 8, the exact margin between "fits" and "wraps" at 375px) is
   what keeps all three spans on one line at every measured width — and NO
   explicit `gap` here on purpose: `space-between` alone already spaces the
   spans when there is slack, but a `gap` on TOP of it sets a floor that
   still applies when content is tight, and tier 8's own three tokens
   measured live at 178.5px combined — under the 182.5px content width at
   375px with zero gap, but over it the instant a 6px `gap` forced 12px more
   width on top (measured: 191px, a real overflow this exact combination
   caused before the `gap` was removed). */
.pb-meter__meaning {
  display: flex; align-items: baseline; justify-content: space-between;
  white-space: nowrap;
  /* round24-ux.md §2.4's own height math budgets the third row at "12px
     text costs >= 19px" (margin-top + a hairline + the text's own line
     box) — NO separate `padding-top` on top of that: measured live, adding
     one pushed the pill to 83.9px at tier 8, over the 82px hard cap (S8).
     Without it: border(1) + line(12px * 1.25 = 15px) + this margin-top(4)
     = 20px added, landing the pill at 79.9px — under budget with 2.1px to
     spare, matching the design's own 78.9px prediction within a pixel. */
  margin-top: 4px; border-top: 1px solid rgba(255,255,255,.12);
  /* streaks.md §4/§5 test 16: "nothing smaller than 12px" inside the pill —
     was 11px. */
  font-size: 12px; font-weight: 600; line-height: 1.25; color: var(--pb-cream); text-shadow: none;
  animation: pb-streak-meaning-in .35s cubic-bezier(.15,.9,.3,1);
}
.pb-meter__eff { flex: 0 0 auto; }
@keyframes pb-streak-meaning-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}
/* Tier-to-tier SWAP (the row was already showing a different tier's
   numbers) — a quick crossfade rather than replaying the appear animation
   above, restarted via the same forced-reflow trick `.pb-streakbanner.is-on`
   already uses (ui/hud.js). Two-class selector outranks the base rule's
   `animation` regardless of source order, so this cleanly overrides the
   entrance keyframe while the class is present. */
.pb-meter__meaning.is-swapping { animation: pb-streak-meaning-swap .3s ease-out; }
@keyframes pb-streak-meaning-swap {
  0%   { opacity: .35; }
  100% { opacity: 1; }
}

/* Right-aligned strip of earned Burst Cache chips — streaks.md §4: "below
   the pill". ROUND 21 FIX ITEM 9 (Opus review, §4) — used to be a FLOW
   child at the bottom of `.pb-meter`, which is exactly why the pill
   measured 203x87px at 375px wide against the §4 target of 210x46: the
   review's own reading, "the cache-chip strip is nested inside the pill;
   §4 put it below." Taken OUT of flow here (`position: absolute`) so it no
   longer inflates `.pb-meter`'s own measured height (and therefore
   `--pb-meter-h`, which `.pb-bossbar`/`.pb-streaktoast` both key off) while
   staying visually attached to the pill: `.pb-meter` is already
   `position: absolute`, which makes it a valid containing block for this
   child's `top: 100%; right: 0`, so the strip renders directly below the
   pill's own true bottom edge — including the meaning subtitle's height
   when THAT is showing — with no separate top-offset formula to keep in
   sync. Still reads as a distinct strip (hairline top divider, no
   background of its own) under the pill it belongs to. `streak-cache-
   count`'s own text is the plain digit; the chips carry no text (see
   hud.js's own comment on why). */
.pb-meter__caches {
  position: absolute; top: 100%; right: 0; margin-top: 6px;
  display: flex; align-items: center; justify-content: flex-end; gap: 5px;
  padding-top: 4px; border-top: 1px solid rgba(255,255,255,.14);
  pointer-events: none;
}
.pb-meter__cachechips { display: flex; gap: 3px; }
.pb-meter__cachechip {
  width: 10px; height: 10px; border-radius: 3px;
  background: linear-gradient(160deg, #FFF3B0, #8FF06A);
  box-shadow: 0 0 6px rgba(143,240,106,.7);
}
.pb-meter__cachecount {
  font-family: var(--pb-font-num); font-weight: 800; font-size: 12px; color: #8FF06A;
  text-shadow: 0 1px 3px rgba(0,0,0,.9);
}
/* ROUND 21 FIX ITEM 2 (Opus review, Criterion 5) — "the chip pulsing on
   arrival" (streaks.md §3), fired by ui/hud.js the same tick a cache flight
   lands (run.js `cachePulseT`). Reuses the existing one-shot scale-pulse
   keyframe rather than inventing a second near-identical one. */
.pb-meter__cachecount.is-pulsing { animation: pb-streak-throb .5s ease-in-out; }
@media (prefers-reduced-motion: reduce) {
  .pb-meter__cachecount.is-pulsing { animation: none; }
}

/* THE TIER-UP BANNER — streaks.md §4(a): centre-screen at 34% height, tier
   name at 40px, 1.1s (1.6s at PineBurst).
   ROUND 24 (round24-ux.md §2.2 "THE LAW") BUGFIX — this used to be a FLAT
   1.1s animation at every tier, with only `fz.cueT`'s longer sim guard (now
   `cueT`, run.js) holding the element's `hidden` false past that point at
   tier 8. Measured: the animation's own `100%` keyframe (opacity 0) is
   already reached at 1.1s regardless of tier, so from 1.1s to 1.6s the
   PineBurst banner sat fully invisible while JS still believed it was
   showing — "1.6s at PineBurst" was true of the DOM node's lifetime, never
   of what the player could see. The `[data-tier="8"].is-on` rule below makes
   the ANIMATION itself 1.6s at tier 8, so the fade genuinely reaches its
   own 78%-opacity-still-1.0 plateau proportionally later and finishes
   exactly when the sim tears the node down, not 0.5s early. */
.pb-streakbanner {
  position: absolute; left: 50%; top: 34%; transform: translate(-50%,-50%);
  pointer-events: none; font-size: clamp(26px, 8vw, 40px); font-weight: 800;
  letter-spacing: 3px; text-transform: uppercase; white-space: nowrap;
  color: var(--pb-amber); text-shadow: 0 0 20px currentColor, 0 3px 0 rgba(0,0,0,.6);
}
.pb-streakbanner.is-on { animation: pb-streakbanner-in 1.1s cubic-bezier(.15,.9,.3,1) forwards; }
.pb-streakbanner[data-tier="8"].is-on { animation-duration: 1.6s; }
@keyframes pb-streakbanner-in {
  0%   { opacity: 0; transform: translate(-50%,-50%) scale(1.25); }
  12%  { opacity: 1; transform: translate(-50%,-50%) scale(1); }
  18%  { transform: translate(-50%,-50%) scale(1.06); }
  26%  { transform: translate(-50%,-50%) scale(1); }
  78%  { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%,-58%) scale(1.04); }
}
.pb-streakbanner[data-tier="1"] { color: #FFC24A; }
.pb-streakbanner[data-tier="2"] { color: #FFD064; }
.pb-streakbanner[data-tier="3"] { color: #FFE08A; }
.pb-streakbanner[data-tier="4"] { color: #FFEE9E; }
.pb-streakbanner[data-tier="5"] { color: #E8F08A; }
.pb-streakbanner[data-tier="6"] { color: #C8E86B; }
.pb-streakbanner[data-tier="7"] { color: #A9E85A; }
.pb-streakbanner[data-tier="8"] { color: #8FF06A; font-size: clamp(30px, 9vw, 44px); }

/* ROUND 24 (round24-ux.md §2.5) — THE CAPTION, just under the banner: the
   sentence run.frenzy.explainText now feeds (moved off the pill — see
   `.pb-meter__meaning`'s own comment). "34% height + 34px" per the design —
   the banner itself sits centred ON 34%, so this reads as sitting just
   below its baseline rather than overlapping it. 0.2s fade-in / hold /
   0.5s fade-out over a flat 3.5s, EVERY tier (no PineBurst-only stretch —
   unlike the banner, this element's own CSS duration never varies, so its
   sim guard, run.js's captionT, is a flat 5.6s too). Tier colour at 82%
   alpha is baked into each `[data-tier]` rule's own `color` (not the
   animation) so the opacity keyframe below can fade all the way to 0 at
   both ends without the two multiplying against each other. */
.pb-streakcaption {
  position: absolute; left: 50%; top: calc(34% + 34px); transform: translate(-50%,-50%);
  width: min(320px, 86vw); text-align: center;
  pointer-events: none; font-size: 13px; font-weight: 600; line-height: 1.3;
  color: var(--pb-amber); opacity: 0;
  text-shadow: 0 2px 6px rgba(0,0,0,.7);
}
.pb-streakcaption.is-on { animation: pb-streakcaption-fade 3.5s linear forwards; }
@keyframes pb-streakcaption-fade {
  0%     { opacity: 0; }
  5.71%  { opacity: 1; }   /* 0.2s of 3.5s */
  85.71% { opacity: 1; }   /* fade-out starts at 3.0s, i.e. 0.5s before the end */
  100%   { opacity: 0; }
}
.pb-streakcaption[data-tier="1"] { color: rgba(255,194,74,.82); }
.pb-streakcaption[data-tier="2"] { color: rgba(255,208,100,.82); }
.pb-streakcaption[data-tier="3"] { color: rgba(255,224,138,.82); }
.pb-streakcaption[data-tier="4"] { color: rgba(255,238,158,.82); }
.pb-streakcaption[data-tier="5"] { color: rgba(232,240,138,.82); }
.pb-streakcaption[data-tier="6"] { color: rgba(200,232,107,.82); }
.pb-streakcaption[data-tier="7"] { color: rgba(169,232,90,.82); }
.pb-streakcaption[data-tier="8"] { color: rgba(143,240,106,.82); }

/* Burst Cache reward toast — streaks.md §3: "BURST CACHE I · KEEP IT
   ALIVE" for 1.8s. Directly under the meter pill (the SAME `--pb-top-h`/
   `--pb-meter-h` the pill and the boss bar both key off — see `.pb-meter`'s
   own comment — rather than a flat guess that would go stale the moment
   either one's real height does), so it can never land on top of either
   the timer row above or the pill itself. The leading `14px` is the SAME
   "topBlock's own 8px top offset, folded into the gap" term `.pb-meter`
   uses — this chains off the meter's TRUE bottom edge (its own `top` plus
   its own height), not just `--pb-top-h` + `--pb-meter-h` alone. */
/* BUG B4 — a high tier's full string ("BURST CACHE III · KEEP IT ALIVE", 31
   chars at 15px bold uppercase with 1.2px letter spacing, ~360px wide with
   padding) overflowed a 375/390px phone outright under the old flat
   `white-space: nowrap` with no `max-width`. Let it wrap and shrink instead;
   the `top` calc() chain above is untouched — it keys off the meter's true
   bottom edge, not a flat number. */
.pb-streaktoast {
  position: absolute; left: 50%;
  top: calc(var(--pb-sat) + var(--pb-top-h, 84px) + 14px + var(--pb-meter-h, 0px) + 8px);
  transform: translateX(-50%);
  pointer-events: none; white-space: normal;
  max-width: min(92vw, 360px); text-align: center;
  font-size: clamp(12px, 3.4vw, 15px); font-weight: 800; letter-spacing: 1.2px; text-transform: uppercase;
  color: #FFF3B0; text-shadow: 0 0 16px rgba(143,240,106,.7), 0 2px 0 rgba(0,0,0,.6);
  background: rgba(10,14,7,.82); border: 1px solid rgba(143,240,106,.55);
  border-radius: 999px; padding: 6px 16px;
  animation: pb-pop-x .4s cubic-bezier(.2,.9,.3,1.35) backwards;
}

/* THE BREAK — streaks.md §4: pill shatters (see .pb-meter.is-breaking below),
   "STREAK LOST" for 1.0s with the peak count struck through. Grey is
   reserved for exactly this — the only grey element on the whole HUD. */
.pb-streakbreak {
  position: absolute; left: 50%; top: 34%; transform: translate(-50%,-50%);
  pointer-events: none; text-align: center; white-space: nowrap;
  animation: pb-pop-xy .3s cubic-bezier(.2,.9,.3,1.2) backwards;
}
.pb-streakbreak__word {
  display: block; font-size: clamp(22px, 6.5vw, 32px); font-weight: 800; letter-spacing: 3px;
  color: #9AA08E; text-shadow: 0 3px 8px rgba(0,0,0,.8);
}
.pb-streakbreak__peak {
  display: block; margin-top: 3px; font-family: var(--pb-font-num); font-size: 14px;
  font-weight: 700; color: #6E7563;
}

/* ROUND 21 — the pill's own break animation: shards falling off the bottom
   over .35s, the bar already drained by the streak-reset that triggered it.
   Applied by giving `.pb-meter` this class for its remaining visible time
   (hud.js keeps the pill un-hidden through breakT — see update()'s own
   comment); a translate+fade stands in for eight literal shard sprites. */
.pb-meter.is-breaking { animation: pb-meter-break .35s ease-in forwards; }
@keyframes pb-meter-break {
  0%   { opacity: 1; filter: grayscale(0); transform: translate(-50%,0); }
  100% { opacity: 0; filter: grayscale(1); transform: translate(-50%,14px); }
}

/* The streak-explainer card — streaks.md §4. The ONE-TIME-EVER first-reach
   path stays NON-BLOCKING (see buildStreakExplainer()'s own doc comment):
   it floats over the action rather than freezing it, on a translucent card
   so the fight stays legible underneath. ROUND 24 (round24-ux.md §2.6) adds
   a SECOND path — tapping the pill/`streak-info` — that DOES pause the run
   first (ui/hud.js); this card's own look is identical either way, it is
   only ever the two callers that differ.
   ROUND 24 (§2.7) also raised `max-height` 62vh -> 74vh: each of the eight
   rows below grew a second line (its own effect text), adding ~141px to the
   card's content — comfortably inside the new cap at 812px tall (601px),
   with the existing `overflow-y: auto` scroller as the fallback on a
   shorter viewport so `streak-explainer-ok` stays reachable either way. */
.pb-streakexplainer {
  position: absolute; left: 50%; bottom: calc(var(--pb-sab) + 90px); transform: translateX(-50%);
  width: min(320px, 86vw); max-height: 74vh; overflow-y: auto; -webkit-overflow-scrolling: touch;
  pointer-events: auto; z-index: 7;
  background: rgba(8,11,6,.94); border: 2px solid var(--pb-amber); border-radius: var(--pb-radius);
  box-shadow: 0 10px 30px rgba(0,0,0,.55);
  padding: 16px 16px 14px; text-align: center;
  animation: pb-pop-x .4s cubic-bezier(.2,.9,.3,1.35) backwards;
}
.pb-streakexplainer__title {
  font-size: 18px; font-weight: 800; letter-spacing: 2px; color: var(--pb-amber-hi); margin-bottom: 6px;
}
.pb-streakexplainer__body { font-size: 13px; line-height: 1.4; color: var(--pb-cream); margin-bottom: 10px; }
.pb-streakexplainer__list {
  list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 5px;
  font-family: var(--pb-font-num); font-size: 12px;
}
/* ROUND 24 (round24-ux.md §2.7) — each step is now TWO lines: the existing
   name+kills row (unchanged — `.pb-streakexplainer__steprow` just wraps it,
   `__stepname`/`__stepkills` keep their own class/testid contract intact),
   plus a NEW effect line underneath naming what the tier actually does. */
.pb-streakexplainer__list li { display: flex; flex-direction: column; gap: 1px; padding: 2px 4px; }
.pb-streakexplainer__steprow { display: flex; justify-content: space-between; }
.pb-streakexplainer__stepname { color: var(--pb-cream); font-weight: 700; text-align: left; }
.pb-streakexplainer__stepkills { color: var(--pb-ink-dim); }
.pb-streakexplainer__stepeff {
  margin-left: 8px; text-align: left; font-size: 12px; color: var(--pb-ink-dim);
}
.pb-streakexplainer__ok { width: 100%; }

.pb-cue {
  position: absolute; left: 50%; top: 34%; transform: translate(-50%,-50%);
  pointer-events: none; font-size: clamp(22px, 6vw, 40px); font-weight: 800;
  letter-spacing: 3px; color: var(--pb-amber-hi);
  text-shadow: 0 0 18px rgba(255,154,60,.85), 0 3px 0 rgba(0,0,0,.6);
  white-space: nowrap;
}
.pb-cue.is-on { animation: pb-cue-in 1.3s cubic-bezier(.15,.9,.3,1) forwards; }
/* EVOLUTION BANNER (round 5 — the owner missed the moment entirely).
   The evolution shares the one-shot cue element with the frenzy tier and the
   companion active, and at that shared size it read as one more toast in a
   busy frame. It gets the boss-card treatment instead — a plate, a pair of
   sweeping rules and a wider, heavier name — without touching hud.js's cue
   logic: same element, same text, purely a presentational upgrade keyed off
   the modifier class the HUD already sets. */
.pb-cue--evo {
  color: #FFF;
  font-size: clamp(26px, 7.4vw, 52px);
  letter-spacing: 4px;
  padding: 14px 34px;
  border-radius: 10px;
  background: radial-gradient(120% 140% at 50% 50%, rgba(255,255,255,.14), rgba(10,7,14,.72) 72%);
  box-shadow: 0 0 60px rgba(255,255,255,.28), inset 0 0 0 2px rgba(255,255,255,.35);
  text-shadow: 0 0 26px var(--pb-r-mythic), 0 0 60px rgba(255,255,255,.6), 0 3px 0 rgba(0,0,0,.6);
}
.pb-cue--evo::before,
.pb-cue--evo::after {
  content: ''; position: absolute; left: 50%; transform: translateX(-50%);
  height: 3px; width: min(66vw, 520px);
  background: linear-gradient(90deg, transparent, #FFF, var(--pb-r-mythic), #FFF, transparent);
  animation: pb-evo-rule .55s ease-out;
}
.pb-cue--evo::before { top: -10px; }
.pb-cue--evo::after { bottom: -10px; }
@keyframes pb-evo-rule { from { transform: translateX(-50%) scaleX(0); } to { transform: translateX(-50%) scaleX(1); } }
/* Companion active name card (round3 twitch: "the active is not a moment" —
   give it the same world-space shout the elite card and the frenzy cue get).
   --pb-ally is set inline per-cast to that companion's own accent colour. */
.pb-cue--ally { color: var(--pb-ally, #FFD98A); text-shadow: 0 0 22px var(--pb-ally, #FFD98A), 0 3px 0 rgba(0,0,0,.6); font-size: clamp(18px, 5vw, 32px); }
@keyframes pb-cue-in {
  0%   { opacity: 0; transform: translate(-50%,-50%) scale(1.9); letter-spacing: 16px; }
  18%  { opacity: 1; transform: translate(-50%,-50%) scale(1); letter-spacing: 3px; }
  70%  { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%,-64%) scale(1.05); }
}

.pb-bosscard {
  position: absolute; left: 50%; top: 30%; transform: translate(-50%,-50%);
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  pointer-events: none; text-align: center;
  animation: pb-boss-in .5s cubic-bezier(.2,.9,.3,1.1);
}
.pb-bosscard__rule {
  display: block; height: 2px; width: min(62vw, 460px);
  background: linear-gradient(90deg, transparent, var(--pb-berry), var(--pb-amber), var(--pb-berry), transparent);
  animation: pb-boss-rule .6s ease-out;
}
.pb-bosscard__kicker { font-size: 11px; letter-spacing: 4px; text-transform: uppercase; color: var(--pb-ink-dim); }
.pb-bosscard__name {
  font-size: clamp(24px, 6.4vw, 46px); font-weight: 800; letter-spacing: 2px;
  color: #FFE9C0; text-shadow: 0 0 22px rgba(224,83,63,.9), 0 3px 0 rgba(0,0,0,.65);
}
@keyframes pb-boss-in { from { transform: translate(-50%,-50%) scale(.86); } to { transform: translate(-50%,-50%) scale(1); } }
@keyframes pb-boss-rule { from { transform: scaleX(0); } to { transform: scaleX(1); } }

/* =====================================================================
   4.22 DEATH / REVIVE — a life-or-death interruption, not a toast.
   ===================================================================== */
.pb-death {
  background: radial-gradient(120% 90% at 50% 45%, rgba(60,8,6,.42), rgba(6,4,3,.93) 70%);
  align-items: center; justify-content: center;
  animation: pb-death-in .4s ease-out;
}
@keyframes pb-death-in { from { transform: scale(1.04); } to { transform: none; } }
.pb-death__vignette {
  position: absolute; inset: 0; pointer-events: none;
  box-shadow: inset 0 0 min(38vw, 300px) rgba(140,20,14,.75);
  animation: pb-death-pulse 2.1s ease-in-out infinite;
}
@keyframes pb-death-pulse { 0%,100% { opacity: .6; } 50% { opacity: 1; } }
.pb-death__panel {
  position: relative; width: min(420px, 92vw); text-align: center;
  border-color: rgba(224,83,63,.7);
  box-shadow: 0 0 44px rgba(180,40,28,.45), var(--pb-shadow);
  animation: pb-pop .3s cubic-bezier(.2,.9,.3,1.2); animation-delay: .12s;
}
.pb-death__mark { display: grid; place-items: center; margin-bottom: 4px; }
.pb-death__mark:not(.has-art) { filter: grayscale(.8) brightness(.75); } /* the code glyph stays dimmed; the painted portrait is full colour */
/* ROUND 44 item 50: the head box of the 427 x 512 portrait is x 157..307, y 8..158 -> a 56 px circle at scale 0.373 */
.pb-death__bust { width: 56px; height: 56px; border-radius: 50%; overflow: hidden; box-shadow: 0 0 0 2px #1a120c, 0 0 0 3px rgba(255,200,170,.28); }
.pb-death__bust .pb-death__bramble { display: block; width: 159px; height: auto; max-width: none; margin-left: -59px; margin-top: -3px; }
.pb-death__title {
  margin: 0 0 2px; font-size: clamp(22px, 6vw, 34px); font-weight: 800; letter-spacing: 4px;
  color: #FF8A78; text-shadow: 0 0 20px rgba(224,83,63,.8), 0 2px 0 rgba(0,0,0,.6);
}
.pb-death__panel .pb-title { font-size: 18px; margin: 2px 0 6px; }
.pb-death__panel .pb-btn { margin-top: 8px; }

/* SECOND WIND (docs/design/feature-plan.md §1) — the "N left today" caption
   under the ad-revive button. `.pb-card__desc` already sets the small dim
   text; this just tightens its spacing against the button above it so the
   caption reads as attached to that button, not floating between it and
   "Let go" below. */
.pb-death__ad-remaining { margin: -6px 0 0; }

/* =====================================================================
   4.23 RESULT — the run-clear payoff.
   ===================================================================== */
.pb-result { overflow: hidden; }
.pb-result__title { animation: pb-pop .45s cubic-bezier(.2,.9,.3,1.3); }
.pb-result--win .pb-result__title { color: var(--pb-amber-hi); text-shadow: 0 0 26px rgba(255,217,138,.6), 0 2px 0 rgba(0,0,0,.5); }
.pb-result--lose .pb-result__title { color: #FF8A78; }
.pb-result__rays {
  position: absolute; left: 50%; top: 34%; width: 160vmax; height: 160vmax;
  transform: translate(-50%,-50%); pointer-events: none; opacity: .3;
  background: repeating-conic-gradient(from 0deg,
    rgba(255,217,138,.5) 0deg 5deg, transparent 5deg 16deg);
  mask-image: radial-gradient(circle, #000 0%, transparent 62%);
  -webkit-mask-image: radial-gradient(circle, #000 0%, transparent 62%);
  animation: pb-rays 34s linear infinite;
}
@keyframes pb-rays { to { transform: translate(-50%,-50%) rotate(360deg); } }
.pb-result__stars { display: flex; justify-content: center; gap: 10px; margin: 2px 0 10px; }
.pb-result__stars .is-dim { opacity: .18; filter: grayscale(1); }
.pb-result__stars .is-lit {
  filter: drop-shadow(0 0 12px rgba(255,217,138,.9));
  animation: pb-star-slam .5s cubic-bezier(.2,.9,.3,1.5);
}
@keyframes pb-star-slam {
  0%   { transform: scale(2.6) rotate(-40deg); }
  70%  { transform: scale(.92) rotate(4deg); }
  100% { transform: none; }
}
.pb-result__banner {
  align-self: center; padding: 5px 16px; border-radius: 999px; font-weight: 800;
  letter-spacing: 2.4px; font-size: 12px; color: #2A1C08;
  background: linear-gradient(180deg, var(--pb-amber-hi), var(--pb-amber));
  animation: pb-pop .35s ease-out; animation-delay: .9s;
}
.pb-result__unlock {
  display: flex; align-items: center; gap: 12px; margin-top: 10px; padding: 12px 14px;
  border-radius: var(--pb-radius); border: 2px solid var(--pb-r-legendary);
  background: linear-gradient(180deg, rgba(120,92,34,.75), rgba(28,22,10,.92));
  box-shadow: 0 0 20px rgba(255,177,61,.4);
  animation: pb-pop .4s ease-out; animation-delay: 1.05s;
}
.pb-reveal-row { animation: pb-rise .28s ease-out; }
/* =====================================================================
   4.23b DEFEAT — the loss deserves production value too.
   The win had rays, stars and confetti; the loss had a flat panel and red
   text, which reads as the game not caring. A defeat gets its own spectacle,
   tuned to the opposite emotion: heavy, cold, slow. No celebration — but
   never nothing.
   ===================================================================== */
.pb-result--lose {
  background:
    radial-gradient(90% 60% at 50% 0%, rgba(176,58,46,.16), transparent 62%),
    linear-gradient(180deg, #14100C 0%, #070906 100%);
}
/* The gloom: a vignette that closes in, plus a dull ember seam behind the
   title where the ground cracked open. */
.pb-result__gloom {
  position: absolute; inset: 0; pointer-events: none;
  background:
    radial-gradient(60% 34% at 50% 16%, rgba(176,58,46,.22), transparent 70%),
    radial-gradient(120% 100% at 50% 50%, transparent 34%, rgba(0,0,0,.72) 100%);
  animation: pb-gloom-close 1.6s ease-out;
}
@keyframes pb-gloom-close { from { transform: scale(1.35); } to { transform: none; } }

/* Slow, cold ash. The deliberate inverse of the victory confetti: falls
   instead of tumbling, grey instead of amber, and takes twice as long. */
.pb-ash { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.pb-ash i {
  position: absolute; top: -6%; left: var(--x);
  width: 3px; height: 3px; border-radius: 50%;
  background: rgba(214,206,190,.5);
  transform: scale(var(--s));
  animation: pb-ash-fall var(--t, 8s) linear infinite;
  animation-delay: var(--d, 0s);
}
.pb-ash i:nth-child(4n) { background: rgba(255,138,120,.4); }
@keyframes pb-ash-fall {
  0%   { opacity: 0; transform: translate(0, 0) scale(var(--s)); }
  8%   { opacity: 1; }
  92%  { opacity: 1; }
  100% { opacity: 0; transform: translate(26px, 108vh) scale(var(--s)); }
}

/* "You got this far" — the single element that actually motivates a retry.
   Seeing 88% is what makes a player press the button again. */
.pb-result__reach {
  border: 2px solid rgba(176,58,46,.5); border-radius: var(--pb-radius);
  background: rgba(40,18,14,.55); padding: 12px; margin-bottom: 10px;
  animation: pb-rise .4s .2s ease-out backwards;
}
.pb-result__reach-row { display: flex; justify-content: space-between; align-items: baseline; }
.pb-result__reach-row .pb-kv__v { color: #FF8A78; font-size: 20px; font-weight: 800; }
.pb-result__reach-bar {
  height: 10px; border-radius: 5px; margin: 8px 0 6px;
  background: rgba(0,0,0,.45); border: 1px solid rgba(255,255,255,.08); overflow: hidden;
}
.pb-result__reach-bar i {
  display: block; height: 100%; width: var(--pct, 0%);
  background: linear-gradient(90deg, #B03A2E, #FF8A78);
  box-shadow: 0 0 12px rgba(255,138,120,.5);
  animation: pb-reach-fill .9s .35s cubic-bezier(.2,.8,.3,1) backwards;
}
@keyframes pb-reach-fill { from { width: 0; } to { width: var(--pct, 0%); } }
.pb-result__reach .pb-card__desc { margin: 0; }

/* Boss fanfare — a boss clear must not look like a trash-level clear. */
.pb-result__boss {
  display: flex; align-items: center; gap: 12px;
  border: 2px solid var(--pb-amber); border-radius: var(--pb-radius);
  background: linear-gradient(100deg, rgba(237,192,96,.16), rgba(20,28,16,.6));
  padding: 10px 14px; margin-bottom: 10px;
  animation: pb-pop .5s .35s cubic-bezier(.2,.9,.3,1.35) backwards;
}
.pb-result__boss-art { line-height: 0; filter: drop-shadow(0 4px 10px rgba(0,0,0,.7)); }
.pb-result__boss-kicker {
  font-size: 11px; letter-spacing: .22em; text-transform: uppercase;
  color: var(--pb-ink-dim); font-weight: 800;
}
.pb-result__boss-name {
  font-size: clamp(17px, 4.6vw, 23px); font-weight: 800; color: var(--pb-amber-hi);
  text-shadow: 0 0 20px rgba(255,217,138,.45);
}

/* Peak-frenzy flex banner (loop 11 fun-ceiling pick) — the results-screen
   payoff for the in-run kill-streak escalation (systems/run.js bumpFrenzy).
   ROUND 21 (docs/design/streaks.md, ui/menus.js's own `result-streak`) — the
   8-tier ladder replaces the old 4-tier one; recoloured here to the SAME
   amber -> gold -> chartreuse law ui/hud.js's `streak-meter` pill and
   systems/vfx.js's STREAK_TIER_COLORS use (never red/pink — the old tier-4
   throbbing mythic pink, `--pb-r-mythic`/#FF4FA3, is retired along with the
   4-tier ladder it belonged to). Own selector, not a shared class with the
   in-run pill — DEV NOTE for whoever's own patch touches this next: keep
   these 8 hex values byte-for-byte identical to vfx.js's STREAK_TIER_COLORS
   if either one ever moves. Shown on wins AND losses — the streak is a brag
   either way. */
.pb-result__streak {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  border: 2px solid var(--pb-amber); border-radius: var(--pb-radius);
  background: linear-gradient(100deg, rgba(237,192,96,.14), rgba(20,28,16,.6));
  padding: 10px 14px; margin-bottom: 10px;
  animation: pb-pop .5s .5s cubic-bezier(.2,.9,.3,1.35) backwards;
}
.pb-result__streak-label {
  font-size: 11px; letter-spacing: .22em; text-transform: uppercase;
  color: var(--pb-ink-dim); font-weight: 800;
}
.pb-result__streak-name {
  font-size: clamp(20px, 5.4vw, 28px); font-weight: 800; letter-spacing: 2px;
  color: var(--pb-amber);
}
.pb-result__streak-num { font-size: 12px; color: var(--pb-ink-dim); font-weight: 700; }
.pb-result__streak[data-tier="1"] .pb-result__streak-name { color: #FFC24A; }
.pb-result__streak[data-tier="2"] .pb-result__streak-name { color: #FFD064; }
.pb-result__streak[data-tier="3"] .pb-result__streak-name { color: #FFE08A; }
.pb-result__streak[data-tier="4"] .pb-result__streak-name { color: #FFEE9E; }
.pb-result__streak[data-tier="5"] .pb-result__streak-name { color: #E8F08A; }
.pb-result__streak[data-tier="6"] .pb-result__streak-name { color: #C8E86B; }
.pb-result__streak[data-tier="7"] .pb-result__streak-name { color: #A9E85A; }
.pb-result__streak[data-tier="8"] {
  border-color: #8FF06A;
  box-shadow: 0 0 20px rgba(143,240,106,.4);
}
.pb-result__streak[data-tier="8"] .pb-result__streak-name {
  color: #8FF06A; animation: pb-streak-throb .6s ease-in-out infinite;
}

.pb-confetti { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.pb-confetti i {
  position: absolute; left: var(--x); top: -6%;
  width: 10px; height: 7px; border-radius: 60% 20% 60% 20%;
  background: var(--pb-moss-300);
  transform: scale(var(--s)); opacity: 0;
  animation: pb-confetti-fall 3.6s cubic-bezier(.35,.1,.6,1) infinite;
  animation-delay: var(--d);
}
.pb-confetti i:nth-child(3n)   { background: var(--pb-amber); }
.pb-confetti i:nth-child(3n+1) { background: var(--pb-wood-300); }
@keyframes pb-confetti-fall {
  0%   { opacity: 0; transform: translateY(0) rotate(var(--r)) scale(var(--s)); }
  10%  { opacity: 1; }
  85%  { opacity: 1; }
  100% { opacity: 0; transform: translateY(112vh) rotate(calc(var(--r) + 540deg)) scale(var(--s)); }
}

/* =====================================================================
   4.24 CHEST REVEAL — face-down cards, a real flip, rarity light-rays.
   ===================================================================== */
/* The reveal is a MODAL, not a scrim over the Caches screen. Opaque ground,
   its own three-row layout (header / stage / footer), and — critically — the
   only scrolling region in it is .pb-reveal__scroll, which is a plain
   min-height:0 flex child with no auto margins. The old version scrolled a
   `.pb-body`, whose `> *:first-child{margin-top:auto}` centring rule pushes
   overflow above the scroll origin as well as below it; scrollTop cannot go
   negative, so the last rows of a 10-pull sat past scrollHeight-clientHeight
   and could not be reached by any means. Do not put the card grid back in a
   .pb-body. */
.pb-reveal {
  /* ROUND 5 TWITCH m9: "Ghost badges over the cache reveal — the cache-list
     cards remain in the DOM at opacity 1 behind the reveal overlay and their
     .pb-card__toptier 'TOP-TIER ODDS' pills punch through it." Root cause:
     mountReveal() appends this screen as a LATER DOM sibling of the chest
     list inside the same unpositioned parent, which is normally enough to
     paint on top — but .pb-card__toptier
     carries an EXPLICIT z-index of its own (1-2) for its own in-card
     layering, and an explicit z-index on a positioned element always wins
     over a sibling with no z-index (computed 'auto', not 0) regardless of
     DOM order. This screen is a full takeover by design (see the doc
     comment below) and must always win that contest.
  */
  /* NOT position: relative — .pb-screen (shared base class, applied
     alongside this one) already sets position:absolute; inset:0, and
     re-declaring position here would win the cascade (this rule is later
     in the file) and silently drop that full-screen sizing. z-index alone
     is enough once an element is already positioned. */
  z-index: 10;
  perspective: 1200px;
  background:
    radial-gradient(120% 70% at 50% 8%, rgba(107,143,58,.20), transparent 62%),
    linear-gradient(180deg, #131A0C 0%, #080B05 100%);
}
.pb-reveal__main {
  flex: 1 1 auto; min-height: 0; position: relative;
  display: flex; flex-direction: column; align-items: center; gap: 10px;
}
.pb-reveal__counter {
  font-size: 13px; font-weight: 800; letter-spacing: .12em;
  color: var(--pb-ink-dim); font-variant-numeric: tabular-nums;
}

/* Phase 1 — the spotlight. Exactly one card, centred, nothing to scroll. */
.pb-reveal__spotlight {
  flex: 1 1 auto; min-height: 0; width: 100%;
  display: flex; align-items: center; justify-content: center;
}
.pb-reveal[data-phase="summary"] .pb-reveal__spotlight { display: none; }
/* data-phase="done" is the single-pull terminal state: the spotlight card stays
   exactly where it landed and only the footer changes. */
.pb-reveal__card.is-spot {
  width: min(78vw, 300px); height: min(46vh, 340px); min-height: 200px;
}
.pb-reveal__card.is-spot .pb-reveal__inner { height: 100%; }
.pb-reveal__card.is-spot .pb-card__name { font-size: 19px; }
.pb-reveal__card.is-spot .pb-card__desc { font-size: 13px; }
.pb-reveal__card.is-spot .pb-card__tag  { font-size: 12px; letter-spacing: .16em; }

/* THE ANTICIPATION. The wind-up before a card turns over, escalating by tier:
   a common ticks over, a legendary shudders and swells first. This is the beat
   the old grid-flip had no room for. */
.pb-reveal__card.is-charging.is-plain { animation: pb-charge-plain .2s ease-out; }
.pb-reveal__card.is-charging.is-big   { animation: pb-charge-big .52s ease-in; }
.pb-reveal__card.is-charging.is-huge  { animation: pb-charge-huge .9s ease-in; }
@keyframes pb-charge-plain { 0% { transform: scale(.96); } 100% { transform: scale(1); } }
@keyframes pb-charge-big {
  0%   { transform: scale(1) rotate(0); }
  100% { transform: scale(1.07) rotate(1.2deg); }
}
@keyframes pb-charge-huge {
  0%   { transform: scale(1) rotate(0); }
  40%  { transform: scale(1.04) rotate(-1.6deg); }
  60%  { transform: scale(1.06) rotate(1.6deg); }
  75%  { transform: scale(1.09) rotate(-2.2deg); }
  88%  { transform: scale(1.12) rotate(2.2deg); }
  100% { transform: scale(1.16) rotate(0); }
}

/* The progress tray — one pip per pull, fixed height, never scrolls. */
.pb-reveal__tray {
  flex: 0 0 auto; display: flex; flex-wrap: wrap; justify-content: center;
  gap: 7px; padding: 4px 0 2px; max-width: 100%;
}
.pb-reveal[data-phase="summary"] .pb-reveal__tray,
.pb-reveal[data-phase="done"] .pb-reveal__tray { display: none; }
.pb-reveal__pip {
  width: 13px; height: 13px; border-radius: 4px;
  border: 2px solid var(--pb-line); background: rgba(255,255,255,.05);
  transition: background .2s ease-out, border-color .2s ease-out, transform .2s ease-out;
}
.pb-reveal__pip.is-lit { transform: scale(1.16); }
.pb-reveal__pip[data-tier="0"].is-lit { background: #9AA0A6; border-color: #9AA0A6; }
.pb-reveal__pip[data-tier="1"].is-lit { background: #7FD84C; border-color: #7FD84C; }
.pb-reveal__pip[data-tier="2"].is-lit { background: #46B2FF; border-color: #46B2FF; }
.pb-reveal__pip[data-tier="3"].is-lit { background: #B268FF; border-color: #B268FF; }
.pb-reveal__pip[data-tier="4"].is-lit { background: #FFB13D; border-color: #FFB13D; box-shadow: 0 0 10px #FFB13D; }
.pb-reveal__pip[data-tier="5"].is-lit { background: #FF4FA3; border-color: #FF4FA3; box-shadow: 0 0 12px #FF4FA3; }

/* Phase 2 — the scrollable summary. THE fix for the unreachable last row. */
.pb-reveal__scroll {
  display: none;
  flex: 1 1 auto; min-height: 0; width: 100%;
  overflow-y: auto; overflow-x: hidden;
  -webkit-overflow-scrolling: touch; touch-action: pan-y;
  padding: 2px 2px 8px;
}
.pb-reveal[data-phase="summary"] .pb-reveal__scroll { display: block; }
.pb-reveal__scroll::-webkit-scrollbar { width: 6px; }
.pb-reveal__scroll::-webkit-scrollbar-thumb { background: var(--pb-bark-500); border-radius: 3px; }
.pb-reveal__card.is-summary { animation: pb-rise .3s ease-out backwards; }

.pb-reveal__stage {
  position: absolute; left: 50%; top: 46%; width: 120vmax; height: 120vmax;
  transform: translate(-50%,-50%); pointer-events: none; opacity: 0;
  transition: opacity .5s ease-out;
  background: radial-gradient(circle, var(--pb-tier, rgba(154,160,166,.45)) 0%, transparent 46%);
}
.pb-reveal__stage[data-tier="1"] { --pb-tier: rgba(127,216,76,.20); opacity: 1; }
.pb-reveal__stage[data-tier="2"] { --pb-tier: rgba(70,178,255,.26); opacity: 1; }
.pb-reveal__stage[data-tier="3"] { --pb-tier: rgba(178,104,255,.34); opacity: 1; }
.pb-reveal__stage[data-tier="4"] { --pb-tier: rgba(255,177,61,.42); opacity: 1; }
.pb-reveal__stage[data-tier="5"] { --pb-tier: rgba(255,79,163,.48); opacity: 1; }
.pb-reveal__stage.is-pulse { animation: pb-stage-pulse .75s ease-out; }
@keyframes pb-stage-pulse { 0% { transform: translate(-50%,-50%) scale(.55); } 100% { transform: translate(-50%,-50%) scale(1); } }

.pb-reveal__grid { perspective: 1000px; }
.pb-reveal__card { position: relative; min-height: 128px; }
.pb-reveal__inner {
  position: relative; width: 100%; height: 100%; min-height: 128px;
  transform-style: preserve-3d; transition: transform .42s cubic-bezier(.3,.8,.3,1.2);
}
.pb-reveal__card[data-revealed="true"] .pb-reveal__inner { transform: rotateY(180deg); }
.pb-reveal__back, .pb-reveal__face {
  position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden;
  border-radius: var(--pb-radius); border: 2px solid var(--pb-line);
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 3px; padding: 10px; text-align: center;
  /* `clip`, not `hidden`. gauntlet-twitch-1.md: "Every .pb-reveal__face is
     itself a scroll container with scrollHeight 754 inside clientHeight 124,
     so a trackpad scroll or a phone flick over a card scrolls the card's own
     contents out of its box." `overflow: hidden` still leaves an element a
     valid scroll container for programmatic AND (in practice, across real
     browsers) wheel-routed scrolling — only the user-facing scrollbar is
     suppressed, `scrollTop` still moves the content. `clip` (CSS Overflow
     Module Level 3) is the keyword that actually removes the scroll
     container: nothing, human or programmatic, can move this box's content
     within itself again, so a summary-grid flick can only ever reach
     `.pb-reveal__scroll` (the intended, single scrolling region — see that
     rule's own comment above) no matter how much a card's content overflows. */
  overflow: clip;
}
.pb-reveal__back {
  background:
    repeating-linear-gradient(45deg, rgba(107,74,42,.35) 0 8px, rgba(59,42,27,.35) 8px 16px),
    linear-gradient(180deg, var(--pb-bark-600), var(--pb-bark-800));
}
/* Painted card back (batch 3): replaces the stripes and the chest glyph. */
.pb-art-revealBack .pb-reveal__back {
  background: url(assets/ui/reveal-back@2x.webp) center / 100% 100% no-repeat;
  border-color: transparent;
}
.pb-art-revealBack .pb-reveal__back > * { display: none; }
.pb-reveal__face {
  transform: rotateY(180deg);
  background: linear-gradient(180deg, rgba(58,78,44,.8), rgba(20,28,16,.94));
  border-color: var(--pb-r, var(--pb-line));
  box-shadow: inset 0 0 22px color-mix(in srgb, var(--pb-r, #888) 30%, transparent);
}
.pb-reveal__face .pb-card__name { font-size: 14px; }
.pb-reveal__face .pb-card__desc { font-size: 11.5px; }
.pb-reveal__art { line-height: 0; filter: drop-shadow(0 3px 6px rgba(0,0,0,.6)); }
/* Rarity light-rays behind the face. Common gets none; they intensify per tier. */
.pb-reveal__rays {
  position: absolute; left: 50%; top: 42%; width: 240%; height: 240%;
  transform: translate(-50%,-50%); pointer-events: none; opacity: 0;
  background: repeating-conic-gradient(from 0deg,
    color-mix(in srgb, var(--pb-r, #fff) 65%, transparent) 0deg 4deg, transparent 4deg 15deg);
  mask-image: radial-gradient(circle, #000 0%, transparent 58%);
  -webkit-mask-image: radial-gradient(circle, #000 0%, transparent 58%);
}
.pb-reveal__rays[data-tier="2"] { opacity: .16; }
.pb-reveal__rays[data-tier="3"] { opacity: .26; }
.pb-reveal__rays[data-tier="4"] { opacity: .36; }
.pb-reveal__rays[data-tier="5"] { opacity: .45; }
.pb-reveal__card[data-revealed="true"] .pb-reveal__rays { animation: pb-rays 18s linear infinite; }
/* Escalating weight per tier: a Common turns over, a Legendary lands. */
.pb-reveal__card.is-plain[data-revealed="true"] { animation: pb-flip-plain .42s ease-out; }
.pb-reveal__card.is-big[data-revealed="true"]   { animation: pb-flip-big .6s cubic-bezier(.2,.9,.3,1.3); z-index: 2; }
.pb-reveal__card.is-huge[data-revealed="true"]  { animation: pb-flip-huge .9s cubic-bezier(.2,.9,.3,1.35); z-index: 3; }
@keyframes pb-flip-plain { 0% { transform: scale(.97); } 100% { transform: none; } }
@keyframes pb-flip-big   { 0% { transform: scale(.9); } 45% { transform: scale(1.12); } 100% { transform: none; } }
@keyframes pb-flip-huge  {
  0%   { transform: scale(.85) rotate(-3deg); }
  35%  { transform: scale(1.28) rotate(2deg); }
  60%  { transform: scale(1.18); }
  100% { transform: none; }
}

/* ---------- 5. Debug panel ---------- */
#debug-panel {
  /* Sits BELOW the header row so it never covers the back button, the HUD's
     level/HP readout, or a screen title. */
  position: absolute; z-index: 60; top: calc(var(--pb-sat) + 62px); left: calc(var(--pb-sal) + 6px);
  max-width: min(340px, 92vw); max-height: 66vh; overflow: auto;
  background: rgba(8,11,6,.93); border: 2px solid var(--pb-r-mythic); border-radius: 10px;
  padding: 8px; font-family: var(--pb-font-num); font-size: 11px; color: var(--pb-moss-200);
  pointer-events: auto;
}
/* 'menu' has no header row to sit below — its hero title+tagline start right
   at the top instead, and at narrow (mobile) widths the tagline wraps to a
   second line that reaches exactly the panel's default 62px offset (gauntlet
   finding: the debug pill's opaque fill sat on top of "...Bramble is
   standing."). main.js stamps body[data-screen] on every navigation so this
   can clear the one screen that actually needs it without touching the 62px
   offset every other screen (run, levelSelect) already clears content at.
   The menu's whole top band (title, tagline, stat panel: measured y10-225 at
   375px wide) is packed edge to edge with no gap the collapsed pill (~186 x
   41.5px) fits into — pushing it to just clear the tagline lands it on the
   currency chips instead, and clearing THOSE lands on the stat rows below.
   The one real gap is under Bramble's portrait (svg.pb-hero__rig, a fixed
   172px-tall block ending ~y225+172=397 relative to the panel above it) and
   above the hero status line — bounded on both sides by fixed-height
   elements, so unlike the text zone above it this gap holds even if the
   tagline's wrap or a save's stat text reflows everything above by a line. */
body[data-screen="menu"] #debug-panel { top: calc(var(--pb-sat) + 405px); }
/* 'run' during a boss fight inserts two extra HUD rows the 62px offset
   predates: the boss name label (ui/hud.js hud-boss-name, y66-87) and its HP
   bar (hud-boss-bar, y95-109) both sit directly below the ordinary level/HP
   row the 62px offset was tuned to clear (gauntlet finding: the boss name
   read "Old Barkrot" behind the debug pill's opaque fill). Those rows are
   fixed-height regardless of viewport width or which boss is up, and nothing
   else occupies y62-115 in ordinary (non-boss) play either — the weapon rack
   is bottom-anchored — so this can unconditionally clear the boss case
   without needing a second data-* hook for "is a boss currently up". */
body[data-screen="run"] #debug-panel { top: calc(var(--pb-sat) + 115px); }
#debug-panel[hidden] { display: none; }
#debug-panel .dbg-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
#debug-panel button {
  font-family: inherit; font-size: 11px; padding: 7px 8px; min-height: 32px;
  background: var(--pb-bark-600); color: var(--pb-moss-200);
  border: 1px solid var(--pb-line); border-radius: 6px; cursor: pointer;
}
#debug-panel button:active { background: var(--pb-bark-500); }
#debug-panel .dbg-stats { white-space: pre-wrap; color: var(--pb-amber); line-height: 1.35; }
#debug-panel .dbg-title {
  color: var(--pb-r-mythic); font-weight: 700; margin-bottom: 5px;
  display: block; width: 100%; text-align: left; background: transparent;
  border: 0; padding: 2px 0; min-height: 0; font-size: 11px;
}
#debug-panel.dbg--min { max-width: 190px; opacity: .82; }
/* ROUND 37 (fast-s5-B5): collapsed, the pill sat at top 62px on every menu
   screen, which is inside the Kit's taller header (its Back button spans
   y19-71 at 430 px) and flush under the Forge's. No corner is free on every
   screen: the bottom band is free on the screens themselves, but the bottom
   sheets (partner, weapon, gear item) run their buttons to 14 px above the
   edge. What IS free everywhere is the left gutter: every screen, header and
   sheet keeps its content at least 12 px in from the left edge. So the
   collapsed pill is a thin vertical tab (11 px wide) hugging the left edge
   at mid-height. 'menu' (the gap under Bramble's portrait) keeps its own
   place. Expanded, the panel still opens at its usual top-left.
   ROUND 37 (fast-s8-B3): 'run' now takes the same tab. Its 115 px place sat
   on the left end of the streak panel (its tier label and the first digit of
   the kill count) and, in a boss fight, on the boss name. The run HUD keeps
   its left gutter at mid-height empty too (the streak panel and boss rows are
   at the top, the rack and the partner button at the bottom). */
body:not([data-screen="menu"]) #debug-panel.dbg--min {
  top: 50%; left: var(--pb-sal); transform: translateY(-50%);
  max-width: none; padding: 5px 0; border-width: 1px; border-left: 0; border-radius: 0 6px 6px 0;
}
body:not([data-screen="menu"]) #debug-panel.dbg--min .dbg-title {
  writing-mode: vertical-rl; margin: 0; padding: 0 1px; font-size: 8px; line-height: 1; width: auto;
}
#debug-panel.dbg--min .dbg-row,
#debug-panel.dbg--min .dbg-stats,
#debug-panel.dbg--min .dbg-pace { display: none; }

/* Round 6 owner directive #4 — the PACE PANEL (main.js buildDebugPanel()). */
#debug-panel .dbg-pace {
  border-top: 1px solid var(--pb-line); margin-top: 6px; padding-top: 6px;
}
#debug-panel .dbg-pace-header {
  color: var(--pb-r-mythic); font-size: 10px; font-weight: 700;
  margin-bottom: 4px; line-height: 1.3;
}
#debug-panel button[data-testid="debug-pace-reset"] { margin-bottom: 6px; width: 100%; }
#debug-panel .dbg-pace-row { margin-bottom: 7px; }
#debug-panel .dbg-pace-label {
  display: flex; justify-content: space-between; font-size: 10px;
  color: var(--pb-moss-200); margin-bottom: 2px;
}
#debug-panel .dbg-pace-val { color: var(--pb-amber); font-weight: 700; }
#debug-panel .dbg-pace-controls { display: flex; align-items: center; gap: 4px; }
#debug-panel .dbg-pace-controls button {
  min-height: 22px; min-width: 22px; padding: 0; font-size: 12px; line-height: 1;
}
#debug-panel .dbg-pace-controls input[type="range"] { flex: 1; min-width: 0; }
#debug-panel .dbg-pace-doc {
  font-size: 9px; color: var(--pb-moss-400, var(--pb-moss-200)); opacity: .8;
  line-height: 1.3; margin-top: 2px;
}

/* ---------- 6. Boot error ---------- */
#boot-error {
  position: absolute; left: 0; right: 0; bottom: 0; max-height: 42vh; z-index: 90; overflow: auto;
  background: rgba(26,11,8,.96); color: #FFC9B8; padding: 12px 14px;
  border-top: 2px solid #C0492E;
  font-family: var(--pb-font-num); font-size: 12px; white-space: pre-wrap;
}
#boot-error pre { margin: 8px 0 0; white-space: pre-wrap; word-break: break-word; }
#boot-error[hidden] { display: none; }

/* ---------- 7. Responsive / a11y ---------- */
@media (max-width: 380px) {
  :root { --pb-tap: 48px; --pb-tap-lg: 58px; --pb-gap: 9px; }
  .pb-card { padding: 10px; font-size: 14px; }
}
@media (min-width: 900px) {
  /* The screens are laid out phone-first (header / scrolling body / footer bar
     pinned to the bottom). On a desktop that leaves a lake of empty space in
     the middle, so widen the gutters and centre the whole column instead. */
  .pb-screen { padding-left: max(var(--pb-sal), 4vw); padding-right: max(var(--pb-sar), 4vw); }
  .pb-screen > .pb-header,
  .pb-screen > .pb-body,
  .pb-screen > .pb-footer { width: 100%; max-width: 820px; margin-left: auto; margin-right: auto; }
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

/* ============================================================================
   8. THE MERGE LADDER + THE MAIN WEAPON SLOT  (round 5, owner directive)
   ui/gearScreen.js. Three components, all rarity-token driven so they inherit
   the same six colours every other rarity surface in the game already uses
   (.pb-r-* sets --pb-r; §4 defines them). Nothing here hard-codes a tier hex.
   ========================================================================= */

/* ---- 8.1 The holdings ladder: one dot per tier, lit when copies are held. */
.pb-ladder {
  display: flex; align-items: stretch; gap: 4px; width: 100%;
  padding: 6px 0 2px;
}
.pb-ladder__rung {
  flex: 1 1 0; min-width: 0;
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  padding: 5px 2px; border-radius: 9px;
  border: 2px solid rgba(255,255,255,.08);
  background: rgba(10,14,7,.55);
  opacity: .42; transition: opacity .18s ease, border-color .18s ease;
}
.pb-ladder__rung.is-held {
  opacity: 1;
  border-color: var(--pb-r, var(--pb-line));
  box-shadow: 0 0 10px -3px var(--pb-r, transparent);
}
.pb-ladder__dot {
  width: 12px; height: 12px; border-radius: 50%;
  background: var(--pb-r, var(--pb-ink-dim));
}
.pb-ladder__n {
  font-size: 12px; font-weight: 800; line-height: 1;
  color: var(--pb-r, var(--pb-ink-dim));
  font-variant-numeric: tabular-nums;
}
/* ROUND 14 — the rung's short name (Com/Unc/Rare/Epic/Leg/Myth), so the row
   reads as a labelled ladder rather than six anonymous coloured dots. Sized
   to still fit six across a 375px phone (`.pb-ladder__rung` is `flex:1 1 0`
   already, so this wraps its own column, not the row). */
.pb-ladder__label {
  font-size: 8.5px; font-weight: 700; letter-spacing: .02em;
  color: var(--pb-ink-dim); text-transform: uppercase; line-height: 1;
}
.pb-ladder__rung.is-held .pb-ladder__label { color: var(--pb-r, var(--pb-ink-dim)); }
/* ---- 8.1b The ladder's own caption (owner: "no merge math" — name what the
   dots ARE before the player has to guess). Sits directly above one weapon's
   `.pb-ladder`; the gear detail panel keeps its existing "MERGE LADDER · THIS
   PIECE" rack label instead of a second copy of this line. */

/* ---- 8.2 The merge control: 3 sockets + the action. */
.pb-merge {
  display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
  width: 100%; margin-top: 4px;
}
.pb-merge__count {
  font-size: 13px; font-weight: 800; letter-spacing: .04em;
  text-transform: uppercase; color: var(--pb-ink-dim);
  font-variant-numeric: tabular-nums;
}
.pb-merge__sockets { display: inline-flex; gap: 5px; }
.pb-merge__socket {
  width: 16px; height: 16px; border-radius: 4px;
  border: 2px dashed rgba(255,255,255,.22);
  background: transparent;
  transition: background .18s ease, border-color .18s ease, transform .18s ease;
}
.pb-merge__socket.is-filled {
  border-style: solid;
  border-color: var(--pb-r, var(--pb-line));
  background: var(--pb-r, var(--pb-line));
  box-shadow: 0 0 9px -2px var(--pb-r, transparent);
}
.pb-merge .pb-btn[data-ready="true"] { animation: pb-merge-ready 1.6s ease-in-out infinite; }
@keyframes pb-merge-ready {
  0%, 100% { box-shadow: 0 0 0 0 rgba(255,209,61,.0); }
  50%      { box-shadow: 0 0 0 4px rgba(255,209,61,.18); }
}

/* ---- 8.3 The merge burst. Anchored to the card that was acted on, never a
       full-screen modal: a player will do this hundreds of times, so it has to
       read in 300ms and never block the next tap (pointer-events:none). */
.pb-mergefx {
  position: absolute; inset: 0; pointer-events: none; overflow: visible;
  z-index: 4;
}
.pb-mergefx__shard {
  position: absolute; left: 50%; top: 50%;
  width: 15px; height: 15px; margin: -7px 0 0 -7px; border-radius: 4px;
  background: var(--merge-color, #fff);
  box-shadow: 0 0 14px -1px var(--merge-color, #fff);
  transform: rotate(calc(var(--i) * 120deg)) translateY(-84px);
  animation: pb-merge-converge .42s cubic-bezier(.6,-0.2,.4,1) forwards;
  animation-delay: calc(var(--i) * .045s);
}
@keyframes pb-merge-converge {
  0%   { transform: rotate(calc(var(--i) * 120deg)) translateY(-84px) scale(.7); opacity: 0; }
  35%  { opacity: 1; }
  100% { transform: rotate(calc(var(--i) * 120deg)) translateY(0) scale(1.25); opacity: 0; }
}
.pb-mergefx__ring {
  position: absolute; left: 50%; top: 50%;
  width: 18px; height: 18px; margin: -9px 0 0 -9px; border-radius: 50%;
  border: 3px solid var(--merge-color, #fff);
  opacity: 0;
  animation: pb-merge-ring .55s ease-out .38s forwards;
}
@keyframes pb-merge-ring {
  0%   { transform: scale(.4); opacity: .95; }
  100% { transform: scale(9);  opacity: 0; }
}
.is-merging { animation: pb-merge-flash .55s ease-out .36s 1; }
@keyframes pb-merge-flash {
  0%   { filter: brightness(1); }
  22%  { filter: brightness(2.1) saturate(1.5); }
  100% { filter: brightness(1); }
}

/* ---- 8.3b THE MYTHIC MERGE. round5 twitch m16: "merge-3 is the load-bearing
   meta hook. Every rung is a small win; the mythic rung is not, and should
   get a screen event." Additive to .pb-mergefx above, not a replacement —
   fires only on the one merge a player does at most a handful of times ever.
   Appended to document.body (see gearScreen.js), outside #overlay's own
   stacking context entirely, so it always wins regardless of whatever
   screen is live underneath; pointer-events:none the whole time so it can
   never eat the next tap. */
.pb-mythicburst {
  position: fixed; inset: 0; z-index: 500; pointer-events: none;
  display: grid; place-items: center; overflow: hidden;
  animation: pb-mythicburst-fade 1.5s ease-out forwards;
}
@keyframes pb-mythicburst-fade {
  0%, 60% { background: radial-gradient(60% 60% at 50% 50%, rgba(255,79,163,.22), transparent 70%); }
  100%    { background: transparent; }
}
.pb-mythicburst__ring {
  position: absolute; width: 40px; height: 40px; border-radius: 50%;
  border: 4px solid var(--pb-r-mythic, #FF4FA3);
  box-shadow: 0 0 40px 4px var(--pb-r-mythic, #FF4FA3);
  animation: pb-mythicburst-ring 1.1s cubic-bezier(.15,.8,.25,1) forwards;
}
.pb-mythicburst__ring--2 { border-color: var(--pb-r-legendary, #FFB13D); animation-delay: .12s; opacity: .8; }
@keyframes pb-mythicburst-ring {
  0%   { transform: scale(.3); opacity: 1; }
  100% { transform: scale(13); opacity: 0; }
}
.pb-mythicburst__label {
  position: relative; text-align: center; color: #FFE9F3;
  font-size: 30px; font-weight: 900; letter-spacing: 3px; line-height: 1.3;
  text-shadow: 0 0 18px rgba(255,79,163,.85), 0 3px 6px rgba(0,0,0,.9);
  animation: pb-mythicburst-label 1.5s ease-out forwards;
}
@keyframes pb-mythicburst-label {
  0%   { opacity: 0; transform: scale(.7); }
  18%  { opacity: 1; transform: scale(1.08); }
  28%  { transform: scale(1); }
  78%  { opacity: 1; }
  100% { opacity: 0; }
}

/* ---- 8.4 The MAIN WEAPON slot. Slot 1 of Bramble's six; the only permanent
       weapon, and the only one whose rarity is decided outside a run. */
.pb-mainw__pick { width: 76px; min-height: 84px; }
.pb-mainw__pick[data-selected="true"] {
  border-color: var(--pb-r, var(--pb-amber));
  box-shadow: 0 0 0 2px var(--pb-r, var(--pb-amber)) inset;
}
.pb-mainw__pick .pb-card__tag { font-size: 9px; }

/* ---- 8.5 The pre-run loadout readout (ui/levelSelect.js). */
.pb-loadout { display: flex; align-items: center; gap: 9px; }
.pb-loadout__art {
  flex: 0 0 auto; display: grid; place-items: center;
  width: 42px; height: 42px; border-radius: 10px;
  border: 2px solid var(--pb-r, var(--pb-line));
  background: rgba(10,14,7,.6);
}
.pb-loadout__txt { display: flex; flex-direction: column; gap: 0; min-width: 0; }

/* ---- 8.6 The honest drop-source list (ui/chestScreen.js). Stacked rows: the
       details are full sentences, so a right-aligned value column shredded
       every one of them into three ragged lines on a 430px phone. */
.pb-source {
  display: flex; flex-direction: column; gap: 1px;
  padding: 6px 0; border-top: 1px solid var(--pb-line);
}
.pb-source:first-of-type { border-top: 0; }
.pb-source__label {
  font-size: 12px; font-weight: 800; letter-spacing: .05em;
  text-transform: uppercase; color: var(--pb-amber);
}
.pb-source__detail { font-size: 13px; line-height: 1.35; color: var(--pb-ink-dim); }

/* =====================================================================
   ROUND 7 — UI DEV "levelSelect" — ITEMS 18, 19, 20 (+ the level-select
   half of item 11). ADDITIVE BLOCK appended at the END of the file per this
   phase's ownership split: another agent edits the sections above this
   marker this phase, so nothing above was touched to land this.

   Source: docs/qa/2026-08-30-combined-report.md section E (the menu
   designer's phone pass) and the full wireframes in
   docs/qa/2026-08-30-menu-designer.md. Consumer: src/ui/levelSelect.js only.
   Measured results: docs/qa/round7-trail.md.
   ===================================================================== */

/* ---- R7.1 The trail footer (ITEM 18): a one-row peek bar at rest, an
   expandable strip on tap, Enter always pinned below both — so .pb-body
   (the fifty-tile trail) is the majority of the screen by default instead
   of the ~70%-of-viewport footer this replaces. ---- */
.pb-trailFooter { flex: 0 0 auto; display: flex; flex-direction: column; gap: 8px; padding-top: 8px; }
.pb-trailFooter__peek {
  display: flex; align-items: center; gap: 8px; width: 100%;
  appearance: none; -webkit-appearance: none; font: inherit; color: var(--pb-cream);
  padding: 9px 12px; min-height: 46px; cursor: pointer; touch-action: manipulation;
}
.pb-trailFooter__peek:focus-visible { outline: 3px solid var(--pb-amber); outline-offset: 2px; }
.pb-trailFooter__peekName {
  flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; text-align: left; font-size: 15px;
}
.pb-trailFooter__chevron { flex: 0 0 auto; font-size: 12px; color: var(--pb-ink-dim); }
/* Collapsed by default: the expand strip has zero height until the JS sets
   `data-expanded="true"` on tap. A plain max-height transition (not
   grid-template-rows) so this holds up on older mobile WebKit too. */
.pb-trailFooter__expand {
  max-height: 0; overflow: hidden; opacity: 0;
  transition: max-height .2s ease-out, opacity .15s ease-out;
}
.pb-trailFooter[data-expanded="true"] .pb-trailFooter__expand { max-height: 480px; opacity: 1; }
.pb-trailFooter__expandInner { display: flex; flex-direction: column; gap: 8px; padding-top: 8px; }
@media (prefers-reduced-motion: reduce) { .pb-trailFooter__expand { transition: none; } }

/* ---- R7.2 Loadout chips (ITEMS 19 + 20): the companion + main-weapon
   readouts, shrunk from full-width panels into a side-by-side chip row that
   fits inside the expand strip instead of eating the footer on their own.
   Both chips are real <button>s (tap affordance for a read-only weapon,
   tap-to-open for the companion sheet), so this resets browser button
   chrome rather than relying on .pb-panel's div-oriented defaults. ---- */
.pb-loadoutRow { display: flex; gap: 8px; }
.pb-loadoutChip {
  flex: 1 1 0; min-width: 0; display: block; text-align: left; cursor: pointer;
  appearance: none; -webkit-appearance: none; font: inherit; color: var(--pb-cream);
  padding: 8px 10px; touch-action: manipulation;
}
.pb-loadoutChip:focus-visible { outline: 3px solid var(--pb-amber); outline-offset: 2px; }
.pb-loadoutChip:active { filter: brightness(1.1); }
.pb-loadoutChip .pb-loadout { gap: 7px; }
.pb-loadoutChip .pb-loadout__art { width: 32px; height: 32px; flex: 0 0 auto; }
.pb-loadoutChip .pb-loadout__txt { min-width: 0; gap: 1px; }
.pb-loadoutChip .pb-rack__label { font-size: 9.5px; }
.pb-loadoutChip .pb-card__name {
  font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.pb-loadoutChip__caret { flex: 0 0 auto; margin-left: auto; color: var(--pb-ink-dim); font-size: 15px; }
/* The rarity pill in the main-weapon chip reuses .pb-chip, sized for a
   standalone badge — too wide once it shares a half-width chip with a name.
   Two classes beat .pb-chip's one on specificity, so this wins regardless of
   the two rules' order in the file. */
.pb-chip.pb-loadoutChip__raritySm { padding: 1px 7px; font-size: 10px; align-self: flex-start; margin-top: 1px; }

/* ---- R7.3 Companion bottom sheet (ITEM 19). `position:fixed` inside the
   screen really does mean the viewport, not just this element's ancestor:
   .pb-screen fills the viewport via inset:0 and nothing between here and it
   carries a transform once the .18s mount animation ends (see the "ENTRY
   ANIMATIONS ARE MOTION-ONLY" rule in §4.1 above). Built once at mount and
   toggled with a class rather than appended/removed per open, so it is torn
   down for free — with the rest of the screen's DOM — when the router
   removes `el` on navigation. ---- */
.pb-sheet__backdrop {
  position: fixed; inset: 0; z-index: 40; background: rgba(6,9,4,.62);
  opacity: 0; pointer-events: none; transition: opacity .2s ease-out;
}
.pb-sheet__backdrop.is-open { opacity: 1; pointer-events: auto; }
.pb-sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 41;
  max-height: 82vh; display: flex; flex-direction: column; min-height: 0;
  background: linear-gradient(180deg, rgba(32,42,20,.98), rgba(14,19,9,.99));
  border: 2px solid var(--pb-line); border-bottom: none;
  border-radius: 18px 18px 0 0; box-shadow: 0 -10px 30px rgba(0,0,0,.55);
  padding: 8px 14px calc(var(--pb-sab) + 14px);
  transform: translateY(102%); pointer-events: none;
  transition: transform .22s cubic-bezier(.2,.85,.3,1);
}
.pb-sheet.is-open { transform: translateY(0); pointer-events: auto; }
@media (prefers-reduced-motion: reduce) { .pb-sheet, .pb-sheet__backdrop { transition: none; } }
.pb-sheet__handle { width: 40px; height: 4px; border-radius: 2px; background: var(--pb-line); margin: 2px auto 8px; flex: 0 0 auto; }
.pb-sheet__header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; flex: 0 0 auto; }
.pb-sheet__title { font-size: 18px; font-weight: 800; color: var(--pb-amber); margin: 0; }
.pb-companionSheet__summary { font-size: 12.5px; text-align: center; margin: 0 0 8px; color: var(--pb-ink-dim); flex: 0 0 auto; }

/* The wrapping grid: every friend + Alone gets a tile with room to wrap its
   full name and carry two ability lines, replacing the old 64px
   horizontal-scroll strip that clipped names ("Clove.") and hid most of the
   roster off-edge with no fade or swipe cue. */
.pb-companionSheet__grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
  gap: 8px; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 2px 2px 4px; min-height: 0;
}
/* .pb-companion / .pb-companion__art / .pb-companion__name are the shared
   base classes gearScreen.js's weapon-swap picker also builds on (its own
   size override, `.pb-mainw__pick`, is the same pattern this follows) — this
   block only ever ADDS a modifier class here, never edits those shared
   rules, so gearScreen's tiles are unaffected by anything below. */
.pb-companionSheet__tile { width: auto; min-height: 108px; padding: 8px 6px 7px; justify-content: flex-start; }
.pb-companionSheet__tile .pb-companion__art { margin-top: 2px; }
.pb-companionSheet__tile .pb-companion__solo { width: 40px; height: 40px; font-size: 24px; }
.pb-companionSheet__tile .pb-companion__name { max-width: none; font-size: 11px; }
.pb-companionSheet__abilities { display: flex; flex-direction: column; gap: 1px; margin-top: 2px; }
.pb-companionSheet__ability {
  font-size: 10px; line-height: 1.25; color: var(--pb-ink-dim); text-align: center; overflow-wrap: break-word;
}
.pb-companionSheet__ability b { color: var(--pb-parch); font-weight: 700; }
.pb-companionSheet__lockCaption {
  font-size: 9.5px; line-height: 1.3; color: #FFB9A0; text-align: center; margin-top: 2px; overflow-wrap: break-word;
}
@media (min-width: 640px) {
  .pb-companionSheet__grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
  .pb-companionSheet__tile .pb-companion__name { font-size: 12px; }
  .pb-companionSheet__ability { font-size: 11px; }
}

/* =====================================================================
   ROUND 8 — UI DEV "levelSelect" — companion pre-entry gate. ADDITIVE BLOCK
   appended at the END of the file, same convention as the ROUND 7 block
   above: this only ever adds rules or overrides via a more-specific
   selector, never edits a rule above in place. Consumer: src/ui/levelSelect.js
   (the `.pb-enterRow` chip+Enter pairing and the gate-mode confirm button
   inside the existing `.pb-sheet`) and src/ui/menus.js (no new CSS needed
   there — it only reuses `.pb-btn--primary`/`.pb-btn--block`, already
   defined above).
   ===================================================================== */

/* ---- R8.1 The Enter row: the companion chip promoted out of the buried
   expand strip to sit directly beside Enter, pinned below the peek bar in
   BOTH its collapsed and expanded states (OWNER: partner choice must be a
   first-class, always-visible element of this row, not one tap deeper).
   `.pb-loadoutChip` on its own is `flex:1 1 0`, sized to share a row evenly
   with a second same-width chip (R7.2 above) — here its partner is the
   full-width primary Enter button, so this fixes the chip to a compact,
   content-sized pill instead and lets Enter keep the room. ---- */
.pb-enterRow { align-items: stretch; }
.pb-enterRow .pb-loadoutChip {
  flex: 0 0 auto; width: 124px; padding: 7px 9px;
}
.pb-enterRow .pb-loadoutChip .pb-loadout__art { width: 28px; height: 28px; }
.pb-enterRow .pb-btn--block { flex: 1 1 auto; width: auto; }

/* ---- R8.2 The gate confirm inside the existing companion sheet: shown only
   while `openCompanionSheet()` is in gate mode (see the JS doc). Both start
   `hidden` and stay visually part of the same sheet — no new panel, no new
   screen, just two more nodes appended after the sheet's pre-existing
   contents. ---- */
.pb-companionSheet__gateNote {
  text-align: center; color: var(--pb-amber); font-weight: 700;
  margin: 0 0 6px;
}
.pb-companionSheet__confirm { flex: 0 0 auto; margin-top: 10px; }

/* =====================================================================
   ROUND 15 GRIEVANCES 13 & 14 — CURRENCY CLARITY + BOSS CACHE TUTORIAL
   Appended per this round's file-ownership rule ("styles.css: append a
   clearly marked section only") rather than interleaved into §4 above.
   Composes existing tokens (--pb-tap, --pb-panel, --pb-line, --pb-amber,
   --pb-shadow, --pb-radius) — no new visual language, per this file's own
   §0 rule.
   ===================================================================== */

/* 15.1 Currency bar chips become tap targets (ui/menus.js currencyBar()).
   .pb-chip's own rules (§4.10) are untouched — other screens still use it on
   a bare <span> — this is an additive modifier only applied to the three
   buttons this round turned into. --pb-tap is 52px, already over the 44px
   floor the brief asked for. */
.pb-chip--tap {
  min-height: var(--pb-tap); min-width: var(--pb-tap);
  cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.pb-chip--tap:active { transform: translateY(1px); }
.pb-chip--tap:focus-visible { outline: 3px solid var(--pb-amber); outline-offset: 2px; }

/* 15.2 The shared currency explainer sheet (`currency-explainer`). A centred
   modal over a dimmed backdrop, same dimming rgba .pb-screen--dim already
   uses (§4.1) so it reads as the same "this is a dialog" language. */
.pb-currency-sheet {
  position: fixed; inset: 0; z-index: 200;
  display: grid; place-items: center;
  background: rgba(8,11,6,.72); backdrop-filter: blur(2px);
  padding: 16px;
}
.pb-currency-sheet[hidden] { display: none; }
.pb-currency-sheet__panel {
  width: min(440px, 100%); max-height: 82vh; overflow-y: auto;
  background: var(--pb-panel); border: 1.5px solid var(--pb-line);
  border-radius: var(--pb-radius); box-shadow: var(--pb-shadow);
  padding: 18px; display: flex; flex-direction: column; gap: 12px;
}
.pb-currency-sheet__line {
  margin: 0; font-size: 14px; line-height: 1.5; color: var(--pb-ink);
}

/* 15.3 Chest card cost/earn/best-for lines (ui/chestScreen.js). Three
   .pb-card__desc lines (§4.9's existing text style, reused verbatim) laid
   out tight underneath the card's flavour desc. */
.pb-card__chestinfo { gap: 2px; margin-top: 4px; }

/* 15.4 The Boss Cache tutorial: its chest-screen card and its own full
   screen (ui/bossCacheScreen.js) both get a warm gold highlight so the
   one-time tutorial reads as a special, can't-miss card rather than one more
   entry in the grid. */
.pb-card--bosscache {
  border-color: var(--pb-amber);
  box-shadow: 0 0 16px rgba(237,192,96,.4), var(--pb-shadow-sm);
}
.pb-bosscache__art {
  display: grid; place-items: center; height: 96px;
  filter: drop-shadow(0 0 18px rgba(237,192,96,.5));
}

/* =====================================================================
   ROUND 15 GRIEVANCE 8 — RED DOTS ("something new here")
   Appended per this round's file-ownership rule ("styles.css: append one
   clearly marked section at the end only"). One component, reused wherever
   a menu control marks something new: an unopened cache, gear that has not
   been equipped, or a level that unlocked but was never entered. See
   systems/meta.js badgeState() for the single rule this renders — every
   consumer (ui/menus.js, ui/levelSelect.js, ui/gearScreen.js,
   ui/chestScreen.js) reads that one function rather than deciding for
   itself, so this file only ever needs to define the dot's LOOK once.
   ===================================================================== */

/* 16.1 The dot itself: a plain 10px red circle with a 2px ring in the base
   background colour so it reads on any surface underneath it — the same
   ring idiom ui/gearScreen.js's own Forge "!" badge already uses
   (the idiom came from the round-17 .pb-forge-entry__badge, removed with the
   in-page forge in round 22 — see ui/gearScreen.js ensureStyles()). No text,
   ever: the dot alone is the whole
   signal, per the grievance ("never a text badge"). No animation either, so
   prefers-reduced-motion has nothing to strip. pointer-events:none so its
   tiny hit box can never steal a tap meant for the control it decorates. */
.pb-dot {
  position: absolute; top: -3px; right: -3px;
  width: 10px; height: 10px; border-radius: 50%;
  background: #E5484D;
  box-shadow: 0 0 0 2px var(--pb-bg);
  pointer-events: none;
  z-index: 2;
}

/* 16.2 Anchor utility for a dot's host control when that control is not
   already a positioning context. `.pb-card` (§4.9) is already
   `position:relative` and `.pb-doll__tile` (ui/gearScreen.js's own injected
   style) is already `position:absolute` — both are valid anchors as they
   stand, no utility needed. `.pb-btn` (§4.6) is not, so any button carrying
   a dot (btn-play, btn-gear, btn-chest) gets this added alongside its
   existing classes. */
.pb-dot-anchor { position: relative; }

/* =====================================================================
   ROUND 16 — FPS meter (owner ask, verbatim: "put an fps meter so i can see
   how many fps im getting on my iphone"). Appended here per this round's
   file-ownership split rather than folded into an existing numbered
   section — see ui/hud.js and main.js for the element and its data.

   PLACEMENT: by the time a boss fight is up, every edge already has a
   claim on it — the top strip is the player's own stat block on the left
   (.pb-hud__top) plus the pause button on the right (.pb-hud__corner), the
   horizontal centre of that same top band is the boss bar once one shows
   (.pb-bossbar — reachable for QA via PB.skipToBoss), and the bottom
   corners are the six-slot loadout rack on the left (.pb-hud__loadout) and
   the companion ability button on the right (.pb-hud__ability, up to 84px
   across on a touch device — see its own `[data-touch="true"]` rule
   above). Bottom-CENTRE, sitting ABOVE both bottom corners rather than
   beside either one, is the one gap that is provably clear of all five
   regardless of dynamic content (a long boss name, how many of the six
   slots are filled): `.pb-hud__ability` is this file's tallest
   bottom-anchored element at bottom+14px, up to 84px tall, so a 120px
   bottom offset here clears its highest point (98px) with real margin,
   and the slot rack never reaches nearly that high. Horizontally centred
   (not pinned to an edge) keeps it clear of the boss bar's own top-anchored
   band purely by being nowhere near the top of the screen. */
.pb-hud__fps {
  position: absolute;
  left: 50%;
  bottom: calc(var(--pb-sab) + 120px);
  transform: translateX(-50%);
  z-index: 6;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  font-family: var(--pb-font-num);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .1px;
  line-height: 1.25;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  /* The brief's own spec: a 1px dark OUTLINE, not a background scrim — the
     number must read over a bright arena tile or a dense swarm without
     adding yet another dark panel to an already-busy HUD. */
  text-shadow:
    -1px -1px 0 #000,  1px -1px 0 #000,
    -1px  1px 0 #000,  1px  1px 0 #000;
}
/* PERF FIX (owner iPhone lag report) — line 1 ("60 FPS · worst 84 ms") is
   now coloured by the WORST raw frame time over the trailing 2s
   (engine/loop.js getFrameStats()), not by the smoothed fps number — a
   hitch the old average could never show now visibly reddens this line
   even while the fps figure it sits next to still reads 60. Thresholds are
   the brief's own numbers (<=20ms good, <=50ms workable, >50ms bad),
   reusing the theme's existing semantic tokens (§1 Tokens). */
.pb-hud__fps-line1[data-tier="good"] { color: var(--pb-good); }
.pb-hud__fps-line1[data-tier="ok"]   { color: var(--pb-amber); }
.pb-hud__fps-line1[data-tier="bad"]  { color: var(--pb-danger); }
/* Line 2 (upd/draw/hitches/quality) is the diagnostic line the owner
   screenshots from his phone — kept a plain, always-legible off-white
   rather than tier-coloured, so it never fights line 1's own colour cue. */
.pb-hud__fps-line2 { color: var(--pb-cream, #F2E8D0); font-weight: 600; }

/* =====================================================================
   ROUND 16 — CACHES AND ADS (owner asks #5, #6, #7). Appended here per this
   round's file-ownership split (chestScreen/adScreen dev), rather than
   folded into the numbered §4 blocks above.

   Covers: the tap-to-open cache sheet (`.pb-cachesheet`, replacing the old
   Open 1/Open 10 footer bar), the daily +gems ad card at the top of the
   Caches grid (`.pb-card--ad`), and the mock full-screen ad placeholder
   (`.pb-adscreen`). All three reuse existing components (`.pb-screen`,
   `.pb-panel`, `.pb-card`, `.pb-btn`, `.pb-col`) for layout/chrome and only
   add what those don't already cover.
   ===================================================================== */

/* The cache sheet is a `.pb-screen` overlay (same technique as `.pb-reveal`
   — a full-screen child appended directly onto the Caches screen's own root,
   so it inherits that root's positioned containing block with no extra
   z-index bookkeeping). `.pb-cachesheet__actions` just widens the gap
   between its (up to two) big open buttons past `.pb-col`'s default 8px —
   these are the screen's primary actions, not a dense settings list. */
.pb-cachesheet__actions { gap: 12px; margin-top: 4px; }

/* Daily gems-ad card (`chest-ad-gems`) — a `.pb-card` like every chest card,
   with a cool teal accent (matching the EVO cross-highlight's own "something
   related, not urgent" colour, styles.css §4.8b) so it never reads as the
   same claim as a chest's rarity-coloured border. Disabled (already watched
   today) dims it exactly like a disabled `.pb-btn`. */
.pb-card--ad {
  border-color: var(--pb-sky);
  box-shadow: 0 0 10px color-mix(in srgb, var(--pb-sky) 30%, transparent), var(--pb-shadow-sm);
}
.pb-card--ad[disabled] { opacity: .58; filter: grayscale(.5); cursor: not-allowed; }

/* The mock ad (`screen-ad`). Plain placeholder by design — ROUND 16 OWNER
   ASK #5: "Set up a mock ad to get ready for ad revenue later" — no
   third-party creative, just a label and a simple animated box standing in
   for wherever a real ad SDK eventually mounts. */
.pb-adscreen__countdown {
  font-family: var(--pb-font-num); font-size: 20px; font-weight: 800;
  color: var(--pb-amber); min-width: 2ch; text-align: right;
  font-variant-numeric: tabular-nums;
}
.pb-adscreen__stage {
  flex: 1 1 auto; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 18px;
}
.pb-adscreen__tag {
  font-size: 13px; font-weight: 800; letter-spacing: .3em; text-transform: uppercase;
  color: var(--pb-ink-dim);
}
.pb-adscreen__box {
  width: min(78vw, 320px); aspect-ratio: 16 / 10;
  display: flex; align-items: center; justify-content: center;
  background: var(--pb-panel-2);
  border: 2px dashed var(--pb-line);
  border-radius: var(--pb-radius);
}
.pb-adscreen__pulse {
  width: 64px; height: 64px; border-radius: 50%;
  background: radial-gradient(circle, var(--pb-amber-hi), var(--pb-amber) 70%, transparent);
  animation: pb-ad-pulse 1.4s ease-in-out infinite;
}
@keyframes pb-ad-pulse {
  0%, 100% { transform: scale(.85); opacity: .7; }
  50%      { transform: scale(1.08); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .pb-adscreen__pulse { animation: none; opacity: .9; }
}

/* =====================================================================
   ROUND 18 — home menu compact row
   Owner (verbatim): "the new features you added cover up the main menu
   characters face, FIX this now." Talents/Checklist/Trailpass were three
   stacked full-width `.pb-btn--block` rows inside the home `.pb-panel`.
   That stack made the panel tall enough that `.pb-hero` (§4.20 above —
   flex:1 1 auto, min-height 190px, overflow:hidden, its content centred by
   justify-content:center) shrank below the height its own centred content
   needs, so the top of the mascot — the face — clipped off flush against
   the panel's own bottom edge, reading as the panel "covering" it.
   Fix: replace the stack with one compact row of three equal icon-over-
   label buttons (ui/menus.js's own `featBtn()` / `.pb-featurerow`), which
   returns the panel to roughly its pre-round-18 height and gives `.pb-hero`
   its headroom back. Do not add height back to this row — that reopens the
   exact clip this section exists to close. */
.pb-featurerow {
  display: flex;
  gap: 8px;
  margin: 2px 0;
}
.pb-featbtn {
  flex: 1 1 0;
  min-width: 0;
  min-height: var(--pb-tap); /* 52px — comfortably over the 44px floor */
  flex-direction: column;
  gap: 2px;
  padding: 7px 4px 6px;
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .1px;
  line-height: 1.15;
}
.pb-featbtn__icon { font-size: 18px; line-height: 1; }
.pb-featbtn__label {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Scoped to the home panel only (ui/menus.js's own "ROUND 18" comment) — a
   tighter vertical rhythm than the shared `.pb-panel`/`.pb-hr`/`.pb-kv`
   defaults use everywhere else, freeing the room `.pb-hero` below needs to
   fit its content without `overflow:hidden` clipping the mascot's face.
   Does not touch the base rules, so every other `.pb-panel` screen
   (Talents, Checklist, Trailpass, Gear, ...) is unaffected.

   ROUND 22 GRIEVANCE (item 9) — `margin-bottom` added: `.pb-body`'s two
   children (this panel, `.pb-hero`) used to touch at 0px, so any growth in
   this panel's own content (the account XP row added after ROUND 18, on a
   narrow phone) ate directly into `.pb-hero`'s box with no slack — measured
   live at 375x812, the rig SVG's own top edge sat 17px ABOVE `.pb-hero`'s
   own top, i.e. under this panel's last row. A fixed gap here is a floor
   that holds regardless of how tall either side's content gets. */
.pb-home-panel { padding: 6px 14px; margin-bottom: 14px; }
.pb-home-panel .pb-hr { margin: 1px 0; }
.pb-home-panel .pb-kv { padding: 0; }

/* ============================================================================
   ROUND 19 — build version stamp (loading screen + pause menu)
   ========================================================================== */
.pb-version-tag { font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--pb-ink-dim, #9aa38f); opacity: 0.85; margin: 0; letter-spacing: 0.02em; }
.pb-splash__version { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
.pb-pause__version { text-align: center; padding-top: 6px; }

/* ============================================================================
   ROUND 21 (docs/design/streaks.md §2) — THE SCREEN-EDGE GLOW.
   `.pb-streak__hold`/`.pb-streak__meaning` (ITEM 9's old badge additions)
   are retired along with the badge itself — see §4.21's `.pb-meter__hold`/
   `.pb-meter__meaning` above, which replace them one-for-one inside the new
   pill. What is left here is `.pb-streakglow` alone, rebuilt for the 8-tier
   law: SHAPE is now top/bottom-EDGE-WEIGHTED, not the full-perimeter vignette
   `.pb-danger` (§4.9) uses — two inset shadows, each offset toward one edge
   with a negative spread so the source rect never reaches the screen centre,
   rather than one symmetric `inset 0 0 Rpx` — so a streak glow and a low-HP
   vignette can never be mistaken for the same cue evenColour-blind (streaks
   .md §0's whole "colour-and-shape collision" post-mortem). COLOUR is the
   same amber -> gold -> chartreuse law as everywhere else, turning on from
   Barkfire (tier 3) rather than only at the old ladder's PineBurst, and
   NEVER the mythic pink (--pb-r-mythic/#FF4FA3) tier 4 used to throb in.
   TEMPO breathes at 2.2s, 1.6s only at PineBurst (tier 8) — `--glow-lo`/
   `--glow-hi` per tier feed ONE shared keyframe rather than eight separate
   ones. z-index stays 3, under `.pb-danger`'s 4, so a genuinely critical HP
   vignette always reads over it. Gated by ui/hud.js off
   `save.settings.visual?.streakEffects !== false`, hidden by default via
   inline `hidden`.
   ========================================================================== */
.pb-streakglow {
  position: absolute; inset: 0; pointer-events: none; z-index: 3;
  animation: pb-streakglow-pulse 2.2s ease-in-out infinite;
}
.pb-streakglow[data-tier="8"] { animation-duration: 1.6s; }
@keyframes pb-streakglow-pulse { 0%,100% { opacity: var(--glow-lo, .35); } 50% { opacity: var(--glow-hi, .55); } }
/* ROUND 21 FIX ITEM 1 (Opus review, Criterion 1 — "the headline") — tiers
   3-5 raised, tiers 3-4 hue-shifted off the scene's own amber axis. Proved
   invisible at the ORIGINAL .35/.45/.48-.55/.70/.74 alpha ladder and
   #FFE08A/#FFEE9E colours by an isolated A/B on a frozen frame (pinned to
   the animation's peak): mean per-pixel luminance delta over the top/bottom
   70px bands was ~0 — `#FFE08A` is a pale cream-gold sitting in the SAME hue
   family as this game's already-warm scene (amber HUD chrome, gold coins,
   warm ground pools, a yellow-green Foxfire aura), so it added luminance
   without adding CHROMA CONTRAST. Fix is two independent moves, per the
   review's own isolated controls (alpha alone only got "just perceptible" at
   2x the shipped peak; colour alone is what actually popped): raise the
   alpha ladder AND cool tiers 3-4 toward green (`#EAF09A`/`#DFF08E`) so the
   cue reads as a distinct hue, not just brighter cream. Tier 5 keeps its
   existing #E8F08A (already closer to the chartreuse end and not flagged by
   the review's own isolated A/B). Tiers 6-8 are UNTOUCHED — the review
   confirms they "already sit at .50-.85 with far larger radii and read
   correctly". Shape (top/bottom two-shadow split), tempo (2.2s / 1.6s at
   tier 8) and z-index (3, under .pb-danger's 4) are also untouched — the
   review calls those "correct" and this fix does not touch them.
   Acceptance: freeze a Level-3 scene (PB.setTimescale(0)), pin the glow to
   its peak, capture two frames differing only in .pb-streakglow's
   visibility — mean per-pixel luminance delta over the top/bottom 70px
   bands must be >= 12/255 at tier 3 (measured ~0 before this fix). Also
   updates docs/design/streaks.md §2 so the document stops carrying the
   numbers that failed — see that file's own changelog note. */
.pb-streakglow[data-tier="3"] {
  --glow-lo: .55; --glow-hi: .85;
  box-shadow: inset 0 60px 100px -40px #EAF09A, inset 0 -60px 100px -40px #EAF09A;
}
.pb-streakglow[data-tier="4"] {
  --glow-lo: .62; --glow-hi: .92;
  box-shadow: inset 0 80px 130px -50px #DFF08E, inset 0 -80px 130px -50px #DFF08E;
}
.pb-streakglow[data-tier="5"] {
  --glow-lo: .66; --glow-hi: .95;
  box-shadow: inset 0 88px 145px -55px #E8F08A, inset 0 -88px 145px -55px #E8F08A;
}
.pb-streakglow[data-tier="6"] {
  --glow-lo: .50; --glow-hi: .78;
  box-shadow: inset 0 96px 155px -60px #C8E86B, inset 0 -96px 155px -60px #C8E86B;
}
.pb-streakglow[data-tier="7"] {
  --glow-lo: .52; --glow-hi: .81;
  box-shadow: inset 0 104px 170px -65px #A9E85A, inset 0 -104px 170px -65px #A9E85A;
}
.pb-streakglow[data-tier="8"] {
  --glow-lo: .55; --glow-hi: .85;
  box-shadow: inset 0 112px 185px -70px #8FF06A, inset 0 -112px 185px -70px #8FF06A;
}
/* ROUND 21 FIX ITEM 7 (Opus review, Criterion 3) — streaks.md §4: "every
   glow, tint and aura fades over 0.5s — never a hard cut." Before this fix
   the glow held at full breathing brightness through the whole post-break
   window ui/hud.js keeps it visible for, then `hidden` cut it instantly —
   measured live, opacity 0.572 (full brightness) at 0.53s post-break, then
   gone with no transition at all. `.is-breaking` (toggled by ui/hud.js the
   same tick the break fires, restarted via a forced reflow on every new
   break) REPLACES the breathing keyframe with a single monotonic fade from
   the tier's own peak (`--glow-hi`, still set by whichever
   `[data-tier]` rule matched at break time) down to 0 over the SAME 0.5s
   window the element stays visible for — two class selectors beats the
   one-class `[data-tier]` pulse rule's specificity regardless of source
   order, so this cleanly overrides it at every tier including 8's shorter
   duration. `forwards` holds the final 0 so there is nothing to jump when
   `hidden` takes over right after. */
.pb-streakglow.is-breaking {
  animation: pb-streakglow-fade 0.5s ease-out forwards;
}
@keyframes pb-streakglow-fade { from { opacity: var(--glow-hi, .55); } to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  .pb-streakglow { animation: none; opacity: .65; }
  .pb-streakglow.is-breaking { animation: none; opacity: 0; }
  .pb-meter__meaning { animation: none; }
  .pb-meter__meaning.is-swapping { animation: none; }
  /* The caption's fade is its ONLY visibility mechanism (no JS timer swaps
     it in/out) — reduced motion drops the animation but keeps the element
     fully legible at its own peak (82% alpha, baked into each `[data-tier]`
     color already) rather than leaving it stuck at the keyframe's 0%. */
  .pb-streakcaption.is-on { animation: none; opacity: 1; }
}

/* ============================================================================
   DEV A — VISUAL EFFECTS SETTINGS PANEL (docs/design/visual-fidelity.md)
   ui/menus.js mountSettings()'s new hidden panel (testid
   screen-settings-visual, opened by btn-visual-settings), same "hidden
   .pb-panel toggled from a button" pattern the Credits panel right above it
   in that same screen already uses. Every control inside it is a plain
   `.pb-btn.pb-btn--block` — already thumb-sized (`.pb-btn`'s own
   min-height: var(--pb-tap), 52px/48px per the §1 breakpoint below) and
   already themed, so this section only tightens the panel's own internal
   rhythm: eight stacked rows (Quality, Particles, five toggles, the
   damage-numbers alias) would otherwise read looser than the rest of the
   Settings screen that hosts them, the same reason `.pb-home-panel` above
   exists for the Home screen's own panel.
   ========================================================================== */
.pb-visual-panel { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
.pb-visual-panel > .pb-btn--block { width: 100%; }

/* ============================================================================
   ROUND 21 (Dev B, docs/design/streaks.md §3) — BURST CACHE REWARDS
   Two small additions, both purely additive (no existing rule touched): the
   results-screen row of earned-cache chips under the peak-streak banner, and
   the muted, non-interactive info card on the Caches screen that explains
   the three Burst Caches. `.pb-result__streak[data-tier]`'s own per-tier
   colour ladder is Dev A's territory (the 8-tier rewrite) and is
   deliberately left untouched here.
   ========================================================================== */
/* Chip row, ui/menus.js mountResult() `result-streak-caches` — reuses
   `.pb-row` (flex+gap+wrap) and `.pb-chip` (§4.10) as-is; only the spacing
   above the row needed a home. */
.pb-result__streak-caches { margin-top: 6px; justify-content: center; }

/* Non-purchasable info card, ui/chestScreen.js `chest-burst-cache` — a plain
   `<div class="pb-card pb-card--info">`, not a `<button>` (nothing to tap).
   Dashed border is the only signal that separates it from an openable cache
   at a glance; every other rule (`.pb-card`, `.pb-card__tag`,
   `.pb-card__chestinfo`, §15.3) is reused unchanged. */
.pb-card--info { border-style: dashed; cursor: default; }

/* ============================================================================
   ROUND 22 GRIEVANCE (item 8, owner verbatim): "her moves are better, but I
   still don't understand what the circle does and it was never explained to
   me. Explain it in-game where the player will see it." ui/companionGate.js
   `tile()`'s Passive/Active cards, consumer ONLY that file — its own
   `pickerGrid`/`btn` carry an EXTRA `pb-companionGate__*` class alongside
   the existing shared `pb-companionSheet__grid`/`pb-companionSheet__tile`,
   so this section only ever ADDS new, more-specific rules and never edits
   those shared base rules in place: ui/levelSelect.js's own companion sheet
   (same shared classes, a DIFFERENT file this round — see this file's
   ownership note at its own top) renders exactly as it did before.

   Layout: single-column full-width rows (was the shared grid's own
   multi-column auto-fill of ~100px squares) — the two cards' full sentences
   need real width, not a 100px tile. Rows are still simple flex-wrap
   children of `.pb-companionSheet__grid`'s own `display:grid`, so switching
   just this screen to one column is the one-line override below.
   ========================================================================== */
/* `display:flex` (not grid): a single-column CSS Grid with `grid-auto-rows:
   auto` measured LIVE at 390x844 was locking every row to a uniform 53px —
   the icon column's own height, not the much taller two-card column next to
   it — so every tile's real content (up to ~245px, two cards + a locked
   caption) painted outside its box and visibly overlapped the row below.
   `.pb-companionGate__tile`'s content was correct (confirmed via
   getBoundingClientRect on `.pb-companionGate__body` — 163px, matching its
   cards) — only the GRID TRACK'S height was wrong. A flex column has no
   independent "track" to mis-size: each child gets its own content height
   directly. Only one column ever existed here, so this loses nothing grid
   gave it. */
.pb-companionGate__grid { display: flex; flex-direction: column; gap: 10px; }
.pb-companionGate__tile {
  width: auto; min-height: 0; flex-direction: row; align-items: flex-start;
  gap: 10px; padding: 10px 12px; text-align: left;
}
.pb-companionGate__tile .pb-companion__art { margin-top: 2px; flex: 0 0 auto; }
.pb-companionGate__tile .pb-companion__name { max-width: none; font-size: 13px; text-align: left; }
.pb-companionGate__body { display: flex; flex-direction: column; gap: 4px; flex: 1 1 auto; min-width: 0; }
.pb-companionGate__cards { display: flex; flex-direction: column; gap: 6px; margin-top: 2px; }
.pb-companionGate__card {
  background: rgba(0,0,0,.22); border: 1px solid var(--pb-line);
  border-radius: 8px; padding: 6px 8px;
}
.pb-companionGate__cardLabel {
  font-size: 9.5px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase;
  color: var(--pb-amber);
}
.pb-companionGate__cardName { font-size: 11.5px; font-weight: 700; color: var(--pb-parch); margin-left: 4px; }
/* The trigger+reach line (activeMeta(), companionGate.js) — deliberately its
   own smaller, dimmer row between the name and the prose, same visual weight
   `.pb-kv__k` uses elsewhere for a secondary data line under a headline. */
.pb-companionGate__cardMeta { display: block; font-size: 10px; color: var(--pb-ink-dim); margin: 2px 0; }
.pb-companionGate__cardDesc { font-size: 11px; line-height: 1.35; color: var(--pb-ink-dim); margin: 2px 0 0; }

/* =====================================================================
   ROUND 25 — GEAR ITEM SHEET + CHIP EXPLAINER (ui/gearScreen.js)
   docs/design/round25-forge-gear.md §7. Per this round's file-ownership
   split, gearScreen.js's UI-GEAR dev only adds rules here for these two
   things — every other gear-screen rule stays in that file's own injected
   ensureStyles() as before. No colour token here is new; every var(--pb-*)
   below already exists.

   `.pb-gsheet` (the full-screen page base for the set/copies/main-weapon
   sub-sheets) is defined in ui/gearScreen.js's own injected <style>, loaded
   at runtime. `.pb-gsheet--item` is a BEM modifier turning that base into a
   bottom sheet instead of a full page; it is applied ALONGSIDE the base
   class (`class="pb-gsheet pb-gsheet--item"`), and every rule below targets
   it with the compound selector `.pb-gsheet.pb-gsheet--item` so its
   specificity (0,0,2,0) always beats the base class alone (0,0,1,0)
   regardless of which of the two <style> tags happens to load/inject last —
   this file's own load order relative to a runtime-injected <style> is not
   guaranteed, so relying on source order alone would be fragile. */
.pb-gitem-backdrop {
  position: fixed; inset: 0; z-index: 44;
  background: rgba(8,11,6,.6);
}
.pb-gitem-backdrop[hidden] { display: none; }
.pb-gsheet.pb-gsheet--item {
  position: fixed; top: auto; right: 0; bottom: 0; left: 0; z-index: 45;
  max-height: 55vh; overflow: hidden;
  border-radius: 16px 16px 0 0;
  border: 1.5px solid var(--pb-line); border-bottom: none;
  box-shadow: 0 -10px 28px rgba(0,0,0,.45);
  transition: transform .15s ease;
  padding-bottom: var(--pb-sab, 0px);
}
.pb-gsheet--item__handle {
  flex: 0 0 auto; display: flex; align-items: center; justify-content: center;
  padding: 8px 0 4px; touch-action: none; cursor: grab;
}
.pb-gsheet--item__handle i { display: block; width: 36px; height: 4px; border-radius: 2px; background: var(--pb-line); pointer-events: none; }
.pb-gsheet--item__header { display: flex; align-items: center; gap: 8px; padding: 0 14px 8px; flex: 0 0 auto; }
.pb-gsheet--item__header .pb-card__name { font-size: 17px; }
.pb-gsheet--item__body {
  flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch;
  padding: 0 14px; display: flex; flex-direction: column; gap: 8px; min-height: 0;
}
.pb-gsheet--item__id { display: flex; align-items: center; gap: 12px; margin: 2px 0 8px; }
.pb-gsheet--item__art {
  flex: 0 0 auto; width: 60px; height: 60px; display: flex; align-items: center; justify-content: center;
  border-radius: 14px; background: var(--pb-panel-2); border: 1.5px solid var(--pb-r,var(--pb-line));
}
.pb-gsheet--item__idtext { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pb-gsheet--item__static { font-size: 11.5px; line-height: 1.35; color: var(--pb-ink-dim); padding: 4px 2px 8px; }
.pb-gsheet--item__actions {
  flex: 0 0 auto; display: flex; gap: 8px;
  padding: 8px 14px calc(10px + var(--pb-sab,0px)); border-top: 1.5px solid var(--pb-line);
}
.pb-gsheet--item__actionslot { flex: 1 1 0; min-width: 0; display: flex; }
.pb-gsheet--item__actionslot .pb-btn { width: 100%; }

/* The chip explainer (testid chip-explainer) — one shared small sheet for
   the four total chips (ATK/HP/Coins/Patterns), ≤30vh, same bottom-sheet /
   backdrop idiom as the item sheet above but its own (smaller, simpler —
   no drag-to-dismiss) shell, since it is single-purpose informational text
   rather than an item's actions. */
.pb-chip-explainer-backdrop {
  position: fixed; inset: 0; z-index: 46;
  background: rgba(8,11,6,.55);
}
.pb-chip-explainer-backdrop[hidden] { display: none; }
.pb-chip-explainer {
  max-height: 30vh; overflow-y: auto;
  background: var(--pb-bg); border-radius: 16px 16px 0 0;
  border: 1.5px solid var(--pb-line); border-bottom: none;
  box-shadow: 0 -10px 28px rgba(0,0,0,.45);
  padding: 8px 16px calc(14px + var(--pb-sab,0px));
  position: fixed; left: 0; right: 0; bottom: 0;
}
.pb-chip-explainer__handle { display: block; width: 36px; height: 4px; border-radius: 2px; background: var(--pb-line); margin: 0 auto 10px; }
.pb-chip-explainer__title { font-size: 14px; font-weight: 800; color: var(--pb-ink); margin: 0 0 4px; }
.pb-chip-explainer__text { font-size: 12.5px; line-height: 1.4; color: var(--pb-ink-dim); margin: 0 0 10px; }

/* =====================================================================
   ROUND 27 — HUD RULES (docs/design/round27-feel-and-balance.md §3)
   Owned by this round's HUD dev: the item sheet (item 4), the elite-cache
   lottery reel and its "rolling" chip (item 9), and the evolution "or" pill
   (item 5, hud half). No colour literal is introduced anywhere below: every
   colour is an existing var(--pb-*) token or an existing rgba scrim value
   copied from the sheet/backdrop idiom this section reuses.
   ===================================================================== */

/* ---- 27.1 The item sheet (ui/hud.js buildItemSheet, testid hud-item-sheet).
   The round-25 gear sheet's shape (`.pb-gsheet--item` above: fixed to the
   bottom edge, a drag handle, a backdrop, <=55% of the viewport) as the HUD's
   own read-only panel.

   WHY IT CARRIES A z-INDEX AT ALL. It is appended to `hud-root`, which the
   router mounts as an EARLIER sibling of whatever modal is open (the pause
   screen, `.pb-screen`, z-index auto). hud-root is `position: absolute` with
   `z-index: auto`, so it establishes no stacking context and a positive
   z-index here compares directly against that sibling — which is exactly how
   this sheet paints above the pause menu it is opened from. 64/65 sits above
   every HUD element (the ability button's 45, the tutorial sheet's 58) and
   below #boot-error's 90, which must always win. hud-root is
   `.pb-screen--clear` (pointer-events: none), so both parts opt back in. */
.pb-itemsheet-backdrop {
  position: fixed; inset: 0; z-index: 64;
  background: rgba(8,11,6,.6);
  pointer-events: auto;
}
.pb-itemsheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 65;
  pointer-events: auto;
  display: flex; flex-direction: column;
  max-height: min(55vh, calc(var(--pb-vh, 100dvh) * 0.55));
  overflow: hidden;
  background: var(--pb-bg);
  border: 1.5px solid var(--pb-line); border-bottom: none;
  border-radius: 16px 16px 0 0;
  box-shadow: 0 -10px 28px rgba(0,0,0,.45);
  padding-bottom: var(--pb-sab, 0px);
}
.pb-itemsheet__handle {
  flex: 0 0 auto; display: flex; align-items: center; justify-content: center;
  padding: 8px 0 4px; touch-action: manipulation; cursor: pointer;
}
.pb-itemsheet__handle i {
  display: block; width: 36px; height: 4px; border-radius: 2px;
  background: var(--pb-line); pointer-events: none;
}
.pb-itemsheet__header {
  flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
  padding: 0 14px 8px;
}
.pb-itemsheet__art {
  flex: 0 0 auto; width: 56px; height: 56px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 14px; background: var(--pb-panel-2);
  border: 1.5px solid var(--pb-r, var(--pb-line));
}
.pb-itemsheet__idtext { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pb-itemsheet__stage {
  font-family: var(--pb-font-num); font-size: 12.5px; font-weight: 800;
  letter-spacing: .4px; color: var(--pb-amber);
}
.pb-itemsheet__body {
  flex: 1 1 auto; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch;
  padding: 0 14px calc(12px + var(--pb-sab, 0px));
  display: flex; flex-direction: column; gap: 8px;
}
.pb-itemsheet__stats {
  display: flex; flex-direction: column; gap: 3px;
  padding: 8px 10px; border-radius: 10px;
  background: var(--pb-panel-2); border: 1.5px solid var(--pb-line);
}
.pb-itemsheet__stat { font-size: 12.5px; line-height: 1.35; color: var(--pb-ink); }
.pb-itemsheet__next,
.pb-itemsheet__evo { font-size: 12px; line-height: 1.4; color: var(--pb-ink-dim); margin: 0; }
.pb-itemsheet__evo { color: var(--pb-amber); font-weight: 700; }

/* ---- 27.2 The elite-cache lottery reel (ui/hud.js buildEliteCacheOverlay,
   testid cache-reel). Three windows over the odds they are rolled at; the
   `is-active` highlight hops between them on every tick and stops on the
   prize, which then flashes at a size set by the prize itself (item 9's
   "1 = amber, 3 = gold, 5 = chartreuse" — --pb-amber / --pb-amber-hi /
   --pb-moss-200, the same ladder the streak meter's own tier colours walk). */
.pb-reel {
  --pb-reel-c: var(--pb-amber);
  --pb-reel-flash: 14px;
  display: flex; gap: 8px; justify-content: center; align-items: stretch;
  margin: 6px 0 2px;
}
.pb-reel[data-prize="1"] { --pb-reel-c: var(--pb-amber);    --pb-reel-flash: 16px; }
.pb-reel[data-prize="3"] { --pb-reel-c: var(--pb-amber-hi); --pb-reel-flash: 30px; }
.pb-reel[data-prize="5"] { --pb-reel-c: var(--pb-moss-200); --pb-reel-flash: 48px; }
.pb-reel__win {
  min-width: 62px; padding: 6px 10px 5px;
  display: flex; flex-direction: column; align-items: center; gap: 1px;
  border-radius: 12px;
  background: var(--pb-panel-2);
  border: 2px solid var(--pb-line);
  transition: transform .08s ease, border-color .08s ease;
}
.pb-reel__val {
  font-family: var(--pb-font-num); font-size: 26px; font-weight: 800; line-height: 1;
  color: var(--pb-ink-dim);
}
.pb-reel__odds {
  font-family: var(--pb-font-num); font-size: 10.5px; font-weight: 700;
  letter-spacing: .6px; color: var(--pb-ink-dim); opacity: .75;
}
.pb-reel__win.is-active {
  border-color: var(--pb-reel-c);
  transform: translateY(-2px) scale(1.06);
}
.pb-reel__win.is-active .pb-reel__val { color: var(--pb-reel-c); }
.pb-reel__win.is-prize {
  border-color: var(--pb-reel-c);
  animation: pb-reel-land .42s ease-out 1;
}
.pb-reel__win.is-prize .pb-reel__val { color: var(--pb-reel-c); }
.pb-reel__win.is-prize .pb-reel__odds { opacity: 1; color: var(--pb-reel-c); }
@keyframes pb-reel-land {
  0%   { transform: scale(1.06); box-shadow: 0 0 0 rgba(0,0,0,0); }
  35%  { transform: scale(1.22); box-shadow: 0 0 var(--pb-reel-flash) var(--pb-reel-c); }
  100% { transform: scale(1.08); box-shadow: 0 0 calc(var(--pb-reel-flash) * .4) var(--pb-reel-c); }
}
@media (prefers-reduced-motion: reduce) {
  .pb-reel__win { transition: none; }
  .pb-reel__win.is-prize { animation: none; box-shadow: 0 0 var(--pb-reel-flash) var(--pb-reel-c); }
}

/* ---- 27.3 The "ELITE CACHE — rolling…" chip, absolutely positioned to hang
   off the bottom edge of `.pb-hud__top` so it sits directly under the timer
   row without changing the block's measured height (item 9). Shown only
   while the reel spins. */
.pb-hud__cachechip {
  /* OUT OF FLOW, hanging off the bottom edge of `.pb-hud__top` (which is
     itself absolutely positioned): as a flow child it grew the block by 27.4px
     for the length of the spin, and `--pb-top-h` — the measured height every
     other top-anchored element chains its own `top` off (syncTopHeight() in
     ui/hud.js) — moved the boss bar and the streak meter down and back with
     it. Measured 92.8px -> 120.2px -> 92.8px before this; 92.8px throughout
     after. */
  position: absolute; top: 100%; left: 0; margin-top: 3px;
  padding: 2px 9px; border-radius: 999px;
  background: rgba(8,11,6,.72);
  border: 1.5px solid var(--pb-line-hi);
  color: var(--pb-amber);
  font-size: 11px; font-weight: 800; letter-spacing: .8px;
  text-shadow: 0 1px 3px rgba(0,0,0,.95);
}
.pb-hud__cachechip[hidden] { display: none; }

/* ---- 27.4 The evolution "or" pill (item 5, hud half). Two partner icons
   touching read as "needs both"; this is the whole fix, on the level-up card
   (`.pb-card__evo`) and on the HUD passive tile's tiny badge alike. */
.pb-evo-or {
  flex: 0 0 auto; align-self: center;
  padding: 0 4px; border-radius: 999px;
  background: var(--pb-panel-2); border: 1px solid var(--pb-line);
  color: var(--pb-ink-dim);
  font-size: 9px; font-weight: 800; line-height: 13px;
  letter-spacing: .4px; text-transform: lowercase;
}
.pb-evo-or--tile {
  padding: 0 2px; font-size: 6.5px; line-height: 9px; border-radius: 4px;
}
