  :root { color-scheme: dark; --tscale:1; --hp:#ff2e4d; --bld:#8a1424; --bg:#12151d; --panel:#1c2637; --panel2:#141c2a; --inset:#121a26; --bar-track:#141c28; --line:rgba(150,178,214,0.30); --neon:#39ff14; --teal:#00d4b8; --pink:#e879f9; --cyan:#8aebff; --amber:#ffb733; --violet:#a96bff; --red:#ff4d4d; --txt:#f5f9ff; --dim:#8f9eb6; --sub:#aebdd6; --title-gold:#ffd479; --space-xs:4px; --space-sm:6px; --space-md:8px; --space-lg:10px; --space-xl:14px; --space-xxl:18px; --radius-sm:4px; --radius-md:6px; --radius-lg:8px; --radius-xl:10px; --radius-pill:999px; --fs-tiny:calc(10px*var(--tscale)); --fs-xs:calc(10px*var(--tscale)); --fs-sm:calc(11px*var(--tscale)); --fs-body:calc(12px*var(--tscale)); --fs-base:calc(13px*var(--tscale)); --fs-md:calc(14px*var(--tscale)); --fs-lg:calc(15px*var(--tscale)); --fs-xl:calc(16px*var(--tscale)); --fs-xxl:calc(18px*var(--tscale)); --mono:ui-monospace,"SF Mono","JetBrains Mono","Cascadia Code",Menlo,Consolas,"Courier New",monospace; --shadow-card:0 6px 22px rgba(0,0,0,.42); }
  * { box-sizing:border-box; }
  /* Always reserve the scrollbar gutter so short tabs (Overview/Exchange) don't re-center & shift right */
  /* overscroll-behavior-y:none kills pull-to-refresh. It has to be NONE, not CONTAIN --
     contain only stops scroll CHAINING and explicitly leaves the local refresh/bounce
     affordance intact, which is the very gesture being complained about. Set on <html>
     because the root's value is what propagates to the viewport; a value on <body> is
     ignored once the root has a non-auto one.
     Why it matters here beyond taste: this is a live PWA/TWA over a session API, so an
     accidental refresh is not a free redraw -- it is a full reboot of a stateful client
     (boot screen, /state refetch, any open modal or in-flight combat view discarded). The
     gesture is a one-finger downward swipe at the top of the page, which is exactly how you
     scroll back up to the map, so it fires by accident far more than on purpose.
     -y only: horizontal is left alone so .boardscroll/.sptrack keep their own contain. */
  html { overflow-y:scroll; overflow-x:hidden; scrollbar-gutter:stable; overscroll-behavior-y:none; }
  /* Scrollbars: present, not shouting. They were a neon-green thumb on a dark groove —
     full brand treatment on a control whose entire job is "there is more below". In a UI
     where neon MEANS something (rarity, health, danger), spending it on furniture is what
     made every screen feel busy. Now a slate thumb on a transparent track.

     MEASURED, so you don't repeat my mistake: Chromium honours `scrollbar-width` and then
     IGNORES ::-webkit-scrollbar entirely. `scrollbar-color` is the ONLY line below that
     does anything in Chrome — the old gradient thumb and its 6px glow never rendered
     there at all, and the neon you actually saw came from scrollbar-color. The ::-webkit
     rules are kept for WebKit/older Safari, which has no scrollbar-width. That also means
     :hover neon is WebKit-only, and the real Chrome width is ~10px ("thin"), not the 8px
     declared below. Change BOTH halves or you'll change nothing. */
  * { scrollbar-width:thin; scrollbar-color:rgba(126,156,196,.30) transparent; }
  ::-webkit-scrollbar { width:8px; height:8px; }
  ::-webkit-scrollbar-track { background:transparent; }
  ::-webkit-scrollbar-thumb { background:rgba(126,156,196,.28); border:2px solid transparent;
    background-clip:padding-box; border-radius:6px; }
  ::-webkit-scrollbar-thumb:hover { background:rgba(57,255,20,.55); background-clip:padding-box; }
  ::-webkit-scrollbar-corner { background:transparent; }
  /* No scrollbar on the boot screen. The bar you saw there was never the boot screen's —
     #boot is position:fixed/inset:0 and cannot scroll. It's `html{overflow-y:scroll}`
     above, which FORCES a track even with nothing to scroll; that rule earns its keep
     everywhere else (it reserves the gutter so short tabs don't re-center and shift
     right), so this suspends it only while boot is up rather than removing it. hideBoot()
     adds .gone before removing the node, so the gutter returns exactly as the boot fades
     and real content arrives — under the fade, where a 10px reflow can't be seen. */
  html:has(#boot:not(.gone)) { overflow-y:hidden; scrollbar-gutter:auto; }
  /* PERF: the backdrop gradients used to live here as `background: ... fixed` —
     background-attachment:fixed is a known Android scroll-jank trigger (repaints the
     gradient every scrolled frame). All gradient layers now ride the position:fixed
     body::after starfield below, which is visually identical (a fixed-position
     backdrop) but composits instead of repainting. Keep body's background a flat
     colour. (The cyber-theme override further down follows the same rule.) */
  body { margin:0; background:var(--bg); color:var(--txt); font-family:var(--mono); font-size:calc(13px*var(--tscale)); line-height:1.55; letter-spacing:.1px; -webkit-tap-highlight-color:transparent; -webkit-text-size-adjust:100%; text-size-adjust:100%; -webkit-user-select:none; user-select:none; }
  input, textarea, [contenteditable], .feed, .statusfeed { -webkit-user-select:text; user-select:text; }
  body::before { content:""; position:fixed; inset:0; pointer-events:none; z-index:99;
    background:repeating-linear-gradient(transparent 0 3px, rgba(0,0,0,.05) 3px 4px); }
  /* Starvoxel depth: soft cool nebulae + edge vignette + star-dust, behind all content.
     Bumped brighter/denser (2026-07-04) — the first pass was correct on-device but
     dark-fill-on-near-black read as "nothing," not "subtle." */
  body::after { content:""; position:fixed; inset:0; pointer-events:none; z-index:-1;
    background:
      radial-gradient(1.6px 1.6px at 40px 60px, rgba(255,255,255,.85), transparent 100%),
      radial-gradient(1.2px 1.2px at 150px 180px, rgba(184,208,255,.6), transparent 100%),
      radial-gradient(1px 1px at 90px 20px, rgba(184,208,255,.45), transparent 100%),
      radial-gradient(2px 2px at 210px 260px, rgba(255,255,255,.7), transparent 100%),
      radial-gradient(1px 1px at 260px 90px, rgba(184,208,255,.4), transparent 100%),
      radial-gradient(900px 520px at 8% 3%, rgba(52,219,242,.16), transparent 62%),
      radial-gradient(820px 640px at 93% 98%, rgba(169,107,255,.15), transparent 62%),
      radial-gradient(150% 130% at 50% -8%, transparent 45%, rgba(0,0,0,.5) 100%),
      /* bottom-most layers: the page backdrop that used to be body's `background ... fixed`
         (grid lines from the cyber theme + the top nebula radial). Moved here for perf —
         this pseudo is position:fixed, so the "fixed backdrop" look is unchanged. */
      repeating-linear-gradient(0deg,  transparent 0 27px, rgba(57,255,20,.022) 27px 28px),
      repeating-linear-gradient(90deg, transparent 0 27px, rgba(57,255,20,.022) 27px 28px),
      radial-gradient(1200px 600px at 50% -10%, #1c2740 0%, var(--bg) 60%);
    background-size:230px 230px, 350px 350px, 190px 190px, 310px 310px, 270px 270px, auto, auto, auto, auto, auto, auto;
    background-repeat:repeat, repeat, repeat, repeat, repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat; }
  header { padding:8px 12px; border-bottom:1px solid var(--line); display:flex; justify-content:space-between; align-items:center; }
  h1 { font-size:calc(clamp(13px, 2.5vw, 15px)*var(--tscale)); margin:0; color:var(--neon); letter-spacing:1px; }
  /* In-game the brand slot becomes live economy stats (brand only shows pre-login) */
  header h1.hstats { font-size:calc(11px*var(--tscale)); letter-spacing:0; display:flex; gap:9px; white-space:nowrap; }
  header h1.hstats b { font-weight:700; }
  /* The STATUS BAR: live economy (left) · collapse clock (center) · controls (right).
     The brand only occupies the left slot on the login screen. */
  /* Label / countdown / greed stack in NORMAL FLOW (a tiny centred column) rather than
     absolute top/bottom:-7px offsets — those were fixed px against tscale-scaled fonts, so
     a larger text-size setting made the label overlap the digits. Flow is scale-proof. */
  /* gap, not a margin on the label: the column holds label / digits / greed, and a gap
     spaces all three consistently however many are present. line-height stays tight so the
     extra room reads as breathing space rather than a loose stack. */
  .hdr-collapse { flex:1 1 auto; min-width:0; white-space:nowrap; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.05; gap:3px; }
  /* The clock sits at TRUE screen-centre, not the middle of the space left over between a wide
     brand+floor block and three small icons (which pushed it right). Equal-weight sides do it
     without absolute positioning (which would collapse the header to the height of a 1-line
     side and drop the 3-line clock below the border). Scoped >600px; the mobile header wraps. */
  .hdr-side { min-width:0; display:flex; align-items:center; }
  .hdr-side-r { justify-content:flex-end; }
  @media (min-width:601px){
    .hdr-side { flex:1 1 0; }
    .hdr-collapse { flex:0 1 auto; }
  }
  .hdr-collapse .hc-l { font-size:calc(10px*var(--tscale)); color:var(--dim); letter-spacing:1.5px; line-height:1.1; }
  /* 16px -> 21px. This is the run's deadline and it was the same size as ordinary header
     text; it should be the thing your eye lands on. tabular-nums so the digits stop
     jittering sideways as the countdown ticks — at 21px that shimmy is very visible. */
  .hdr-collapse b { font-size:calc(21px*var(--tscale)); letter-spacing:.5px; color:var(--title-gold); text-shadow:0 0 8px rgba(255,176,0,.5); font-variant-numeric:tabular-nums; }
  .hdr-collapse .ccol { color:var(--red); text-shadow:0 0 8px rgba(255,49,49,.55); animation:collapsePulse 1.2s ease-in-out infinite; }
  /* Frozen collapse clock (safe room). Deliberately drops the urgency pulse and cools the
     digits — a held clock is the one moment on a floor that ISN'T urgent, and leaving it
     red-and-pulsing would keep signalling danger while the danger is paused. */
  .hdr-collapse .ct-paused { color:var(--cyan) !important; text-shadow:0 0 8px rgba(52,219,242,.45) !important; animation:none !important; opacity:.9; }
  .hdr-collapse .cgreed { font-size:calc(10px*var(--tscale)); color:var(--amber); line-height:1.1; }
  /* Reckless Plunge status pill — persistent reminder you're on a tougher-but-richer
     floor (server-gated: mapState.plunge, see app/services/plunge.py active()). Violet
     so it never collides with the collapse-timer tiers (green/amber/red) or greed-gold
     (.cgreed, amber) — matches the existing ⚡ Power / elite-foe violet accent. */
  .hdr-collapse .hc-plunge { display:inline-block; margin-top:1px; padding:0 5px; border-radius:4px; background:#1c1230; border:1px solid var(--violet); color:var(--violet); font-size:calc(9px*var(--tscale)); font-weight:800; letter-spacing:.5px; line-height:1.5; text-shadow:0 0 6px rgba(169,107,255,.6); }
  .hdr-season{font-size:calc(9px*var(--tscale));color:var(--dim);letter-spacing:.5px;margin-left:4px;white-space:nowrap}
  .hdr-season b{color:var(--amber);font-variant-numeric:tabular-nums}
  /* Collapse-timer urgency colours: default (no class) is amber (1h-6h remaining) —
     see .hdr-collapse b above. Green = safe (>6h); red reuses the COLLAPSED pulse. */
  .hdr-collapse b.ct-green { color:var(--neon); text-shadow:0 0 8px rgba(57,255,20,.5); }
  .hdr-collapse b.ct-red { color:var(--red); text-shadow:0 0 8px rgba(255,49,49,.55); animation:collapsePulse 1.2s ease-in-out infinite; }
  .live { color:var(--red); }
  /* PERF: glow pre-rendered at full strength; the pulse animates ONLY opacity
     (compositor-friendly — animating text-shadow repainted every frame). Same
     pattern as the tile sheen. */
  .live::before {
    content: "● ";
    color: #FF073A;
    display: inline-block;
    text-shadow: 0 0 10px #FF073A, 0 0 20px #FF073A, 0 0 24px rgba(255,7,58,.6);
    animation: livePulse 1.5s ease-in-out infinite;
  }
  @keyframes livePulse {
    0%, 100% { opacity:.72; }
    50%      { opacity:1; }
  }
  /* Breathing pulse: live (uncleared) monster tiles gently scale so enemy hexes read as alive. */
  @keyframes mobBreathe     { 0%,100%{ transform:scale(1); } 50%{ transform:scale(1.08); } }
  @keyframes mobBreatheBoss { 0%,100%{ transform:scale(1); } 50%{ transform:scale(1.15); } }
  .mob-breathe      { transform-box:fill-box; transform-origin:center; animation:mobBreathe     2.4s ease-in-out infinite; }
  .mob-breathe-boss { transform-box:fill-box; transform-origin:center; animation:mobBreatheBoss 1.9s ease-in-out infinite; }
  .wrap { display:grid; grid-template-columns:1fr 1fr; gap:10px; padding:10px; max-width:1100px; margin:0 auto; }
  .panel { background:linear-gradient(180deg,#273140,#1a2431); border:1px solid var(--line); border-radius:14px; padding:14px; box-shadow:inset 0 1px 0 rgba(255,255,255,.04), var(--shadow-card); }
  .panel h2 { font-size:calc(11px*var(--tscale)); color:var(--cyan); margin:0 0 6px; text-transform:uppercase; letter-spacing:1.5px; font-weight:700; text-align:center; position:relative; padding-bottom:5px; }
  .panel h2::after { content:""; position:absolute; left:50%; bottom:0; transform:translateX(-50%); width:34px; height:2px; border-radius:2px; background:var(--cyan); opacity:.45; }
  /* subtle glossy sheen on every bar fill so they read modern, not flat */
  .hbar .hbt, .dtbl .tbar { position:relative; }
  .hbar .hbt::after, .dtbl .tbar::after { content:""; position:absolute; inset:0; pointer-events:none; border-radius:inherit; background:linear-gradient(180deg, rgba(255,255,255,.16), rgba(255,255,255,0) 55%); }
  /* Reusable aligned table: every list (standings/rosters/records) shares fixed columns,
     one header style, zebra rows, tabular numerals — so they all scan identically. */
  .dtbl { width:100%; }
  .dtbl .dth, .dtbl .dtr { display:grid; align-items:center; gap:8px; padding:5px 8px; font-size:calc(11px*var(--tscale)); }
  .dtbl .dth { color:var(--dim); font-size:calc(10px*var(--tscale)); letter-spacing:.6px; text-transform:uppercase; border-bottom:1px solid var(--line); }
  .dtbl .dtr { border-radius:7px; }
  .dtbl .dtr:nth-child(odd of .dtr) { background:rgba(255,255,255,.022); }
  .dtbl .dtr.me { background:rgba(57,255,20,.13); }
  .dtbl .num { text-align:right; font-variant-numeric:tabular-nums; }
  .dtbl .ctr { text-align:center; }
  .dtbl .clip { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .dtbl .tbar { height:9px; background:#0a0f0c; border:1px solid var(--line); border-radius:5px; overflow:hidden; }
  .dtbl .tbar > i { display:block; height:100%; }
  .full { grid-column:1 / -1; }
  button { background:linear-gradient(180deg,#12290f,#0b1a0b); color:var(--neon); border:1px solid #2f7a28; border-radius:9px; padding:7px 13px; font:inherit; font-weight:600; cursor:pointer; margin:2px 2px; -webkit-tap-highlight-color:transparent; touch-action:manipulation; -webkit-user-select:none; user-select:none; transition:background .12s,box-shadow .12s,transform .06s ease; box-shadow:inset 0 1px 0 rgba(255,255,255,.05); min-height:calc(44px*var(--tscale)); }
  button:active:not(:disabled) { transform:scale(.96); }
  /* hover fills only on real pointers, so taps don't leave buttons "stuck" highlighted on touch */
  @media (hover:hover) { button:hover { background:var(--neon); color:#04140a; box-shadow:0 0 14px rgba(57,255,20,.45); } button.alt:hover { background:var(--amber); color:#1a1400; box-shadow:0 0 14px rgba(255,183,51,.4); } }
  button.alt { color:var(--amber); border-color:#7a5e14; background:linear-gradient(180deg,#241a00,#170f00); }
  button:disabled { opacity:.35; cursor:not-allowed; }
  input { background:#0a0f0c; color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font:inherit; outline:none; transition:border-color .12s,box-shadow .12s; }
  input:focus { border-color:var(--cyan); box-shadow:0 0 0 2px rgba(52,219,242,.18); }
  button:focus-visible, [tabindex]:focus-visible, a:focus-visible { outline:2px solid var(--neon); outline-offset:2px; }
  .stat { display:inline-block; margin-right:14px; }
  .stat b { color:#fff; }
  .feed { max-height:200px; overflow:auto; background:#08120a; border:1px solid var(--line); padding:8px; white-space:pre-wrap; font-size:calc(12px*var(--tscale)); }
  .feed div { line-height:1.45; }
  /* persistent status feed — always-visible last few actions */
  .statusfeed { background:#0c1310; border:1px solid var(--line); border-left:3px solid var(--cyan); border-radius:7px; padding:5px 9px; margin:0 0 9px; font-size:calc(11px*var(--tscale)); line-height:1.5; }
  .statusfeed > div { white-space:normal; overflow-wrap:anywhere; }   /* long lines wrap, never ellipsize */
  .fl-sys{color:#9fc4d8} .fl-ai{color:#c08bff;font-style:italic} .fl-ach{color:#ffd23f;font-weight:600}
  .fl-loot{color:#ffb000} .fl-good{color:#7CFC54} .fl-bad{color:#ff6b6b} .fl-err{color:var(--red)} .fl-event{color:#ff8a3c}
  .fl-gold{color:#ffd54a} .fl-combat{color:#c4d8cf}
  .flt{padding:6px 10px;font-size:calc(10px*var(--tscale));background:none;border:1px solid var(--line);border-radius:10px;color:var(--dim);cursor:pointer;font-family:inherit;letter-spacing:.3px}
  .flt.on{color:var(--amber);border-color:var(--amber)}
  #ticker { max-height:220px; }
  .tkage { color:var(--dim); font-size:calc(10px*var(--tscale)); margin-right:4px; display:inline-block; min-width:22px; }
  /* Premium-pass badge: gold, breathing glow (kept subtle — it's in every ticker line a premium player touches) */
  /* PERF: glow baked at full strength, pulse = opacity only (was an every-frame
     text-shadow repaint in every premium ticker line). */
  .premstar { color:#ffd23f; font-weight:700; display:inline-block; margin-left:2px;
    text-shadow:0 0 7px #ffd23f, 0 0 13px rgba(255,183,51,.55);
    animation:premGlow 1.6s ease-in-out infinite; }
  @keyframes premGlow { 0%,100% { opacity:.82; } 50% { opacity:1; } }
  .row { border-bottom:1px solid var(--line); padding:5px 0; line-height:1.5; }
  .muted { color:var(--dim); }
  .bar { height:10px; background:#08120a; border:1px solid var(--line); border-radius:5px; overflow:hidden; }
  .bar > i { display:block; height:100%; background:var(--neon); }
  /* Inline vitals bar (crawler sheet, recruit sheet, sponsor quota) — sits mid-sentence next
     to a "412 / 640", hence inline-block and the 64px default. */
  .vbar { display:inline-block; width:64px; height:9px; background:#0a0f0c; border:1px solid var(--line); border-radius:5px; overflow:hidden; vertical-align:middle; }
  .vbar > i { display:block; height:100%; }
  table { width:100%; border-collapse:collapse; font-size:calc(12px*var(--tscale)); table-layout:fixed; }
  table td, table th { overflow:hidden; text-overflow:ellipsis; }
  td,th { text-align:left; padding:3px 6px; border-bottom:1px solid var(--line); }
  .hide { display:none !important; }   /* utility must beat #id selectors (e.g. #compare overlay) */
  .tag { color:var(--pink); }
  .r-Common{color:#9aa6a0} .r-Bronze{color:#a4622c} .r-Silver{color:#dfe6ef} .r-Gold{color:#ffd23f}
  .r-Platinum{color:var(--teal)} .r-Legendary{color:#ff9326;text-shadow:0 0 7px rgba(255,147,38,.55)}
  .r-Celestial{color:#ff63e3;text-shadow:0 0 7px rgba(255,99,227,.55)}
  .atable { width:100%; border-collapse:collapse; table-layout:fixed; }
  .atable td { padding:5px 4px; border-bottom:1px solid var(--line); vertical-align:top; overflow-wrap:anywhere; }
  .atable td.aright { text-align:right; white-space:nowrap; padding-left:8px; width:74px; }
  .shoptable { width:100%; border-collapse:collapse; table-layout:fixed; }
  .shoptable td { padding:7px 4px; border-bottom:1px solid var(--line); vertical-align:middle; overflow-wrap:anywhere; }
  .shoptable td.sright { text-align:right; white-space:nowrap; width:118px; }
  .shoptable td.sright button { margin:0 0 0 4px; padding:3px 8px; min-height:32px; }
  /* inventory item tile — matches the paper-doll slot look (scaled art, rarity border) */
  .irow{display:flex;align-items:center;gap:9px}
  .itile{width:46px;height:46px;flex:0 0 46px;border:2px solid var(--line);border-radius:9px;overflow:hidden;position:relative;cursor:pointer;background:radial-gradient(circle at 50% 36%,#11181d,#06090b);box-shadow:inset 0 0 8px rgba(0,0,0,.55);transition:transform .09s ease,filter .09s ease}
  @media (hover:hover){ .itile:hover{transform:translateY(-2px) scale(1.05);filter:brightness(1.13);z-index:2} }
  .itile img{width:100%;height:100%;object-fit:cover;transform:scale(1.2);display:block;pointer-events:none}
  .itile .tglyph{display:flex;align-items:center;justify-content:center;width:100%;height:100%;font-size:calc(23px*var(--tscale))}
  .iname{min-width:0}
  /* Standings carries nine broadcast columns through ONE table-layout:fixed row. Sizing the
     name column by percentage left the other seven an equal share of the remainder — 30px
     inside a ~325px phone panel — so Power was cut mid-digit and every header was clipped.
     A desktop masonry column is the same ~360px, so there is no viewport where nine columns
     fit by division. Each column is sized to the widest value it can hold (px * --tscale, so
     the user's text-size multiplier widens them too) and the board slides sideways inside its
     panel instead of compressing. Crawler names take the leftover and wrap — they never
     ellipsize. */
  .boardscroll { overflow-x:auto; overflow-y:hidden; overscroll-behavior-x:contain; -webkit-overflow-scrolling:touch; }   /* contain: a swipe past the board's ends must not trigger browser back/forward (matches .sptrack) */
  .board { width:100%; border-collapse:collapse; font-size:calc(12px*var(--tscale)); }
  .board th:nth-child(1) { width:calc(34px*var(--tscale)); }
  .board td:nth-child(2) { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:0; }
  .board th:nth-child(3) { width:calc(64px*var(--tscale)); }   /* [TAG] — guilds allow five bold caps */
  .board th:nth-child(4) { width:calc(86px*var(--tscale)); }   /* league name, " League" already stripped */
  .board th:nth-child(5), .board th:nth-child(6) { width:calc(44px*var(--tscale)); }   /* Floor, Party */
  .board th:nth-child(7) { width:calc(62px*var(--tscale)); }   /* Power — raw, un-abbreviated, runs to six digits */
  .board th:nth-child(8) { width:calc(56px*var(--tscale)); }   /* the GOLD EARNED heading is wider than any fmtK value under it */
  .board th:nth-child(9) { width:calc(48px*var(--tscale)); }   /* Views */
  .board th { font-size:calc(10px*var(--tscale)); color:var(--dim); text-transform:uppercase; letter-spacing:.5px; text-align:left; padding:4px 6px; border-bottom:1px solid var(--line); }
  .board td { padding:5px 6px; border-bottom:1px solid var(--line); }
  /* Google Sign-In: a fixed, centered width so GSI's iframe can't stretch weird */
  #gsi-prompt { text-align:center; }
  /* Custom dark Google button. The real GIS button is rendered invisibly ON TOP
     (it must receive the actual user click to mint an ID token — you can't forge
     a click into its cross-origin iframe), so Google's own white/personalized
     button never shows. Our styled button underneath is what the player sees. */
  .gwrap { position:relative; width:300px; height:44px; margin:0 auto; }
  .g-custom {
    position:absolute; inset:0; width:100%; height:100%; margin:0;
    display:flex; align-items:center; justify-content:center; gap:10px;
    background:#0b0f14; color:#e8eef5; border:1px solid var(--line);
    border-radius:22px; font-size:calc(14px*var(--tscale)); font-weight:600;
    letter-spacing:.2px; cursor:pointer; pointer-events:none;
    box-shadow:0 0 0 1px rgba(0,229,204,.12), 0 2px 10px rgba(0,0,0,.45);
    transition:border-color .15s, box-shadow .15s;
  }
  .gwrap:hover .g-custom { border-color:var(--neon); box-shadow:0 0 0 1px rgba(0,229,204,.35), 0 2px 14px rgba(0,0,0,.5); }
  .gwrap:active .g-custom { transform:translateY(1px); }
  .g-custom svg { width:18px; height:18px; flex:0 0 18px; }
  /* Real GIS button: on top, click-catching, all but invisible. */
  #gbtn { position:absolute; inset:0; z-index:2; opacity:.001; overflow:hidden; }
  #gbtn > div, #gbtn iframe { margin:0 auto !important; width:300px !important; height:44px !important; }
  .kbar { display:flex; flex-wrap:wrap; gap:9px; }
  .ktile { width:60px; text-align:center; cursor:pointer; }
  .ktile:active img { transform:scale(.94); }
  /* WoW-style hover tooltip: the full item card floats next to any [data-itip]
     tile on hover-capable devices (touch keeps tap -> action popup). */
  .wtip { position:fixed; z-index:70; max-width:300px; pointer-events:none; display:none;
    background:#0c1310; border:1.5px solid var(--line); border-radius:10px; padding:10px 12px;
    box-shadow:0 8px 30px rgba(0,0,0,.7); font-size:calc(12px*var(--tscale)); }
  /* Shift held over a BAG item → WoW-style side-by-side: the equipped piece on the left,
     the hovered item on the right, so you compare at a glance. .cmp widens the tip. */
  .wtip.cmp { max-width:calc(640px*var(--tscale)); }
  .wtip-cmp { display:flex; gap:12px; align-items:stretch; }
  .wtip-cmp .wtip-col { flex:1 1 0; min-width:0; }
  .wtip-cmp .wtip-vs { flex:0 0 auto; align-self:center; color:var(--dim); font-size:calc(15px*var(--tscale)); }
  .wtip-hd { font-size:calc(10px*var(--tscale)); font-weight:700; text-transform:uppercase;
    letter-spacing:.5px; margin:-2px 0 7px; padding-bottom:5px; border-bottom:1px solid var(--line); }
  .wtip-verd { text-align:center; font-weight:800; letter-spacing:.6px; font-size:calc(12px*var(--tscale));
    padding:3px 0 7px; margin-bottom:6px; border-bottom:1px solid var(--line); }
  .wtip-verd.up { color:var(--neon); } .wtip-verd.dn { color:var(--red); } .wtip-verd.sd { color:var(--dim); }
  /* z-index 80 so a confirmPop opened FROM the edit-profile / haggle sheet (#compare,
     z-index 66) sits ABOVE it — it used to render behind the profile screen. */
  .infopop { position:fixed; inset:0; background:rgba(0,0,0,.62); display:flex; align-items:center; justify-content:center; z-index:80; padding:24px; }
  /* max-height + scroll: tall popups (the 20-rung skill ladder) used to clip at BOTH ends
     on a short phone — centered fixed container, so the top content and the Close button
     were equally unreachable. dvh so an iOS URL bar doesn't eat the close button. */
  .infopop .ipc { background:#0c1310; border:1px solid var(--line); border-radius:14px; padding:15px; max-width:300px; width:100%; box-shadow:0 8px 30px rgba(0,0,0,.6); max-height:min(86vh, calc(100dvh - 48px)); overflow-y:auto; }
  .ktile img { width:54px; height:54px; border-radius:12px; padding:2px; background:radial-gradient(circle at 50% 35%,#10171c,#06080a); border:1.5px solid var(--line); }
  .ktile.skill img { border-color:rgba(124,252,84,.4); box-shadow:0 0 9px rgba(124,252,84,.16); }
  .ktile.spell img { border-color:rgba(34,211,238,.4); box-shadow:0 0 9px rgba(34,211,238,.16); }
  .ktile.basic img { opacity:.85; }
  .ktile .kn { font-size:calc(10px*var(--tscale)); color:var(--txt); margin-top:3px; line-height:1.1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .ktile .km { font-size:calc(10px*var(--tscale)); color:var(--dim); }
  /* editable 4-slot action bar (tap to pick up, tap another to reorder) */
  /* Slots are sized by the ICON, not by the card. repeat(5,1fr) stretched every slot to a
     fifth of the panel while the art capped at 64px, so each one carried ~20px of dead
     space on both sides and the bar ate far more width than it needed. auto-fill packs
     icon-sized squares from the left and simply wraps sooner on narrow screens. */
  .kbar4 { display:grid; grid-template-columns:repeat(auto-fill,minmax(52px,60px)); gap:5px; justify-content:start; }
  .katkline { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
  .katkchip { display:inline-flex; align-items:center; gap:5px; font-size:calc(11px*var(--tscale)); color:var(--dim); border:1px dashed var(--line); border-radius:8px; padding:4px 9px; }
  .katkchip img { width:16px; height:16px; object-fit:contain; }
  .kslot { position:relative; min-width:0; aspect-ratio:1/1; border:1.5px solid var(--line); border-radius:10px; background:radial-gradient(circle at 50% 30%,#10171c,#06080a);
    display:flex; flex-direction:column; align-items:center; justify-content:center; cursor:pointer; padding:0; overflow:hidden; }
  .kslot.empty { border-style:dashed; background:rgba(255,255,255,.02); }
  .kslot.picked { border-color:var(--amber); box-shadow:0 0 12px rgba(255,176,0,.4); }
  .kslot.skill { border-color:rgba(124,252,84,.45); } .kslot.spell { border-color:rgba(34,211,238,.45); }
  /* Icon-only slots: art as large as fits — name/cost/cooldown live in the hover tooltip */
  .kslot img { width:100%; height:100%; object-fit:contain; }
  .kslot .kn { font-size:calc(10px*var(--tscale)); color:var(--txt); margin-top:2px; max-width:100%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .kslot .km { font-size:calc(10px*var(--tscale)); color:var(--dim); }
  .kslot .ksn { font-size:calc(20px*var(--tscale)); font-weight:800; color:var(--dim); }
  .kslot .kx, .kslot .kinfo { position:absolute; top:2px; width:15px; height:15px; line-height:15px; text-align:center; font-size:calc(10px*var(--tscale)); border-radius:5px; background:rgba(0,0,0,.55); }
  /* 15px glyphs stay 15px visually, but the tap target grows to ~41px via an invisible
     ::after halo (the .kslot's overflow:hidden trims what spills past the slot edge). */
  .kslot .kx::after, .kslot .kinfo::after { content:""; position:absolute; inset:-13px; }
  .kslot .kx { right:2px; color:var(--red); } .kslot .kinfo { left:2px; color:var(--cyan); }
  .kbench { margin-top:8px; display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
  .kbadd { color:var(--neon); cursor:pointer; border:1px solid rgba(57,255,20,.45); border-radius:7px; padding:8px 8px; font-size:calc(11px*var(--tscale)); background:rgba(57,255,20,.06); }
  .kbinfo { margin-left:5px; opacity:.6; font-size:calc(10px*var(--tscale)); cursor:pointer; }
  .kbinfo:hover { opacity:1; }
  .kbadd.isnew { border-color:var(--amber); color:var(--amber); box-shadow:0 0 8px rgba(255,183,51,.35); }
  .newtag { font-size:calc(10px*var(--tscale)); font-weight:800; color:#06120a; background:var(--amber); border-radius:4px; padding:0 3px; letter-spacing:.5px; vertical-align:1px; }
  .newdot { position:absolute; top:5px; right:calc(50% - 20px); width:8px; height:8px; border-radius:50%; background:var(--amber); box-shadow:0 0 6px var(--amber); }
  .tabbtn { position:relative; }
  .cb-bar button img { width:18px; height:18px; vertical-align:middle; margin-right:3px; }
  /* z-130: ABOVE #combat (60) — toasts fired mid-fight (loot, errors) were painting UNDER
     the combat overlay and lost. Below the achievement celebration (200). Top inset keeps
     them out from under a notched phone's status bar (viewport-fit=cover PWA). */
  /* Toasts clear the status bar instead of landing on it. At top:10px the achievement
     banner covered the floor COLLAPSE TIMER — the one number you cannot afford to lose
     sight of. --toast-top is measured from the real #topstick height (see _fitToasts),
     because that bar is a different height on phone vs desktop and changes with the
     text-size setting, so no fixed offset is right "on all platforms". Falls back to a
     sane constant before the first measurement. Deliberately NOT moved to the bottom:
     that is where the combat CONTINUE button lives, and achievement toasts fire exactly
     on victory. */
  #toasts { position:fixed; top:calc(var(--toast-top, 74px) + env(safe-area-inset-top, 0px)); left:50%; transform:translateX(-50%); z-index:130; display:flex; flex-direction:column; gap:0; max-width:92%; min-width:min(340px,80vw); pointer-events:none; }
  /* In combat/event/captive (#combat overlay is up) the below-HUD offset drops the toast onto
     the encounter diorama — on a tall desktop HUD --toast-top is ~350px, i.e. mid-screen. The
     overlay covers the HUD anyway, so there's no collapse timer to clear: pin toasts to the top. */
  body:has(#combat:not(.hide)) #toasts { top:calc(env(safe-area-inset-top, 0px) + 12px); }
  .toast { background:#0e1a12; border:1px solid var(--neon); border-radius:8px; padding:8px 12px; font-size:calc(12px*var(--tscale)); box-shadow:0 0 14px rgba(57,255,20,.28); animation:toastIn .3s ease; margin-bottom:6px;
    transition:max-height .26s ease, opacity .26s ease, transform .26s ease, margin .26s ease, padding .26s ease, border-width .26s ease; }
  @keyframes toastIn { from{ opacity:0; transform:translateY(-8px); } to{ opacity:1; transform:none; } }
  /* PILE, not a column. Notifications overlap like a lock-screen stack: the newest is full
     (the .front), and every one behind it collapses to a thin strip showing only the TOP of
     its card, tucked under the next. JS tags .peek/.front (see _restackToasts) because a CSS
     :last-child can't skip the interleaved achievement toasts, which keep their full column.
     The mask fades the cut edge so a clipped card doesn't end in a hard line. */
  .toast.peek { max-height:15px; overflow:hidden; opacity:.72; transform:scale(.975); margin-bottom:-4px; pointer-events:none;
    -webkit-mask-image:linear-gradient(#000 45%, transparent); mask-image:linear-gradient(#000 45%, transparent); }
  .toast.peek + .toast.front, .toast.peek + .toast.peek { position:relative; z-index:1; }
  /* ── Achievement notification stack (iOS Notification-Center style) ───────────
     One action can cross several gold-spend / inventory-slot milestones and unlock
     a fistful of achievements at once. Firing one full card each stacked them PAST
     the screen edge, burying the game — even 2 full cards covered the combat view.
     At rest it now collapses to ONE compact single-line bar (.ach-compact); tapping
     it expands into the self-scrolling full-card list. #achstack is a
     pointer-events:auto child of #toasts (which is :none). */
  #achstack { display:flex; flex-direction:column; gap:6px; pointer-events:auto; }
  #achstack:empty { display:none; }
  /* Collapsed rest state: ONE compact single-line bar — small enough it never covers
     the mob cards / combat text behind it. Newest unlock's icon + short title + a
     "+N" when more are queued; the whole bar taps open into the list. */
  .ach-compact { display:flex; align-items:center; gap:7px; height:38px; padding:0 11px;
    border:1px solid var(--neon); border-radius:8px; background:#0e1a12;
    box-shadow:0 0 12px rgba(57,255,20,.24); cursor:pointer; font-size:calc(11.5px*var(--tscale));
    color:var(--neon); animation:toastIn .22s ease; }
  .ach-compact .ic { flex:none; font-size:15px; line-height:1; }
  .ach-compact .ttl { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis;
    white-space:nowrap; font-weight:700; color:var(--txt); }
  .ach-compact .cnt { flex:none; color:var(--amber); font-weight:800; }
  /* CSS-drawn caret — font-independent, so it sits dead-centre. The ⌄ / ⌃ glyphs
     render high in their character cell and read as top-aligned; a bordered box
     rotated 45° does not, and align-items:center on the parent centres it. */
  .chevron { flex:none; display:inline-block; width:7px; height:7px;
    border:solid currentColor; border-width:0 1.6px 1.6px 0; box-sizing:border-box; }
  .chevron.down { transform:translateY(-1px) rotate(45deg); }
  .chevron.up   { transform:translateY(1px) rotate(-135deg); }
  /* Expanded: header + own-scroll list, capped so it can never exceed the viewport. */
  .ach-expanded { border:1px solid var(--neon); border-radius:8px; background:#0e1a12;
    box-shadow:0 0 16px rgba(57,255,20,.3); overflow:hidden; animation:toastIn .22s ease; }
  .ach-exp-head { display:flex; align-items:center; gap:7px; padding:7px 10px;
    border-bottom:1px solid var(--line); }
  .ach-exp-head .ttl { font-weight:800; color:var(--amber); font-size:calc(11px*var(--tscale));
    letter-spacing:1px; text-transform:uppercase; flex:1; min-width:0; }
  .ach-exp-head button { display:inline-flex; align-items:center; gap:5px;
    background:none; border:none; color:var(--dim);
    border-radius:5px; font-size:calc(10px*var(--tscale)); padding:3px 7px; cursor:pointer;
    line-height:1.2; width:auto; margin:0; letter-spacing:.5px; white-space:nowrap; min-height:auto; box-shadow:none; }
  .ach-exp-head button:hover { color:var(--txt); }
  .ach-exp-list { max-height:min(56vh, 420px); overflow-y:auto; padding:0 10px; }
  .ach-exp-row { display:flex; gap:8px; align-items:flex-start; padding:7px 0;
    border-bottom:1px solid var(--line); }
  .ach-exp-row:last-child { border-bottom:none; }
  .ach-exp-row .rx { background:none; border:none; color:var(--dim); font-size:15px;
    line-height:1; cursor:pointer; padding:2px 4px; margin:0; width:auto; align-self:flex-start; }
  .ach-exp-row .rx:hover { color:var(--neon); }
  .slotgrid{display:grid;grid-template-columns:repeat(2,1fr);gap:4px}
  .slot{border:1px solid var(--line);border-radius:3px;padding:4px 6px;background:#0c1410;font-size:calc(11px*var(--tscale));cursor:pointer;overflow:hidden}
  .slot.empty{opacity:.4}
  .slot .sn{color:var(--dim);font-size:calc(10px*var(--tscale));text-transform:uppercase;letter-spacing:.5px}
  .star{color:var(--amber);font-size:calc(10px*var(--tscale))}
  .region{color:var(--cyan);font-size:calc(10px*var(--tscale));margin:10px 0 4px;text-transform:uppercase;letter-spacing:1px;text-align:center}
  /* paper doll + stat grid */
  .statgrid{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin:8px 0}
  .statgrid .sc{background:#0c1310;border:1px solid var(--line);border-radius:6px;padding:5px 2px;text-align:center}
  .statgrid .sc .sl2{font-size:calc(10px*var(--tscale));color:var(--dim);letter-spacing:.5px}
  .statgrid .sc .sv2{font-size:calc(15px*var(--tscale));font-weight:700;color:#fff}
  /* WoW-style character sheet: square gear slots flank a central model */
  .dslot{width:48px;height:48px;border:2px solid var(--line);border-radius:8px;background:radial-gradient(circle at 50% 36%,#11181d,#06090b);display:flex;align-items:center;justify-content:center;font-size:calc(20px*var(--tscale));line-height:1;cursor:pointer;position:relative;overflow:hidden;box-shadow:inset 0 0 8px rgba(0,0,0,.55)}
  .dslot:not(.empty){transition:transform .09s ease,filter .09s ease}
  @media (hover:hover){ .dslot:not(.empty):hover{transform:translateY(-3px) scale(1.05);filter:brightness(1.15);z-index:3} }
  .dslot.empty{border-style:dashed;opacity:.7;cursor:default;font-size:calc(15px*var(--tscale))}
  .dslot img{width:36px;height:36px;display:block;pointer-events:none}
  .dslot img.itemico{width:100%;height:100%;border-radius:7px;object-fit:cover;transform:scale(1.2)}
  .dslot.empty img{opacity:.5}
  .dslot .q{position:absolute;bottom:-1px;right:3px;font-size:calc(10px*var(--tscale));color:var(--amber);text-shadow:0 0 3px #000}
  /* Solo baseline: NPC recruiting closed (server recruits_enabled=false) — hide the draft entry. */
  body.no-recruit #recruitBtn { display:none !important; }
  /* Recruit draft — swipeable rarity-framed trading cards (ID-photo portrait, not the full figure) */
  .rtrow{display:flex;gap:8px;overflow-x:auto;padding:8px 2px 4px;scroll-snap-type:x mandatory}
  /* fluid 3-up: all candidates visible at once, no side-scroll on phones */
  .rtcard{flex:1 1 0;min-width:0;max-width:190px;border:2px solid var(--line);border-radius:10px;padding:8px;background:#11161d;display:flex;flex-direction:column;align-items:center}
  .rtcard .rthead{display:flex;justify-content:space-between;width:100%;font-size:calc(10px*var(--tscale));font-weight:800}
  .rtcard .figbox{position:relative;width:100%;max-width:130px;height:152px;border-radius:8px;border:1px solid var(--neon);background:#0a0f0c;overflow:hidden;box-shadow:inset 0 0 16px rgba(0,0,0,.6);margin:6px 0}
  .rtcard .figlayer{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:50% 50%}
  .rtcard .rcname{width:100%;text-align:center;font-weight:800;font-size:calc(12px*var(--tscale));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .rtcard .rtattrs{font-size:calc(10px*var(--tscale));color:var(--sub);margin-top:3px;text-align:center}
  .rtcard .rtbtns{display:flex;gap:5px;width:100%;margin-top:7px}
  .rtcard .rtbtns button{flex:1;font-size:calc(11px*var(--tscale));padding:5px 4px}
  .rtcard .rtbtns button.alt{flex:0 0 auto;padding:5px 8px}
  .sbanner{border:2px solid var(--line);border-radius:10px;padding:9px 11px;background:linear-gradient(180deg,#141b22,#0d1117)}
  /* jewelry & tattoo tray (Layout A): unlabeled tile grid, all capacity, below everything */
  .jeweltray{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin-top:12px}
  .jeweltray .dslot{width:46px;height:46px}
  .jeweltray .dslot img{width:34px;height:34px}
  /* Doll-less equipped layout: a flat gear grid + labeled jewelry groups. */
  .geargrid{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:2px}
  .jwwrap{display:flex;flex-wrap:wrap;gap:12px 16px;margin-top:4px}
  .jwgroup{min-width:46px}
  .jwlabel{color:var(--dim);font-size:calc(10px*var(--tscale));letter-spacing:.5px;margin-bottom:4px}
  .jwrow{display:flex;flex-wrap:wrap;gap:5px;max-width:calc(5*46px + 4*5px)}
  .jwrow .dslot{width:46px;height:46px}
  .jwrow .dslot img{width:34px;height:34px}
  .gsbar{display:flex;flex-wrap:wrap;justify-content:center;gap:6px 16px;margin:9px 0 2px;font-size:calc(11px*var(--tscale));color:var(--dim)}
  .gsbar b{color:var(--title-gold);font-size:calc(13px*var(--tscale))}
  .cmp-strip{display:flex;gap:0;margin:6px 0 2px;border:1px solid var(--bdr);border-radius:4px;overflow:hidden}
  .cmp-strip>div{flex:1;text-align:center;padding:6px 4px;border-right:1px solid var(--bdr)}
  .cmp-strip>div:last-child{border-right:none}
  .cmp-strip .num{font-family:'Rajdhani',sans-serif;font-weight:700;font-size:calc(16px*var(--tscale));line-height:1.1}
  .cmp-strip .lbl{font-size:calc(9px*var(--tscale));color:var(--dim);letter-spacing:1px}
  .gsr{display:flex;gap:10px;align-items:flex-start;margin-top:2px}
  .gsr-gear{flex:1;min-width:0}
  .gsr-stats{display:flex;flex-direction:column;gap:3px;min-width:56px}
  .gsr-stats .scr{display:flex;justify-content:space-between;align-items:baseline;gap:4px;background:#0c1310;border:1px solid var(--line);border-radius:4px;padding:2px 6px}
  .gsr-stats .scr .sl2{font-size:calc(9px*var(--tscale));color:var(--dim);letter-spacing:.3px}
  .gsr-stats .scr .sv2{font-size:calc(12px*var(--tscale));font-weight:700;color:#fff}
  .gsr-stats .scr.sep{background:none;border:none;height:1px;padding:0;border-top:1px solid var(--line)}
  .setline{text-align:center;font-size:calc(10px*var(--tscale));color:var(--dim);margin:3px 0 0;letter-spacing:.3px}
  .inkgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:9px}
  .inktile{display:flex;flex-direction:column;align-items:center;text-align:center;gap:3px;padding:11px 8px;
    border:1px solid color-mix(in srgb,var(--amber) 42%,transparent);border-radius:11px;
    background:linear-gradient(180deg,color-mix(in srgb,var(--amber) 12%,transparent),rgba(10,14,22,.4))}
  .inktile-ic{font-size:calc(24px*var(--tscale));line-height:1}
  .inktile-nm{font-weight:800;font-size:calc(11px*var(--tscale));color:var(--txt)}
  .inktile-eff{font-size:calc(10px*var(--tscale));font-weight:700;color:var(--amber)}
  .inktile-eff.cosmetic{color:var(--dim);font-weight:400;font-style:italic}
  .setline b{color:var(--title-gold)}
  /* party digest cards (symmetrical) */
  /* recruits roster table — even, symmetrical columns */
  /* The roster is table-layout:fixed behind a 40% name column, so its five value columns
     divide ~195px on a phone. A 64px vitals bar simply ran off the end of its ~35px cell and
     td{overflow:hidden} sheared it — the fill looked full at every HP. Let the bar take
     whatever cell it lands in and keep 64px as the ceiling for the roomier callers. */
  /* mini crawler portrait in roster rows */
  /* recruit offer cards — framed portrait + rarity */
  /* cosmetics store — frame swatches (dense grid, minimal dead space) */
  .frgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(86px,1fr));gap:6px}
  .frcard{border:1px solid var(--line);border-radius:9px;padding:6px 5px;text-align:center;background:#0b110e;display:flex;flex-direction:column}
  .frcard.on{border-color:var(--neon);box-shadow:0 0 8px rgba(57,255,20,.3)}
  .frcard.cos-prev{border-color:var(--cyan);box-shadow:0 0 8px rgba(52,219,242,.3)}
  /* Locked = earn-only, or can't afford yet: still visible (you should want it), just dimmed. */
  .frcard.locked{opacity:.45;filter:saturate(.55)}
  .frcard.locked .frprice{color:var(--dim)}
  .frswatch{width:42px;height:42px;border-radius:50%;margin:1px auto 4px;border:2px solid var(--neon);background:radial-gradient(circle at 50% 35%,#1a2230,#0a0f0c)}
  /* One line, shrink-to-fit (user call): --n is the name's character count, set inline by
     _cosCard; a monospace glyph is ~0.62em wide, so the size that fits the card's inline
     size is 100cqw/(n*.62). Browsers without container units keep the 10px line above. */
  .frcard{container-type:inline-size}
  .frname{font-size:calc(10px*var(--tscale));font-weight:700;white-space:nowrap;overflow:hidden;line-height:1.2}
  .frname{font-size:min(calc(10px*var(--tscale)),calc(100cqw / (var(--n,12) * .62)))}
  .frprice{font-size:calc(10px*var(--tscale));color:var(--dim);margin:1px 0 4px}
  .frcard button{width:100%;padding:3px;font-size:calc(11px*var(--tscale));margin:auto 0 0}
  /* Stable -- multi-crawler character-select gate (config.stable). One card per
     crawler + open/locked slot cards. Square corners + urgency border colour to
     match the terminal/panel language elsewhere (no new CSS vars invented). */
  .stb-sub{font-size:calc(12px*var(--tscale));color:var(--sub);margin:-4px 0 14px;line-height:1.5}
  /* Exactly THREE columns, because the roster is hard-capped at three slots. auto-fill with a
     220px floor needed ~684px of container to keep 3 abreast and silently dropped to 2-up
     below that (which is most of the mid-width range), so the third slot wrapped underneath.
     minmax(0,1fr) lets the cards shrink to fit instead of forcing a reflow. */
  .stb-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
  /* Only genuinely narrow screens stack -- 3-up under ~500px would be unreadable. */
  @media (max-width:520px){ .stb-grid{grid-template-columns:1fr} }
  .stb-card{position:relative;border:1px solid var(--line);background:linear-gradient(180deg,#141b26,#0d131b);
    padding:12px;cursor:pointer;transition:border-color .15s,box-shadow .15s,transform .15s;min-height:140px}
  .stb-card:hover{border-color:var(--neon);box-shadow:0 0 12px rgba(57,255,20,.25);transform:translateY(-2px)}
  .stb-card.stb-dead,.stb-card.stb-locked{cursor:default}
  .stb-card.stb-dead{opacity:.55;filter:grayscale(.7)}
  .stb-card.stb-dead:hover,.stb-card.stb-locked:hover{border-color:var(--line);box-shadow:none;transform:none}
  .stb-card.stb-locked{opacity:.5;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px}
  .stb-card.stb-open{border-style:dashed;opacity:.8;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px}
  .stb-card.stb-open:hover{border-color:var(--cyan);box-shadow:0 0 10px rgba(138,235,255,.25);transform:translateY(-2px)}
  .stb-head{display:flex;gap:10px;align-items:center}
  .stb-por{width:52px;height:52px;flex:0 0 52px;border-radius:8px;border:2px solid var(--line);object-fit:cover;object-position:50% 14%;background:#0a0f16}
  .stb-name{display:flex;align-items:baseline;gap:6px;font-weight:800;font-size:calc(16px*var(--tscale));color:var(--txt);white-space:nowrap}
  /* .stb-active retired: the active card already has a neon border + crew row; the crawler
     number (.pnum) now sits where the ACTIVE badge was. */
  .stb-sub2{font-size:calc(10.5px*var(--tscale));color:var(--dim);margin-top:2px}
  .stb-stats{display:flex;gap:10px;font-size:calc(10px*var(--tscale));color:var(--dim);margin-top:3px;font-variant-numeric:tabular-nums}
  .stb-stats b{color:var(--title-gold);font-weight:700}
  .stb-hpline{display:flex;align-items:center;gap:6px;margin-top:9px;font-size:calc(10.5px*var(--tscale));color:var(--dim)}
  .stb-hpline .vbar{flex:1;width:auto;height:8px}
  .stb-floors{margin-top:7px;font-size:calc(10.5px*var(--tscale));color:var(--sub);display:flex;justify-content:space-between}
  .stb-dead-badge{margin-top:8px;text-align:center;font-size:calc(11px*var(--tscale));font-weight:800;letter-spacing:1px;color:var(--dim);border-top:1px solid var(--line);padding-top:6px}
  .stb-lock-ic{font-size:26px;opacity:.7}
  .stb-lock-hint{font-size:calc(10.5px*var(--tscale));color:var(--dim)}
  .stb-open-ic{font-size:24px;color:var(--cyan);opacity:.85}
  /* The reason to descend, under the action the tap actually performs. Deliberately dimmer
     and narrower than .stb-sub2: it is flavour that happens to be true, not a second button,
     and it must not compete with the line that says what tapping does. */
  .stb-open-hook{font-size:calc(10px*var(--tscale));color:var(--sub);opacity:.8;line-height:1.4;
    max-width:24ch;margin-top:4px;font-style:italic}
  /* --- Crawlers revamp: roster header strip, triage pill, at-risk gold, Fallen ---
     PALETTE RULE: the pill must never re-use a colour that already MEANS something
     else on this card. The big collapse clock owns the green/amber/red TIME tiers
     (.stb-ct-green/.stb-ct-red), so the pill only spends red on "gone or trapped"
     (dead/stranded) and amber on "the clock is nearly out" (collapsing) -- the two
     states where red/amber agree with the clock anyway. Combat gets VIOLET, the
     colour this file already reserves for run-state badges (.hc-plunge, .rbchip);
     a safe live floor gets TEAL (--teal is otherwise unused on this screen, so it
     reads as "nothing to do here"), and a parked crawler gets plain --dim. A party
     member following the star wears the ACTIVE tag's NEON: same run, same meaning. */
  .stb-hdr{display:flex;flex-direction:column;gap:7px;margin:0 0 12px;padding:8px 10px;
    border:1px solid var(--line);border-left:3px solid var(--cyan);border-radius:7px;background:#0c1310}
  .stb-hrow{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}
  .stb-hstat{display:inline-flex;align-items:baseline;gap:5px;font-size:calc(9.5px*var(--tscale));color:var(--dim);letter-spacing:.6px;white-space:nowrap}
  .stb-hstat b{font-size:calc(12.5px*var(--tscale));font-weight:800;letter-spacing:0;font-variant-numeric:tabular-nums}
  .stb-hgap{flex:1 1 auto}
  .stb-hbtn{margin:0;padding:3px 10px;font-size:calc(10px*var(--tscale));border-radius:6px;letter-spacing:.6px;white-space:nowrap}
  .stb-season{display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 7px;font-size:calc(9.5px*var(--tscale));
    color:var(--dim);letter-spacing:.6px;border-top:1px solid var(--line);padding-top:6px}
  .stb-season .ssl{color:var(--amber);font-weight:800;letter-spacing:.3px;font-size:calc(11px*var(--tscale))}
  .stb-season b{color:var(--amber);font-size:calc(12px*var(--tscale));font-variant-numeric:tabular-nums;letter-spacing:.3px;
    text-shadow:0 0 7px rgba(255,183,51,.35)}
  .stb-clk-corner{margin-left:auto;font-size:calc(11px*var(--tscale));font-weight:700;color:var(--title-gold);font-variant-numeric:tabular-nums;flex-shrink:0}
  .stb-clk-corner.clk-paused{color:var(--cyan)}
  .stb-pill.p-paused{color:var(--cyan)}
  .stb-pillrow{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:9px}
  .stb-pill{display:inline-block;padding:1px 7px;border-radius:4px;border:1px solid currentColor;
    font-size:calc(9px*var(--tscale));font-weight:800;letter-spacing:1px;line-height:1.65;white-space:nowrap}
  .stb-pill.p-dead,.stb-pill.p-stranded{color:var(--red);background:#2a0d0d}
  .stb-pill.p-stranded{animation:collapsePulse 1.4s ease-in-out infinite}
  .stb-pill.p-collapsing{color:var(--amber);background:#241a00;animation:collapsePulse 1.6s ease-in-out infinite}
  .stb-pill.p-fighting{color:var(--violet);background:#1c1230}
  .stb-pill.p-holding,.stb-pill.p-paused{background:#04211c}
  .stb-pill.p-holding{color:var(--teal)}
  .stb-pill.p-idle{color:var(--dim);background:#0e141c}
  .stb-pill.p-party{color:var(--neon);background:rgba(57,255,20,.1)}
  .stb-crew{display:flex;gap:6px;margin-top:9px;align-items:center}
  .stb-crew-label{font-size:calc(9px*var(--tscale));color:var(--dim);letter-spacing:.6px;margin-right:2px}
  .stb-crew-por{width:28px;height:28px;border-radius:50%;border:2px solid var(--neon);object-fit:cover;object-position:50% 14%;background:var(--bg);cursor:default;transition:border-color .15s}
  .stb-crew-por.crew-dead{opacity:.4;filter:grayscale(.7);border-color:var(--dim)}
  .stb-crew-por.crew-hurt{border-color:var(--amber)}
  .stb-crew-por.crew-low{border-color:var(--red)}
  /* --- Party tab (party rework): the 4-seat active line + idle roster. Reuses the .stb-card
     shell; overrides the whole-card click/hover since party cards carry explicit buttons. --- */
  .pty-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
  @media (max-width:520px){ .pty-grid{grid-template-columns:1fr} }
  .stb-card.pty-seat,.stb-card.pty-bench,.stb-card.pty-open{cursor:default}
  .stb-card.pty-seat:hover,.stb-card.pty-bench:hover,.stb-card.pty-open:hover{border-color:var(--line);box-shadow:none;transform:none}
  .stb-card.pty-driving{border-color:var(--neon);box-shadow:0 0 10px rgba(57,255,20,.22)}
  .stb-card.pty-driving:hover{border-color:var(--neon);box-shadow:0 0 10px rgba(57,255,20,.22)}
  .pty-acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}
  .pty-btn{margin:0;padding:3px 12px;font-size:calc(10.5px*var(--tscale));border-radius:6px;letter-spacing:.4px}
  .pty-star{display:inline-flex;align-items:center;font-size:calc(10px*var(--tscale));font-weight:800;letter-spacing:1px;color:var(--neon)}
  /* At-risk carried gold. Deliberately NOT .riskchip -- that class is display:none
     under 560px (it's a status-bar space saver), and this is the one warning that
     absolutely has to survive on a phone. */
  .stb-risk{color:#ff8a1a;font-size:calc(10px*var(--tscale));font-weight:700;white-space:nowrap}
  /* The derived line WRAPS rather than ellipsing: at the 220px grid minimum a phrase like
     "collapsed -- stranded on F11" plus DEEPEST Fnn does not fit on one line, and clipping
     the one line that says what happened is the exact opposite of the point. DEEPEST is
     pushed right by margin-auto so it stays right-aligned on whichever row it lands on. */
  .stb-floors{gap:2px 8px;align-items:baseline;flex-wrap:wrap}
  .stb-line{flex:1 1 auto;min-width:0;white-space:normal;overflow-wrap:anywhere;line-height:1.45}
  .stb-deep{flex:0 0 auto;margin-left:auto;color:var(--dim);font-size:calc(9.5px*var(--tscale));letter-spacing:.5px}
  /* Hall of the Fallen -- rows inside the file's standard .infopop card. */
  .fal-list{margin-top:9px;max-height:52vh;overflow-y:auto}
  .fal-row{display:flex;gap:9px;align-items:center;padding:7px 0;border-bottom:1px solid var(--line)}
  .fal-row:last-child{border-bottom:none}
  .fal-por{width:38px;height:38px;flex:0 0 38px;border-radius:7px;border:1px solid var(--line);object-fit:cover;
    object-position:50% 14%;background:#0a0f16;filter:grayscale(.85);opacity:.82}
  .fal-n{font-weight:800;font-size:calc(12px*var(--tscale));color:var(--sub);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .fal-m{font-size:calc(9.5px*var(--tscale));color:var(--dim);line-height:1.5}
  @media (min-width:1080px){
    #stable:not(.hide){min-height:calc(100vh - 120px);display:grid;grid-template-columns:auto;place-content:start center;padding-top:5vh;max-width:none}
    #stable .panel{width:min(980px, 94vw)}
  }
  /* Below 1080 the stable still rode the generic 2-column .wrap, so the panel only ever got
     one column's worth and three slots had nowhere to go. Give it the full row and the same
     centred single-column treatment as the wide case. */
  @media (max-width:1079px){
    #stable:not(.hide){grid-template-columns:1fr;max-width:1100px}
    #stable .panel{width:auto}
  }
  /* Getting Started onboarding checklist: no longer needs its own border/glow override —
     the base .panel rule already gives it a neon-accented translucent border + drop-shadow
     glow (this ID rule used to beat it on specificity, forcing a flat opaque border). */
  .ob-head{display:flex;align-items:center;gap:8px}
  .ob-head h2{flex:1}
  #ob-prog{font-size:calc(12px*var(--tscale));color:var(--neon);font-weight:700}
  .ob-x{background:none;border:none;color:var(--dim);font-size:calc(14px*var(--tscale));cursor:pointer;padding:2px 4px;width:auto;
    min-width:40px;min-height:40px;display:inline-flex;align-items:center;justify-content:center}
  .oblist{display:flex;flex-direction:column;gap:5px;margin-top:8px}
  .obrow{display:flex;align-items:center;gap:9px;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#0b110e}
  .obrow.obnext{border-color:var(--neon);background:#0e1a10}
  .obrow.obdone{opacity:.55}
  .obck{flex:none;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:calc(12px*var(--tscale));font-weight:700;border:1px solid var(--line);color:var(--dim)}
  .obrow.obdone .obck{background:var(--neon);border-color:var(--neon);color:#06210a}
  .obrow.obnext .obck{border-color:var(--neon);color:var(--neon)}
  .obtx{flex:1;min-width:0}
  .obt{font-size:calc(12px*var(--tscale));font-weight:700;color:var(--txt)}
  .obrow.obdone .obt{text-decoration:line-through;font-weight:400}
  .obd{font-size:calc(10px*var(--tscale));color:var(--dim);margin-top:2px}
  .obcta{flex:none;font-size:calc(11px*var(--tscale));padding:5px 10px}
  /* frPulse still animates box-shadow ON PURPOSE (the perf sweep converted the others to
     opacity layers): it runs on at most 1-2 portrait-frame elements at once, and the target
     is an <img> — no pseudo-element to pre-shadow. Revisit only if frames multiply. */
  @keyframes frPulse{0%,100%{box-shadow:0 0 3px var(--gc,var(--neon))}50%{box-shadow:0 0 10px var(--gc,var(--neon))}}
  @keyframes frShimmer{0%{filter:hue-rotate(0deg) brightness(1)}50%{filter:hue-rotate(45deg) brightness(1.3)}100%{filter:hue-rotate(0deg) brightness(1)}}
  .fr-pulse{animation:frPulse 2.8s ease-in-out infinite}
  .fr-shimmer{animation:frShimmer 2.4s linear infinite}
  .trk{font-size:calc(11px*var(--tscale));padding:3px 0;border-bottom:1px solid var(--line)}
  /* z-index 66 so the haggle sheet (reuses #compare) sits ABOVE the safe-room #tavern /
     #market overlays (z-index 62) it's opened from — it used to render behind them. */
  #compare{position:fixed;inset:0;background:rgba(0,0,0,.75);display:flex;align-items:center;justify-content:center;z-index:66;padding:12px}
  #compare .card{background:var(--panel);border:1px solid var(--neon);border-radius:6px;padding:14px;max-width:540px;width:100%;max-height:86vh;overflow:auto}
  /* Small-form sheets (list/bid/pet-rename) — replaced the old stacked prompt()
     flows, whose validation errors landed in a feed hidden BEHIND the prompt. */
  #compare .shlbl{font-size:calc(10px*var(--tscale));letter-spacing:1px;color:var(--dim);font-weight:800;margin:10px 0 4px}
  #compare .shin{width:100%;box-sizing:border-box}
  #compare .durchip{flex:1;margin:0;padding:8px 0;background:#0c1310;color:var(--dim);border:1px solid var(--line);border-radius:8px;font-size:calc(11px*var(--tscale));letter-spacing:.3px}
  #compare .durchip.on{color:var(--amber);border-color:var(--amber);background:rgba(255,176,0,.12);box-shadow:0 0 8px rgba(255,176,0,.25);font-weight:800}
  .cmp{display:grid;grid-template-columns:1fr 1fr;gap:10px}
  .cmp>div{border:1px solid var(--line);border-radius:4px;padding:8px;font-size:calc(12px*var(--tscale))}
  .icard{border-radius:6px;padding:6px 9px;background:#0b110e}
  .up{color:var(--neon)} .down{color:var(--red)}

  @media (max-width: 600px) {
    .wrap { grid-template-columns: 1fr; }
    .panel.full { grid-column: 1; }
    header { flex-wrap: wrap; gap: 4px; }
    .stat { margin-right: 8px; }
  }
  /* --- tabbed app shell --- */
  .tab { display:none; }
  .tab.active { display:block; }
  #game { max-width:580px; margin:0 auto; display:block;
    padding:calc(10px + env(safe-area-inset-top)) calc(10px + env(safe-area-inset-right)) calc(66px + env(safe-area-inset-bottom)) calc(10px + env(safe-area-inset-left)); }
  .tab .panel { margin-bottom:10px; }
  [data-dash]>h2 { cursor:pointer; user-select:none; }
  [data-dash]>h2::after { content:' \25BE'; font-size:.7em; opacity:.4; }
  .dash-col>h2::after { content:' \25B8' !important; }
  .dash-col>*:not(h2):not(.dash-tb) { display:none !important; }
  .dash-col { padding-bottom:8px !important; }
  .dash-hid { display:none !important; }
  .dash-editing .dash-hid { display:block !important; opacity:.4; }
  .dash-tb { display:flex; gap:4px; margin-bottom:6px; }
  .dash-tb button { padding:1px 8px; font-size:calc(10px*var(--tscale)); }
  #xpwrap { padding:0; height:0; }
  /* XP bar = the bottom border of the WHOLE status bar (#topbar): pinned edge-to-edge,
     flush with the panel's own border, regardless of the panel's own padding.
     Current XP in violet, rested bonus overlaid as a translucent blue. */
  #topbar { position:relative; padding-bottom:15px; }   /* reserve room for the taller XP bar */
  .xpunder { position:absolute; left:0; right:0; bottom:0; height:15px; background:#0a0f0c; border-top:1px solid rgba(169,107,255,.55); overflow:hidden; border-radius:0; margin:0; display:flex; align-items:center; justify-content:center; }
  .xpunder > i.xpfill { position:absolute; left:0; top:0; height:100%; background:var(--violet); box-shadow:0 0 6px rgba(169,107,255,.5); transition:width .2s; }
  .xpunder > i.xprested { position:absolute; top:0; height:100%; background:#8fd0ff; opacity:.35; }
  .xpunder .xptxt { position:relative; z-index:1; font-size:calc(9px*var(--tscale)); font-weight:700; letter-spacing:.3px; color:#efe6ff; text-shadow:0 1px 2px #000,0 0 5px rgba(169,107,255,.55); white-space:nowrap; pointer-events:none; }
  /* Status-bar vitals: custom glyph + value over a small tinted fill bar (mirrors native) */
  .vcell { display:inline-flex; flex-direction:column; gap:1px; min-width:34px; }
  .vcell .vtop { display:flex; align-items:center; gap:3px; font-size:calc(11px*var(--tscale)); line-height:1; }
  .vcell .vtop b { font-weight:800; }
  /* 6px fills (was 4) — HP/MP/SP need to read at a glance mid-crawl */
  .vcell .vmini { height:6px; border-radius:3px; overflow:hidden; border:1px solid currentColor; background:var(--vmix, rgba(255,255,255,.22)); }
  .vcell .vmini > i { display:block; height:100%; }
  /* Hero tile — left-of-map identity panel, desktop only */
  #hero-tile { display:none; }
  #hero-tile .ht-por { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
  #hero-tile .ht-por img.hud-av { width:48px; height:48px; }
  #hero-tile .ht-name { font-weight:800; font-size:calc(15px*var(--tscale)); line-height:1.3; }
  #hero-tile .ht-sub { font-size:calc(10px*var(--tscale)); color:var(--dim); line-height:1.5; }
  #hero-tile .ht-affil { display:flex; flex-wrap:wrap; gap:4px 6px; font-size:calc(10px*var(--tscale)); margin:6px 0; }
  #hero-tile .ht-affil .af { white-space:nowrap; }
  #hero-tile .ht-econ { display:grid; grid-template-columns:1fr 1fr; gap:4px 8px; margin:8px 0; }
  #hero-tile .ht-econ .echip { font-size:calc(10px*var(--tscale)); gap:4px; }
  #hero-tile .ht-econ .echip b { font-size:calc(12px*var(--tscale)); }
  #hero-tile .ht-econ .echip .uic { width:14px; height:14px; }
  #hero-tile .ht-gauge { display:flex; justify-content:center; margin:6px 0; }
  #hero-tile .ht-gauge .vitals-gauge { width:clamp(120px,100%,180px); height:clamp(120px,100%,180px); }
  #hero-tile .ht-spx { text-align:center; font-size:calc(10px*var(--tscale)); color:var(--dim); cursor:pointer; margin-bottom:6px; }
  #hero-tile .ht-xp { margin-top:4px; }
  #hero-tile .ht-xp .xpunder { position:relative; height:14px; background:#0a0f0c; border:1px solid rgba(169,107,255,.55); overflow:hidden; border-radius:7px; display:flex; align-items:center; justify-content:center; }
  #hero-tile .ht-xp .xpunder > i.xpfill { position:absolute; left:0; top:0; height:100%; background:var(--violet); box-shadow:0 0 6px rgba(169,107,255,.5); transition:width .2s; }
  #hero-tile .ht-xp .xpunder > i.xprested { position:absolute; top:0; height:100%; background:#8fd0ff; opacity:.35; }
  #hero-tile .ht-xp .xptxt { position:relative; z-index:1; font-size:calc(8px*var(--tscale)); font-weight:700; letter-spacing:.3px; color:#efe6ff; text-shadow:0 1px 2px #000,0 0 5px rgba(169,107,255,.55); white-space:nowrap; pointer-events:none; }
  /* Liquid gauge vitals */
  .vitals-gauge { width:clamp(90px,12vw,120px); height:clamp(90px,12vw,120px); flex:none; }
  .vitals-gauge svg { width:100%; height:100%; overflow:visible; }
  .vg-track { fill:none; opacity:.12; }
  .vg-bar { fill:none; stroke-linecap:round; }
  .vg-mp { filter: drop-shadow(0 0 3px rgba(68,170,255,.35)); }
  .vg-sp { filter: drop-shadow(0 0 3px rgba(255,200,0,.3)); }
  @keyframes vg-glow { 0%,100%{opacity:.5} 50%{opacity:.9} }
  .vg-surface { animation: vg-glow 2.5s ease-in-out infinite; filter: drop-shadow(0 0 4px rgba(255,102,102,.6)); }
  .hstats .vlv { color:var(--title-gold); font-weight:800; font-size:calc(11px*var(--tscale)); align-self:center; margin-right:3px; letter-spacing:.5px; }
  .hstats .vlv b { font-size:calc(14px*var(--tscale)); margin-left:1px; }
  /* Economy cells + audience toggle */
  .ecell { display:inline-flex; align-items:center; gap:4px; font-size:calc(11px*var(--tscale)); }
  .ecell b { font-weight:800; }
  /* Status-bar CURRENCY as labelled pill chips (bigger + grouped) — glyph + LABEL/value,
     accent-coloured left edge (--cc set inline per currency). */
  .echip { display:inline-flex; align-items:center; gap:6px; background:#0e150f; border:1px solid #1e3322; border-left:3px solid var(--cc); border-radius:7px; padding:5px 9px 5px 8px; }
  .echip .uic { width:16px; height:16px; }
  .echip .ec-col { display:flex; flex-direction:column; gap:2px; line-height:1; }
  .echip .ec-l { font-size:calc(9px*var(--tscale)); color:var(--dim); text-transform:uppercase; letter-spacing:.5px; }
  .echip b { font-size:calc(15px*var(--tscale)); font-weight:700; white-space:nowrap; }
  /* Player number (crawler #) badge in the identity line. */
  /* The Crawler number is how another player finds you, so it is an ADDRESS, not a footnote.
     It was 10px in a row of 10px muted text and disappeared into it. Now sized with the
     name it sits beside, tabular so the digits don't wobble, and clickable to copy —
     "read it off the screen and retype it" is not a way to add a friend. */
  .pnum { color:var(--dim); font-size:calc(10px*var(--tscale)); font-weight:600; font-variant-numeric:tabular-nums; margin-left:5px; cursor:pointer; }
  .pnum:hover { color:var(--neon); }
  /* Reset-in-place identity: REBOOT vN badge (incarnation count, only shown once >0) and
     the lifetime-deaths stat beside it. Subtle — a fresh crawler (reboot 0) shows neither. */
  .rbchip { color:var(--violet); font-weight:700; display:inline-flex; align-items:center; gap:2px; }
  .dchip { color:var(--dim); font-size:calc(10px*var(--tscale)); display:inline-flex; align-items:center; gap:2px; }
  /* Overview party table (5 cols: name+hp / lv / race / class / power) */
  /* Square ends. The 5px radius rounded the track AND (via overflow:hidden) the fill, so a
     bar that is nearly empty came out as a lozenge and read as fuller than it was. */
  .xpbar { height:8px; background:#08120a; border:1px solid var(--line); border-radius:0; overflow:hidden; }
  .xpbar > i { display:block; height:100%; background:linear-gradient(90deg,#22d3ee,var(--neon)); }
  .seasonbar > i { background:linear-gradient(90deg,#ffb020,#ff7a18); }   /* amber accent, matches the Ratings Season panel */
  /* Ratings Season track — a RAIL, not a wall. 40 tiers x 2 lanes = 80 chips; wrapping
     them at 92px meant ~10 rows on a phone and the season panel swallowed the Overview
     tab. A season pass is a line you advance along, so it now reads as one: scroll
     sideways, snap per tier, and render() parks it on the tier you're actually on. */
  .sptrack{display:grid;grid-auto-flow:column;grid-auto-columns:92px;gap:6px;padding:4px 0 8px;
    overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x proximity}
  .sptier{border:1px solid var(--line);border-radius:8px;padding:5px 5px 6px;text-align:center;background:#0b110e;scroll-snap-align:center}
  .sptier.spclaim{border-color:var(--neon);box-shadow:0 0 9px #39ff1433}
  .sptier.un{opacity:.45}
  .sptier .spt{font-size:calc(10px*var(--tscale));color:var(--dim);letter-spacing:.5px;font-weight:700}
  .spr{font-size:calc(10px*var(--tscale));margin-top:4px;border-radius:5px;padding:3px;border:1px solid var(--line);line-height:1.25}
  .spr.free{background:#0e141a}
  .spr.prem{background:#170c1c;border-color:#ff63e355}
  .spr button{width:100%;padding:1px 0;font-size:calc(10px*var(--tscale));margin:2px 0 0}
  .spr .done{color:var(--neon);font-size:calc(11px*var(--tscale))}
  .spr .lk{color:var(--dim);font-size:calc(10px*var(--tscale))}
  #tabnav { position:fixed; bottom:0; left:0; right:0; display:flex; background:#0c0f12; z-index:40; max-width:580px; margin:0 auto; padding-bottom:env(safe-area-inset-bottom); }
  /* ---------- TABLET BAND (581–1079px) ----------
     STRATEGY CHOSEN: keep the desktop/masonry breakpoint at 1080 and style this band
     as a widened phone, rather than lowering the masonry to ~900px. Reasons: deskMason
     deals ~380px columns (floor(width/380)), so 900–1079 would only ever get 2 cramped
     columns while ALSO needing isDesktopView()/the matchMedia listener/the CSS gate
     changed in lockstep — and 601–899 would still be left as an unstyled "big phone"
     with the same floating-nav bug. One CSS block here fixes the entire band.
     - #game/#login widen to 720px so an iPad doesn't get a 580px column adrift in space
       (acceptance: content ≥580px wide on any 768px+ viewport).
     - #tabnav's background/border span the FULL viewport; the button row stays capped
       at the phone's 580px and centered via side padding (no inner wrapper needed), so
       the nav never looks like a detached bar floating mid-air. */
  @media (min-width:581px) and (max-width:1079px) {
    #game, #login { max-width:720px; }
    #tabnav { max-width:none;
      padding-left:calc((100% - 580px)/2); padding-right:calc((100% - 580px)/2); }
  }
  /* ---------- DESKTOP: one-screen multi-column dashboard ----------
     On wide viewports the tabbed phone shell becomes a masonry of ALL sections at
     once (map + party + feed + everything visible together, no tab-hopping) and the
     bottom nav is hidden. Every rule is gated behind the width, so the mobile/PWA
     layout is byte-for-byte unchanged. Sections are packed whole into as many ~380px
     columns as fit. */
  @media (min-width: 1080px) {
    /* Desktop runs ~10% larger text — body/flavor copy was cramped at 100% zoom on wide
       screens. --tscale feeds every calc() font-size, so this scales everything at once. */
    :root { --tscale:1.1; }
    /* 2820px cap fits up to FIVE ~540px columns edge-to-edge on a wide monitor. The old
       1720 cap was chosen while deskMason ALSO capped at 3 columns: on a 2560 screen that
       left three stretched columns under a 1180 map with ~400px of black each side. The
       fix isn't a narrower shell — it's letting the column COUNT grow with the width so
       each stays ~540px (the design width) instead of ballooning. Now: 3 cols on a
       1280-1720 laptop, 4 on a 2560 monitor, 5 on a 3200+ ultrawide. Columns are
       flex:1 1 0 so they always fill the shell; the 540 figure is only the add-a-column
       threshold (see deskMason). */
    #game:not(.hide) { max-width:2820px; margin:0 auto; padding:14px 20px 26px; }
    #game .tab { display:block; margin:0; }
    /* TWO to FIVE ~540px columns, one added per ~540px of width (2 by ~1080px, 3 by
       ~1620, 4 by ~2160, 5 by ~2700). Panels are designed against the ~580px phone
       column; an earlier deal allowed ~380px columns, which squeezed whole tabs into
       phone-width strips — the 540 threshold keeps every column at least phone-wide.
       The columns are real elements and each section is dealt into one ONCE, on load and on
       resize (see deskMason). This was CSS multicol with column-fill:balance, which made every
       section one continuous flow: the browser re-balanced the WHOLE page whenever any card
       changed height, so opening the recruit draft or flipping the Standings board to HARDCORE
       threw unrelated cards — Inventory among them — into a different column under the
       player's cursor. Cards must only ever move when their OWN column grows. */
    #deskmason { display:flex; align-items:flex-start; gap:16px; }
    #deskmason > .dmcol { flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:16px; }
    /* The Crawl (dungeon map) is the dashboard hero: a full-width band pinned to the very
       top, above the columns. #tab-dungeon is reordered to the first child in JS. */
    #game > #tab-dungeon { max-width:none; margin:0 0 18px; }
    /* The map band pairs the hero with a RIGHT RAIL instead of centring a capped box.
       It used to be `max-width:1180px; margin:auto`, which on a 2560 monitor left ~690px
       of dead page on EACH side while the Actions panel below it was a full-width strip
       holding one half-width button. The rail now carries the whole navigation cluster
       (objective, stairs/safe counts, safe room, fast-travel, auto toggles — #map-actions
       is moved here by deskDungeonRail) plus Actions, which fills the column and leaves
       the hero as pure map/corridor art. */
    #hero-tile { display:block; border:1px solid var(--line,#1e3a2c); border-radius:10px;
      padding:9px; background:rgba(10,16,13,.55); align-self:start; }
    #game > #tab-dungeon { display:grid; grid-template-columns:minmax(210px,260px) minmax(0,1fr) minmax(340px,460px);
      gap:10px; align-items:start; }
    /* `.full { grid-column:1/-1 }` (phone semantics: span the wrap grid) would make the map
       a lone full-width band and drop the rail to a second row — the same trap already
       documented for #tab-crawler below. */
    #game > #tab-dungeon > .panel.full { grid-column:auto; max-width:none; margin:0; }
    #game > #tab-dungeon > #dungrail { display:flex; flex-direction:column; gap:10px; min-width:0; }
    /* #map-actions is bare markup inside the map frame; standing alone in the rail it needs
       its own box so it reads as a panel rather than text floating on the page. */
    #game > #tab-dungeon > #dungrail > #map-actions { border:1px solid var(--line,#1e3a2c);
      border-radius:10px; padding:7px 10px; background:rgba(10,16,13,.55); }
    #fp-btns { display:grid; grid-template-columns:1fr 1fr; gap:4px; }
    #fp-btns button { font-size:calc(9.5px*var(--tscale)); padding:4px 6px; min-height:calc(34px*var(--tscale)); }
    /* Same emptiness guard as body.mapfs below: outside a safe room this is just an
       "Actions" header over three hidden buttons, dead weight beside the map. */
    #game > #tab-dungeon > #dungrail > .panel:not(.full):not(:has(button:not([style*="display:none"]))) { display:none; }
    /* CONDENSED: aspect-ratio:1 made the mapview a giant square (left column is 1000px+ wide),
       leaving 800px of black void beneath the 180px SVG. A bounded height caps the map to a
       glanceable rectangle; the SVG fills it instead of sitting at a fixed 180px.
       The ⛶ full-screen map is where you actually navigate. */
    #game > #tab-dungeon .mapview { height: min(66vh, 720px); }
    #game > #tab-dungeon #map-svg { height: 100%; }
    /* Gear + Action Bar + Skill Constellation ride at the TOP as a full-width strip just
       under the map (JS hoists #tab-crawler above the columns). Three panels laid side by
       side; Sponsor City / personal-space overflow spans the full row when shown. */
    #game > #tab-crawler { max-width:1440px; margin:0 auto 18px; }
    #game > #tab-crawler > [data-sub="stats"] {
      display:grid; grid-template-columns:minmax(0,1.05fr) minmax(0,1fr) minmax(0,1.35fr); gap:16px; align-items:start; }
    /* The gear panel carries .full (phone semantics: span the wrap grid) — inside THIS
       grid `.full { grid-column:1/-1 }` made it a lone full-width band and left the
       third track EMPTY beside Action Bar + Constellation. Three side-by-side is the
       whole point of the band; only the Sponsor City overflow spans. */
    #game > #tab-crawler > [data-sub="stats"] > .panel.full { grid-column:auto; }
    #game > #tab-crawler > [data-sub="stats"] > .panel:has(#perks) { grid-column:1/-1; }
    /* Keep the long management lists from turning a card into a page-tall wall — they
       scroll inside a glanceable box so the columns stay comparable. (Achievements alone
       rendered ~3500px, dwarfing every other card and forcing a ~4800px page.) Desktop
       only; the phone's single-tab view is unchanged. */
    #game #achievements { max-height:460px; overflow-y:auto; overscroll-behavior:contain; padding-right:4px; }
    #game #inventory    { max-height:520px; overflow-y:auto; overscroll-behavior:contain; padding-right:4px; }
    /* Cap the other consistently-tall lists so the columns even out instead of leaving
       one long Social/roster column beside short ones. */
    #game #guild, #game #faction { max-height:460px; overflow-y:auto; overscroll-behavior:contain; padding-right:4px; }
    /* Login on a big monitor rendered as a thin strip atop a ~70%-empty viewport —
       centre the intake card vertically and give it a comfortable width. Phone and
       tablet presentation are untouched (this is all inside the desktop gate). */
    /* grid-template-columns:auto is load-bearing: #login inherits `1fr 1fr` from .wrap,
       so without this it stays a two-column grid and the 560px card — though it spans
       both columns — sits left-aligned in its 2520px cell (grid items start-align), which
       reads as "shoved to the left and halfway down" rather than centred. Collapsing to a
       single content-width column lets justify-content:center actually centre it. */
    #login:not(.hide) { min-height:calc(100vh - 120px); display:grid; grid-template-columns:auto; place-content:start center; padding-top:5vh; max-width:none; }
    #login .panel { width:min(620px, 94vw); }
    /* Install-as-PWA is a phone/tablet affordance; on a desktop it's noise — hide it even
       when Chrome fires beforeinstallprompt on the desktop. */
    #installBtn { display:none !important; }
    /* Reveal the jump-nav on desktop — only while the game (not the login) is on screen. */
    body:has(#game:not(.hide)) #deskjump { display:flex; }
    /* Exchange on the one-screen dashboard shows everything at once, so reveal all hub
       sub-panels (Shop / Market / Cosmetics) and drop the now-redundant mobile sub-nav. */
    #game #tab-hub .subnav { display:none; }
    #game #tab-hub > [data-sub] { display:block !important; }
    /* Same deal for the Social + Inventory sub-navs (audit batch 3): the one-screen
       dashboard shows every panel stacked, so the mobile-only segmenting disappears. */
    #game #tab-social .subnav, #game #tab-bags .subnav, #game #tab-crawler .subnav { display:none; }
    #game #tab-social > [data-sub], #game #tab-bags > [data-sub] { display:block !important; }
    #tabnav { display:none; }
    /* Status bar spans the same band as the columns below it (same 2820 cap). */
    #topstick { max-width:2820px; margin-left:auto; margin-right:auto; }
    /* Roomier, taller status bar on desktop: the three central identity/econ lines get
       breathing room, and HP/MP/SP become big glanceable readouts. The phone keeps its
       compact HUD untouched (this is all inside the desktop breakpoint). */
    #topstick #topbar { padding:24px 20px; }
    #topstick .hud-id { margin-bottom:16px; }
    #topstick .hud-id img.hud-av { width:54px; height:54px; }
    #topstick .hud-id .por-wrap { width:54px !important; height:54px !important; flex-basis:54px !important; margin:0 !important; }
    #topstick .hud-id-txt { line-height:1.65; font-size:calc(13px*var(--tscale)); }
    #topstick .hud-id-txt b { font-size:calc(18px*var(--tscale)); }
    /* THE DEAD CENTRE. Measured at 1280px: the last stat ended at x=541 and the vitals
       began at x=1172 — 631px of nothing, because the stat row packs left inside a
       full-width column (`justify-content:flex-start` is set inline on .hud1) while the
       vitals are pinned right. The row has the width; it just wasn't using it. Spreading
       the stats across it costs no height and fills the hole with the numbers that were
       already there. `!important` because the inline style on the element would otherwise
       win — the render sets flex-start for the PHONE, where left-packed is correct and
       space-between would fling five stats to the screen edges. */
    #topstick .hud1 { gap:10px; font-size:calc(14px*var(--tscale)); margin-top:7px;
      justify-content:flex-start !important; padding-right:6px; }
    #topstick .echip b { font-size:calc(17px*var(--tscale)); }
    #topstick .echip .uic { width:18px; height:18px; }
    #topstick .hud-right { gap:13px; }
    #topstick .vcell { min-width:118px; gap:5px; }
    #topstick .vcell .vtop { font-size:calc(19px*var(--tscale)); gap:7px; }
    #topstick .vcell .vmini { height:12px; border-radius:6px; }
  }
  .tabbtn { flex:1; min-width:0; background:none; border:none; border-top:2px solid transparent; border-radius:0; color:var(--dim); padding:6px 0 7px; margin:0; font:inherit; font-size:calc(10px*var(--tscale)); letter-spacing:.3px; cursor:pointer; overflow:hidden; --tc:var(--neon); --hue:0deg; --tc-55:rgba(57,255,20,.55); --tc-11:rgba(57,255,20,.20); --tc-7:rgba(57,255,20,.12); }
  /* one accent colour per tab. --tc-55/11/7 are PRECOMPUTED rgba (not color-mix — see
     the .panel comment above for why: color-mix() doesn't resolve on every real device). */
  .tabbtn[data-tab="dashboard"] { --tc:var(--neon); --hue:0deg;   --tc-55:rgba(57,255,20,.55);  --tc-11:rgba(57,255,20,.20);  --tc-7:rgba(57,255,20,.12); }
  /* Nav colours = the canonical scheme (user screenshot 2026-07-12): overview green,
     crawler cyan, party violet, crawl gold, inventory pink, social blue, exchange orange.
     (Web brought to match native here — the earlier native→web flip was backwards.) */
  .tabbtn[data-tab="dungeon"]   { --tc:#ffc23a; --hue:290deg; --tc-55:rgba(255,194,58,.55); --tc-11:rgba(255,194,58,.20); --tc-7:rgba(255,194,58,.12); }
  .tabbtn[data-tab="crawler"]   { --tc:#22d3ee; --hue:75deg;  --tc-55:rgba(34,211,238,.55); --tc-11:rgba(34,211,238,.20); --tc-7:rgba(34,211,238,.12); }
  .tabbtn[data-tab="bags"]      { --tc:#ff5db1; --hue:210deg; --tc-55:rgba(255,93,177,.55); --tc-11:rgba(255,93,177,.20); --tc-7:rgba(255,93,177,.12); }
  .tabbtn[data-tab="party"]     { --tc:#b07cff; --hue:150deg; --tc-55:rgba(176,124,255,.55);--tc-11:rgba(176,124,255,.20);--tc-7:rgba(176,124,255,.12); }
  .tabbtn[data-tab="social"]    { --tc:#5b8fc7; --hue:200deg; --tc-55:rgba(91,143,199,.55); --tc-11:rgba(91,143,199,.20); --tc-7:rgba(91,143,199,.12); }
  .tabbtn[data-tab="hub"]       { --tc:#ff8a3c; --hue:270deg; --tc-55:rgba(255,138,60,.55); --tc-11:rgba(255,138,60,.20); --tc-7:rgba(255,138,60,.12); }
  .tabbtn img { width:26px; height:26px; display:block; margin:0 auto 3px; opacity:.5; filter:hue-rotate(var(--hue)) saturate(1.15); }
  .tabbtn .tabglyph { width:26px; height:26px; line-height:26px; font-size:calc(19px*var(--tscale)); display:block; margin:0 auto 3px; opacity:.55; color:var(--tc); }
  .tabbtn.active .tabglyph { opacity:1; }
  /* label shrinks with the viewport so 7 tabs never clip at ≤390px, but still honors
     the user's text-scale at the top end */
  .tabbtn span { display:block; color:var(--tc); opacity:.6; font-size:clamp(8px, 2.7vw, calc(10px*var(--tscale))); }
  .tabbtn.active { color:var(--tc); border-top-color:var(--tc-55); background:var(--tc-11); }
  .tabbtn.active img { opacity:1; }
  .tabbtn.active span { opacity:1; }
  /* override the global bright button:hover — hover highlights in THAT tab's accent.
     (hover:hover) gate: on touch, :hover sticks after a tap and left the previous tab lit. */
  @media (hover:hover) {
    .tabbtn:hover { background:var(--tc-11); color:var(--tc); box-shadow:0 0 10px var(--tc-11); border-top-color:var(--tc-55); }
    .tabbtn:hover img { opacity:.85; }
    .tabbtn:hover span { opacity:1; }
    .tabbtn:hover .tabglyph { opacity:.85; }
  }
  .actrow { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:6px; margin-top:4px; }
  .actrow button { margin:0; width:100%; }
  /* --- manual combat screen --- */
  /* FULLY opaque: combat is a takeover, not a scrim. At .94 the dashboard (panel borders,
     tab bar, map) ghosted through behind the fight and made the screen read as cluttered. */
  /* inset:-18px, not 0: the hit-shake translates (and slightly rotates) this whole opaque
     takeover by up to ~10px, and at inset:0 that slid the far edge off and flashed the HUD
     behind it. Oversizing past the viewport means the background always covers even mid-shake;
     flex still centres the content, and .cbx (100vh) still fills the screen exactly. */
  /* overflow:hidden is deliberate and STAYS: this box is 18px larger than the viewport on
     every side, so letting it scroll would only expose that off-screen band. It does mean
     the black gutter around the card is not a scrollport -- a wheel over it hit nothing,
     which on a short window read as "the fight is frozen". A wheel that lands on the gutter
     is forwarded into the card in JS (see the #combat wheel handler next to openCombat).
     The card is the one scrollport, and align-items:center is safe because .cbx is capped
     BELOW this box's content height (100dvh-24px vs 100vh+12px), so it can never overflow
     past the top, where centred flex overflow would be unreachable. */
  #combat { position:fixed; inset:-18px; background:#05080a; z-index:60; display:flex; align-items:center; justify-content:center; padding:12px; overflow:hidden; }
  #combat.hide { display:none !important; }
  /* ---- DEEP-FLOOR MODIFIER COMPOSITE (ADR 0001, phase 2) --------------------------------
     A modified floor is the SAME zone plate with a filter and ONE overlay layer over it,
     composited at runtime. No second set of art — that is the whole point of the ADR.

     WHY backdrop-filter AND NOT filter: the plate is always an element's own background-image,
     so a `filter` on that element drags every child through the hue shift with it — the card,
     the log, the buttons, the foe's HP numbers. A pseudo-element in the right paint slot with
     backdrop-filter tints exactly the background and nothing else.

     THREE CARRIERS, because _zonePlateUrl() has three consumers and they need three different
     paint slots (`.cbx` is opaque var(--panel), so the #combat one is NOT enough on its own —
     it sits behind the panel unseen whenever a scene or hero card is up):
       #combat      renderCombat's backdropImage + openDescentFork's shorthand. ::before at
                    z-index:-1 — #combat is a stacking context (position:fixed + z-index:60)
                    so -1 cannot escape it, and -1 paints above the element's own background
                    and below every in-flow child.
       .cb-scene    the mob-rack stage, background-image:_zoneImg inline. ::before, which is
                    FIRST in tree order, so it lands on the plate and under the vignette, the
                    figures and the scene HUD — the figures get the FILTER on their own
                    wrapper instead (phase 4, below).
       .cb-hero     the marquee stage, background:_zoneBg inline. ::after, not ::before: the
                    hero card's .cb-hero-img is absolute inset:0 and covers the whole card, so
                    a ::before would be painted over by the art. ::after at z-index:0 lands
                    above the art (z-auto) and below .cb-hero-info (z-index:1), so the foe's
                    NAME stays unfiltered.

     Every colour here is rgba() or a token: tests/test_css_uses_the_token_palette.py ratchets
     literal hex and this block adds none. --mod-filter / --mod-a come from applyModFx() on
     <body>; with no [data-mod] attribute nothing below matches, which is what makes a payload
     without the modifier fields (lap 0, or an older server) render byte-identically. */
  body[data-mod] #combat::before, body[data-mod] .cb-scene::before, body[data-mod] .cb-hero::after {
    content:""; position:absolute; inset:0; pointer-events:none; opacity:var(--mod-a,1);
    -webkit-backdrop-filter:var(--mod-filter,none); backdrop-filter:var(--mod-filter,none); }
  body[data-mod] #combat::before { z-index:-1; }
  /* z-index:1 puts the scene composite over the plate AND over .cb-scene-vig / .cb-scene-fl
     (both z-auto) — those two are warm gradients that would otherwise sit unfiltered on top
     and wash the tint straight back out — but under .cb-side (z-index:2), so the overlay
     never lands on a figure. */
  body[data-mod] .cb-scene::before { z-index:1; }
  body[data-mod] .cb-hero::after { z-index:0; }
  /* Phase 4: the cast is lit by the same filter as its plate, so a "Frozen Sewer Rat" reads
     frozen. The figures sit above every composite (.cb-side z-index:2; the rack lives inside
     the opaque .cbx panel), so the filter rides the sprite WRAPPER — not the <img>, whose
     filter the stage prototype (.cbstage) already spends on a drop-shadow; two filters on one
     element replace rather than compose. Mob figures only; the crew keeps its colours. Static, so
     the motion guards below have nothing to kill here. Pinned by
     tests/test_client_floor_modifiers.py. */
  body[data-mod] .cb-mob-im, body[data-mod] .cb-mob .cb-fig-im { filter:var(--mod-filter,none); }
  /* Flooded — a shimmer band riding a waterline, and the bottom third gone under. */
  body[data-mod="flooded"] #combat::before, body[data-mod="flooded"] .cb-scene::before, body[data-mod="flooded"] .cb-hero::after {
    background:linear-gradient(transparent 58%, rgba(6,26,38,.62) 100%),
               repeating-linear-gradient(0deg, rgba(150,220,255,.13) 0 2px, transparent 2px 11px);
    background-size:100% 100%, 100% 33px; background-position:0 0, 0 100%;
    animation:modFlood 7s linear infinite; }
  @keyframes modFlood { from{background-position:0 0, 0 100%} to{background-position:0 0, 0 calc(100% - 33px)} }
  /* Lightless — the vignette, doubled. Static: nothing moves in the dark. */
  body[data-mod="lightless"] #combat::before, body[data-mod="lightless"] .cb-scene::before, body[data-mod="lightless"] .cb-hero::after {
    background:radial-gradient(ellipse at 50% 45%, transparent 24%, rgba(0,0,0,.72) 88%),
               radial-gradient(ellipse at 50% 45%, transparent 46%, rgba(0,0,0,.55) 100%); }
  /* Frozen — frost specks. Three dot fields on one pitch, offset, so no tile edge reads. */
  body[data-mod="frozen"] #combat::before, body[data-mod="frozen"] .cb-scene::before, body[data-mod="frozen"] .cb-hero::after {
    background:radial-gradient(1.5px 1.5px at 14px 22px, rgba(224,246,255,.55), transparent 100%),
               radial-gradient(1px 1px at 47px 9px, rgba(190,230,255,.45), transparent 100%),
               radial-gradient(2px 2px at 31px 54px, rgba(255,255,255,.35), transparent 100%),
               linear-gradient(rgba(180,225,255,.07), rgba(180,225,255,.07));
    background-size:64px 68px, 64px 68px, 64px 68px, 100% 100%; }
  /* Infested — the wrong texture, and the wrong texture is moving. */
  body[data-mod="infested"] #combat::before, body[data-mod="infested"] .cb-scene::before, body[data-mod="infested"] .cb-hero::after {
    background:radial-gradient(2.5px 2.5px at 9px 13px, rgba(150,210,60,.30), transparent 100%),
               radial-gradient(1.5px 1.5px at 30px 34px, rgba(190,230,90,.26), transparent 100%),
               radial-gradient(2px 2px at 21px 5px, rgba(110,170,40,.30), transparent 100%);
    background-size:44px 46px, 44px 46px, 44px 46px;
    animation:modCrawl 9s linear infinite; }
  @keyframes modCrawl { from{background-position:0 0, 0 0, 0 0} to{background-position:44px 46px, -44px 46px, 22px -46px} }
  /* Bloodlit — lit from below at 0.4 Hz (2.5s), and it is not a lamp. */
  body[data-mod="bloodlit"] #combat::before, body[data-mod="bloodlit"] .cb-scene::before, body[data-mod="bloodlit"] .cb-hero::after {
    background:radial-gradient(ellipse at 50% 108%, rgba(255,40,60,.34), transparent 62%);
    animation:modPulse 2.5s ease-in-out infinite; }
  @keyframes modPulse { 0%,100%{opacity:calc(var(--mod-a,1)*.45)} 50%{opacity:var(--mod-a,1)} }
  /* Ashen — grey over it, and the ash still coming down. */
  body[data-mod="ashen"] #combat::before, body[data-mod="ashen"] .cb-scene::before, body[data-mod="ashen"] .cb-hero::after {
    background:radial-gradient(1.5px 1.5px at 18px 8px, rgba(226,226,226,.42), transparent 100%),
               radial-gradient(1px 1px at 52px 41px, rgba(190,190,190,.36), transparent 100%),
               linear-gradient(rgba(120,120,120,.20), rgba(120,120,120,.20));
    background-size:70px 90px, 52px 64px, 100% 100%;
    animation:modAsh 14s linear infinite; }
  @keyframes modAsh { from{background-position:0 0, 0 0, 0 0} to{background-position:14px 90px, -9px 64px, 0 0} }
  /* Grade 3 — the render tears. One band, ~1s out of every 26s (the design's "every 20-40 s").
     Its own pseudo, not another layer on ::before: the families above each own that element's
     background AND its animation, and two animations on one element fight over both. */
  body[data-tear] #combat::after, body[data-tear] .cb-scene::after {
    content:""; position:absolute; inset:0; pointer-events:none; opacity:0;
    background:linear-gradient(transparent 0, rgba(138,235,255,.34) 38%, rgba(255,255,255,.24) 50%, rgba(138,235,255,.34) 62%, transparent 100%);
    background-size:100% 15px; background-repeat:no-repeat;
    animation:modTear 26s linear infinite; }
  /* Two different slots for the same band, for the same reason the composites above differ:
     #combat IS a stacking context so -1 stays inside it and behind the panel; .cb-scene is
     position:relative with no z-index, so -1 there would escape behind its own plate and be
     invisible. On the stage the tear goes OVER everything (the scene HUD is z-index:3) —
     a render glitch that politely stayed under the sprites would not read as one. */
  body[data-tear] #combat::after { z-index:-1; }
  body[data-tear] .cb-scene::after { z-index:4; }
  @keyframes modTear { 0%,96%{opacity:0;background-position:0 17%} 96.6%{opacity:1;background-position:0 17%}
                       99.4%{opacity:1;background-position:0 76%} 100%{opacity:0;background-position:0 76%} }
  /* Motion off. `body.no-motion *` already kills all three; this is the OS-level twin, and
     the tear's base opacity:0 means killing its animation hides it rather than freezing a
     glitch band across the screen. */
  @media (prefers-reduced-motion:reduce) {
    body[data-mod] #combat::before, body[data-mod] .cb-scene::before, body[data-mod] .cb-hero::after,
    body[data-tear] #combat::after, body[data-tear] .cb-scene::after { animation:none !important; } }
  /* The HUD's half of the modifier: the SECOND modifier as a tag, and the depth grade as
     chevrons. The primary is already in the floor's NAME (server-side, via sector). */
  .modtag { display:inline-block; padding:0 5px; border:1px solid var(--violet); border-radius:var(--radius-sm);
    color:var(--violet); font-size:var(--fs-tiny); font-weight:700; letter-spacing:1px; }
  .modgrade { color:var(--amber); font-weight:800; letter-spacing:-1px; }
  /* Phase 3: the rule the modifier imposes (amber) and what it pays back (dim), one line
     under the header. The only place the player is told what a deep floor does to them. */
  .modrule { display:block; margin-top:2px; font-size:var(--fs-tiny); color:var(--dim); line-height:1.35;
    text-transform:none; letter-spacing:0; font-weight:400; }   /* prose, not the header's caps */
  .modrule b { color:var(--amber); font-weight:600; }
  /* A FIGHT IS FULL-BLEED ON A PHONE. The 12px gutter left a strip of the app framing the
     combat panel on every side — you could see the dashboard cards you were supposedly
     deep in a dungeon away from, which reads as a window rather than a place. Desktop
     keeps the gutter (the panel is a card on a big screen and floating it is correct);
     phones lose it, so the fight IS the screen. */
  @media (max-width: 720px) {
    #combat { padding:0; }
    /* 100% here is #combat's content box = viewport + 36px (inset:-18px above, padding:0).
       Subtracting the 36 back is exactly what max-height:100vh does on the other axis;
       `none` hung the card 18px off each edge and clipped the gutter. Not 100vw -- html
       reserves a scrollbar gutter, so 100vw overshoots the containing block. */
    #combat > .cbx { max-width:calc(100% - 36px); max-height:100vh; height:100%; border-radius:0;
                     border-left:0; border-right:0; border-top:0; box-shadow:none; }
  }
  /* THE CARD IS THE SCROLLPORT. Everything a fight can put on screen -- mob rack, log,
     school meters, VICTORY/PARTY WIPED, CONTINUE -- has to stay reachable at ANY viewport
     height, so the cap is tied to the real visible height (dvh, because a phone's
     collapsing URL bar makes vh lie) and the box scrolls inside it.
     overscroll-behavior:contain keeps a fight's scroll inside the fight instead of chaining
     out to the page behind it. The log keeps its own scroll and deliberately does NOT
     contain, so a wheel that reaches the log's end chains on up to this card.
     NO VISIBLE BAR ON EITHER SCROLLER. The bright thumb was the "there is more below" hint;
     the player's verdict on it was "no scroll bar on choices and encounters". A two-paragraph
     event prompt wearing a neon gutter reads as a broken panel, not as an affordance, and the
     hint has two better carriers now: the wheel-forward handler on #combat scrolls this card
     from anywhere in the overlay, and _cbRevealResult puts CONTINUE on screen by itself when a
     fight ends. Still scrollable, just silent. BOTH halves are required (see the * rule at the
     top of this sheet: Chromium honours scrollbar-width and ignores the webkit pseudo). The log
     is paired here rather than at its own rule because it is the SECOND bar in the same card,
     and hiding one without the other just moves the complaint. */
  .cbx { width:100%; max-width:440px; max-height:96vh; max-height:calc(100dvh - 24px); overflow:auto; overscroll-behavior:contain; background:var(--panel); border:1px solid var(--neon); border-radius:10px; padding:14px; box-shadow:0 0 30px rgba(57,255,20,.15); }
  .cbx, .cb-log { scrollbar-width:none; }
  .cbx::-webkit-scrollbar, .cb-log::-webkit-scrollbar { display:none; }
  .cb-name { color:var(--red); font-weight:bold; letter-spacing:1px; }
  /* DESKTOP: a fight and a safe room are full-screen overlays, so on a monitor they were
     a 440px phone column marooned in black — the one place the mobile-first cap has
     nothing to justify it, since there's no masonry column to fit inside.
     The box and its contents have to widen TOGETHER: .cb-party is a fixed 3-up grid and
     .cb-rack is minmax(84px,1fr), so widening the box alone gives three enormous crew
     cards and 250px mob tiles. The rack gets a CEILING (not 1fr) and centres, so two
     mobs stay two readable tiles instead of stretching to fill the room. */
  @media (min-width: 900px) {
    .cbx  { max-width:1100px; }
    .tavx { max-width:860px; }   /* wider safe-room + market on desktop (was 720) */
    .cb-party { grid-template-columns:repeat(5,1fr); }
    .cb-rack  { grid-template-columns:repeat(auto-fit,minmax(84px,132px)); justify-content:center; }
  }
  @media (min-width: 1400px) {
    .cbx { max-width:1400px; }
    .cb-party { grid-template-columns:repeat(6,1fr); }
  }
  /* ---- Arena VERSUS scene: players have no sprite/paper-doll models, so a bout is a
     duel between two PROFILE CARDS — real portraits, HP bars, and the fight animated on
     the cards themselves (attacker lunges in; the one hit flashes + shakes). ---- */
  .va-wrap { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; margin-bottom:9px; }
  .va-card { position:relative; text-align:center; border:1px solid var(--line); border-radius:10px; padding:8px 6px 7px; background:rgba(0,0,0,.4); }
  .va-card .va-por { width:80px; height:80px; max-width:100%; object-fit:cover; border-radius:9px; border:2px solid var(--dim); display:block; margin:0 auto 5px; background:#0a0e12; }
  /* #95: .va-por is styled for the unframed neon/red ring (its own border + bottom margin), which left the face inset and off-fit once a real frame is equipped -- por-wrap only exists when a frame is present, so go edge-to-edge there and let the frame art supply the ring. */
  .va-card .por-wrap > img:first-child { width:100% !important; height:100% !important; margin:0; border:none; }
  .va-me .va-por { border-color:var(--neon); box-shadow:0 0 12px -3px var(--neon); }
  .va-champ .va-por { border-color:var(--red); box-shadow:0 0 12px -3px var(--red); }
  .va-nm { font-weight:800; font-size:calc(12px*var(--tscale)); letter-spacing:.4px; }
  .va-sub { font-size:calc(10px*var(--tscale)); color:var(--dim); }
  .va-hp { height:9px; border-radius:5px; overflow:hidden; background:#1a0a0a; border:1px solid rgba(255,49,49,.6); margin:6px 2px 2px; }
  .va-hp > i { display:block; height:100%; background:linear-gradient(90deg,#ff3131,#ff7b00); transition:width .35s ease; }
  .va-me .va-hp { border-color:rgba(57,255,20,.55); } .va-me .va-hp > i { background:linear-gradient(90deg,var(--neon),#8bd44a); }
  .va-vs { font-weight:900; color:var(--amber); font-size:calc(19px*var(--tscale)); letter-spacing:1px; text-shadow:0 0 10px rgba(255,176,0,.55); }
  .va-card.hit { animation:vaHit .34s ease; }
  @keyframes vaHit { 0%,100%{transform:translateX(0);filter:none} 12%{transform:translateX(-6px) rotate(-1deg);filter:brightness(1.7) sepia(.7) hue-rotate(-25deg) saturate(3)} 55%{transform:translateX(5px)} }
  .va-me.lunge { animation:vaLungeR .3s cubic-bezier(.3,1.4,.6,1); }
  .va-champ.lunge { animation:vaLungeL .3s cubic-bezier(.3,1.4,.6,1); }
  @keyframes vaLungeR { 0%,100%{transform:translateX(0) scale(1)} 45%{transform:translateX(26px) scale(1.05)} }
  @keyframes vaLungeL { 0%,100%{transform:translateX(0) scale(1)} 45%{transform:translateX(-26px) scale(1.05)} }
  /* CHARGE — a PHYSICAL attacker crosses the scene, lands the blow on the far side, and
     comes back. The lunge above is a 26px nudge, which reads as flinching toward the foe
     rather than reaching it; a bite or a slam has to actually arrive.
     `--charge` is the measured gap to the target, written per-swing in JS (_cbCharge), so
     this holds at any scene width — a hard-coded distance would overshoot on a phone and
     fall short on a monitor. The hold between 40% and 55% is the contact window: the
     impact FX fire inside it, so the hit lands while the attacker is actually there.
     Casters and spitters never get this — they throw from where they stand, which is the
     whole visual difference between a wolf and a lich. */
  @keyframes cbCharge {
    0%   { transform:translateX(0) scale(1); }
    40%  { transform:translateX(var(--charge,0px)) scale(1.07); }
    55%  { transform:translateX(var(--charge,0px)) scale(1.07); }
    100% { transform:translateX(0) scale(1); }
  }
  .charge { animation:cbCharge var(--chargeMs,560ms) cubic-bezier(.35,.02,.25,1); z-index:40; }
  .cb-scene .cb-fig.charge { animation:none; z-index:40; }
  .cb-scene .cb-fig.charge .cb-fig-im { animation:cbCharge var(--chargeMs,560ms) cubic-bezier(.35,.02,.25,1); }
  @keyframes cbMobEnter { from { transform:translateX(60px); opacity:0; } to { transform:translateX(0); opacity:1; } }
  .cb-mob-enter { animation:cbMobEnter .45s ease-out both; }
  @media (prefers-reduced-motion:reduce) { .cb-mob-enter { animation:none; } }
  .dmg-pop.move { color:var(--amber); font-size:calc(11px*var(--tscale)); font-weight:800; text-shadow:0 0 6px #000; }
  /* Enemy intent telegraph (next move) */
  .cb-intent { display:inline-block; font-size:calc(11px*var(--tscale)); font-weight:700; letter-spacing:.3px; margin-bottom:3px; padding:1px 7px; border-radius:999px;
     color:var(--red); border:1px solid rgba(255,77,77,.55); background:rgba(255,77,77,.12); }
  .cb-intent.sp { color:var(--amber); border-color:rgba(255,183,51,.6); background:rgba(255,183,51,.13); text-shadow:0 0 7px rgba(255,176,0,.5); }
  .cb-hpbar { position:relative; height:14px; background:#1a0a0a; border:1px solid var(--red); border-radius:7px; overflow:hidden; margin:6px 0 3px; }
  .cb-hpbar > i { display:block; height:100%; background:linear-gradient(90deg,#ff3131,#ff7b00); transition:width .2s; }
  .cb-hptext { font-size:calc(11px*var(--tscale)); color:var(--dim); }
  /* wind-up warning + crowd/hype meter (combat overhaul) */
  /* PERF: sspulse/enrPulse/youPulse used to animate box-shadow (main-thread repaint
     every frame). Pattern now (same as the tile sheen): the BASE glow is painted
     statically on the element; the pulse "delta" glow is pre-rendered at full
     strength on a ::after layer whose ONLY animated property is opacity. Hosts that
     are overflow:hidden (.cb-hpbar, .cb-tile) get an INSET glow layer instead, since
     a pseudo's outer shadow would be clipped. Rhythm/durations unchanged. */
  .cb-windup { position:relative; font-size:calc(11px*var(--tscale)); font-weight:800; color:var(--amber); text-align:center; margin:5px 0 2px; text-shadow:0 0 8px rgba(255,176,0,.5); box-shadow:0 0 7px var(--pink); }
  .cb-windup::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; box-shadow:0 0 17px var(--pink); animation:sspulse 1.1s ease-in-out infinite; }
  .cb-hype { position:relative; height:18px; border-radius:6px; overflow:hidden; background:#160a02; border:1px solid rgba(255,183,51,.6); margin:6px 0 3px; }
  .cb-hype > i { position:absolute; left:0; top:0; bottom:0; background:linear-gradient(90deg,#ff7a1a,#ffd23f); opacity:.5; transition:width .3s; }
  .cb-hype > span { position:relative; display:block; text-align:center; font-size:calc(10px*var(--tscale)); font-weight:800; letter-spacing:.5px; color:var(--amber); line-height:18px; text-shadow:0 0 4px #000; }
  /* Break meter — marquee foes only. Violet, to read distinct from the amber Hype bar. */
  .cb-break { position:relative; height:18px; border-radius:6px; overflow:hidden; background:#140a1f; border:1px solid rgba(192,140,255,.55); margin:6px 0 3px; }
  .cb-break > i { position:absolute; left:0; top:0; bottom:0; background:linear-gradient(90deg,#7a3aff,#c08cff); opacity:.55; transition:width .3s; }
  .cb-break > span { position:relative; display:block; text-align:center; font-size:calc(10px*var(--tscale)); font-weight:800; letter-spacing:.5px; color:#d9b8ff; line-height:18px; text-shadow:0 0 4px #000; }
  .cb-break.shattered { border-color:#c08cff; box-shadow:0 0 10px rgba(192,140,255,.6); animation:cbBreakPulse .5s ease-in-out infinite alternate; }
  .cb-break.shattered > i { opacity:.9; }
  @keyframes cbBreakPulse { from{box-shadow:0 0 6px rgba(192,140,255,.45);} to{box-shadow:0 0 16px rgba(192,140,255,.85);} }
  /* combat FEEL: HP bar hit-shake on every action; enrage red-pulse; status flags */
  .cb-hpbar.big { animation:cbHit .28s ease; }
  @keyframes cbHit { 0%,100%{transform:translateX(0)} 30%{transform:translateX(-3px)} 70%{transform:translateX(3px)} }
  .cb-hpbar.big.enr { border-color:var(--hp); box-shadow:0 0 7px rgba(255,46,77,.5), 0 0 18px rgba(255,46,77,.6); animation:cbHit .28s ease; }
  /* overflow:hidden host — the pulse rides an INSET glow layer, opacity-only */
  .cb-hpbar.big.enr::after { content:""; position:absolute; inset:0; pointer-events:none; box-shadow:inset 0 0 10px rgba(255,46,77,.85); animation:enrPulse 1s ease-in-out infinite; }
  @keyframes enrPulse { 0%,100%{opacity:.3} 50%{opacity:1} }
  .cb-flags { display:flex; gap:6px; justify-content:center; margin:4px 0 2px; }
  .cb-flag { position:relative; font-size:calc(10px*var(--tscale)); font-weight:800; padding:1px 8px; border-radius:999px; letter-spacing:.3px; box-shadow:0 0 7px var(--pink); }
  .cb-flag::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; box-shadow:0 0 17px var(--pink); animation:sspulse 1.1s ease-in-out infinite; }
  .cb-flag.enr { color:var(--hp); border:1px solid rgba(255,46,77,.6); background:rgba(255,46,77,.12); }
  .cb-flag.spo { color:#ffd23f; border:1px solid rgba(255,210,63,.6); background:rgba(255,210,63,.12); }
  /* combat FEEL: hero hit-reaction (shake + flash) + floating damage/heal numbers */
  .cb-hero.hit { animation:cbHeroHit .3s ease; }
  @keyframes cbHeroHit { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-5px)} 60%{transform:translateX(4px)} }
  /* NOTE: named cbHeroFlash, NOT cbFlash — the FX layer defines its own @keyframes cbFlash
     (opacity pop for .cb-fx-flash) and a duplicate name let it win document-wide, which made
     the hero portrait fade OUT on every hit instead of flaring. Keep the names distinct. */
  .cb-hero.hit .cb-hero-img { animation:cbHeroFlash .32s ease; }
  @keyframes cbHeroFlash { 0%{filter:none} 35%{filter:brightness(2.1) saturate(.6)} 100%{filter:none} }
  .dmg-pop { position:absolute; z-index:80; pointer-events:none; font-weight:900; font-size:calc(20px*var(--tscale)); font-family:var(--mono);
    transform:translate(-50%,-50%); text-shadow:0 2px 5px #000,0 0 9px currentColor; animation:dmgFloat .8s ease-out forwards; }
  .dmg-pop.enemy { color:#ff8a3c; } .dmg-pop.hurt { color:var(--red); } .dmg-pop.heal { color:var(--neon); }
  .dmg-pop.big { font-size:calc(31px*var(--tscale)); } .dmg-pop.crit { color:#ffd23f; }
  @keyframes dmgFloat { 0%{opacity:0; transform:translate(-50%,-30%) scale(.6)} 18%{opacity:1; transform:translate(-50%,-62%) scale(1.15)} 100%{opacity:0; transform:translate(-50%,-150%) scale(1)} }
  body.no-motion .dmg-pop { animation:none; opacity:1; }
  /* per-ability CAST FX: school-colored projectile / slash / heal-bloom over the combat scene (tinted via --fx) */
  .cb-fx { position:absolute; pointer-events:none; z-index:85; }
  .cb-fx-orb { width:calc(15px*var(--tscale)); height:calc(15px*var(--tscale)); margin:calc(-7.5px*var(--tscale)) 0 0 calc(-7.5px*var(--tscale));
    border-radius:50%; background:radial-gradient(circle at 40% 35%,#fff,var(--fx) 55%,transparent 76%);
    box-shadow:0 0 10px 2px var(--fx),0 0 18px 4px var(--fx); animation:cbOrb .4s cubic-bezier(.45,.05,.6,1) forwards; }
  .cb-fx-orb.big { width:calc(22px*var(--tscale)); height:calc(22px*var(--tscale)); margin:calc(-11px*var(--tscale)) 0 0 calc(-11px*var(--tscale));
    animation-duration:.46s; box-shadow:0 0 16px 4px var(--fx),0 0 30px 8px var(--fx); }
  @keyframes cbOrb { 0%{transform:translate(0,0) scale(.7); opacity:.2} 12%{opacity:1} 100%{transform:translate(var(--dx,0),var(--dy,0)) scale(1); opacity:1} }
  .cb-fx-burst { width:calc(46px*var(--tscale)); height:calc(46px*var(--tscale)); margin:calc(-23px*var(--tscale)) 0 0 calc(-23px*var(--tscale));
    border-radius:50%; background:radial-gradient(circle,var(--fx) 0%,transparent 68%); box-shadow:0 0 18px 4px var(--fx);
    opacity:0; animation:cbBurst .4s ease-out forwards; }
  .cb-fx-burst.big { width:calc(76px*var(--tscale)); height:calc(76px*var(--tscale)); margin:calc(-38px*var(--tscale)) 0 0 calc(-38px*var(--tscale)); box-shadow:0 0 30px 8px var(--fx); }
  @keyframes cbBurst { 0%{transform:scale(.25); opacity:0} 25%{opacity:.95} 100%{transform:scale(1.5); opacity:0} }
  .cb-fx-slash { width:calc(58px*var(--tscale)); height:calc(5px*var(--tscale)); margin:calc(-2.5px*var(--tscale)) 0 0 calc(-29px*var(--tscale));
    border-radius:3px; background:linear-gradient(90deg,transparent,#fff,var(--fx),transparent); box-shadow:0 0 12px 2px var(--fx);
    transform:rotate(-32deg) scaleX(0); opacity:0; animation:cbSlash .42s ease-out forwards; }
  .cb-fx-slash.big { width:calc(86px*var(--tscale)); height:calc(7px*var(--tscale)); margin:calc(-3.5px*var(--tscale)) 0 0 calc(-43px*var(--tscale)); }
  @keyframes cbSlash { 0%{transform:rotate(-32deg) scaleX(0); opacity:0} 30%{opacity:1} 45%{transform:rotate(-32deg) scaleX(1); opacity:1} 100%{transform:rotate(-32deg) scaleX(1) translateX(7px); opacity:0} }
  .cb-fx-bloom { width:calc(52px*var(--tscale)); height:calc(52px*var(--tscale)); margin:calc(-26px*var(--tscale)) 0 0 calc(-26px*var(--tscale));
    border-radius:50%; background:radial-gradient(circle,var(--fx) 0%,transparent 70%); box-shadow:0 0 22px 6px var(--fx);
    opacity:0; animation:cbBloom .64s ease-out forwards; }
  .cb-fx-bloom.big { width:calc(80px*var(--tscale)); height:calc(80px*var(--tscale)); margin:calc(-40px*var(--tscale)) 0 0 calc(-40px*var(--tscale)); animation-duration:.76s; }
  @keyframes cbBloom { 0%{transform:translateY(10px) scale(.4); opacity:0} 30%{opacity:.9} 100%{transform:translateY(-26px) scale(1.25); opacity:0} }
  #combat.cb-shake { animation:cbShake .44s ease; }
  @keyframes cbShake { 0%,100%{transform:translate(0,0)} 20%{transform:translate(-4px,2px)} 45%{transform:translate(4px,-2px)} 70%{transform:translate(-3px,1px)} }
  body.no-motion .cb-fx { animation:none !important; opacity:0 !important; }
  body.no-motion #combat.cb-shake { animation:none !important; }
  /* --- punchier per-skill cast FX: shards, rings, beams, angled streaks, impact flash, meteor --- */
  .cb-fx-shard { width:calc(7px*var(--tscale)); height:calc(7px*var(--tscale)); margin:calc(-3.5px*var(--tscale)) 0 0 calc(-3.5px*var(--tscale));
    border-radius:2px; background:linear-gradient(120deg,#fff,var(--fx)); box-shadow:0 0 9px 2px var(--fx);
    opacity:0; animation:cbShard var(--slife,.5s) cubic-bezier(.2,.7,.3,1) forwards; }
  @keyframes cbShard { 0%{transform:translate(0,0) scale(.5) rotate(0); opacity:0} 15%{opacity:1} 100%{transform:translate(var(--dx,0),var(--dy,0)) scale(1) rotate(var(--spin,220deg)); opacity:0} }
  .cb-fx-ring { width:calc(30px*var(--tscale)); height:calc(30px*var(--tscale)); margin:calc(-15px*var(--tscale)) 0 0 calc(-15px*var(--tscale));
    border-radius:50%; border:calc(3px*var(--tscale)) solid var(--fx); box-shadow:0 0 16px 3px var(--fx),0 0 18px 5px var(--fx) inset;
    opacity:0; animation:cbRing var(--rlife,.5s) ease-out forwards; }
  .cb-fx-ring.big { border-width:calc(4px*var(--tscale)); }
  @keyframes cbRing { 0%{transform:scale(.2); opacity:0} 22%{opacity:1} 100%{transform:scale(3.5); opacity:0} }
  .cb-fx-ring.implode { animation-name:cbRingIn; }
  @keyframes cbRingIn { 0%{transform:scale(3.5); opacity:0} 22%{opacity:1} 100%{transform:scale(.15); opacity:0} }
  .cb-fx-beam { height:calc(6px*var(--tscale)); margin-top:calc(-3px*var(--tscale)); transform-origin:0 50%;
    background:linear-gradient(90deg,var(--fx),#fff 55%,var(--fx)); box-shadow:0 0 14px 3px var(--fx); border-radius:3px;
    opacity:0; animation:cbBeam var(--blife,.42s) ease-out forwards; }
  .cb-fx-beam.big { height:calc(9px*var(--tscale)); margin-top:calc(-4.5px*var(--tscale)); }
  @keyframes cbBeam { 0%{opacity:0; filter:brightness(2.4)} 12%{opacity:1} 58%{opacity:1} 100%{opacity:0} }
  .cb-fx-streak { width:calc(62px*var(--tscale)); height:calc(5px*var(--tscale)); margin:calc(-2.5px*var(--tscale)) 0 0 calc(-31px*var(--tscale));
    border-radius:3px; background:linear-gradient(90deg,transparent,#fff,var(--fx),transparent); box-shadow:0 0 12px 2px var(--fx);
    transform:rotate(var(--rot,-32deg)) scaleX(0); opacity:0; animation:cbStreak var(--klife,.4s) ease-out forwards; }
  .cb-fx-streak.big { width:calc(98px*var(--tscale)); margin-left:calc(-49px*var(--tscale)); height:calc(7px*var(--tscale)); margin-top:calc(-3.5px*var(--tscale)); }
  @keyframes cbStreak { 0%{transform:rotate(var(--rot,-32deg)) scaleX(0); opacity:0} 28%{opacity:1} 46%{transform:rotate(var(--rot,-32deg)) scaleX(1); opacity:1} 100%{transform:rotate(var(--rot,-32deg)) scaleX(1) translateX(8px); opacity:0} }
  .cb-fx-flash { border-radius:9px; background:radial-gradient(circle,var(--fx) 0%,transparent 72%); mix-blend-mode:screen;
    opacity:0; animation:cbFlash var(--flife,.34s) ease-out forwards; }
  @keyframes cbFlash { 0%{opacity:0; transform:scale(.7)} 22%{opacity:.92; transform:scale(1.12)} 100%{opacity:0; transform:scale(1)} }
  .cb-fx-meteor { width:calc(26px*var(--tscale)); height:calc(26px*var(--tscale)); margin:calc(-13px*var(--tscale)) 0 0 calc(-13px*var(--tscale));
    border-radius:50%; background:radial-gradient(circle at 40% 30%,#fff,var(--fx) 55%,transparent 80%);
    box-shadow:0 0 20px 6px var(--fx),0 0 34px 10px var(--fx); animation:cbMeteor var(--mlife,.44s) cubic-bezier(.5,0,.85,.5) forwards; }
  .cb-fx-meteor::after { content:''; position:absolute; left:15%; width:70%; top:calc(-200%); height:calc(250%);
    background:linear-gradient(to bottom,transparent 10%,var(--fx) 85%,#fff); border-radius:40%; opacity:.55;
    filter:blur(calc(3px*var(--tscale))); animation:cbMeteorTail var(--mlife,.44s) ease-out forwards; }
  @keyframes cbMeteorTail { 0%{opacity:.55;height:calc(250%)} 70%{opacity:.4} 100%{opacity:0;height:calc(80%)} }
  @keyframes cbMeteor { 0%{transform:translate(var(--mdx,0),var(--mdy,-160px)) scale(.7); opacity:.2} 20%{opacity:1} 100%{transform:translate(0,0) scale(1.15); opacity:1} }
  /* DROPLET — venom spatter. Half the meteor's size, no white core, a 46px fall instead
     of 150px, and it squashes on impact instead of blooming. The distinction is the
     whole point: a venom spit must not read as artillery. */
  .cb-fx-drop { width:calc(13px*var(--tscale)); height:calc(13px*var(--tscale));
    margin:calc(-6.5px*var(--tscale)) 0 0 calc(-6.5px*var(--tscale));
    border-radius:50%; background:radial-gradient(circle at 45% 35%,var(--fx),transparent 78%);
    box-shadow:0 0 7px 2px var(--fx); animation:cbDrop var(--dlife,.3s) cubic-bezier(.55,0,.85,.6) forwards; }
  @keyframes cbDrop { 0%{transform:translate(var(--ddx,0),-46px) scale(.85); opacity:.35}
    25%{opacity:1} 100%{transform:translate(0,0) scaleX(1.35) scaleY(.6); opacity:.9} }
  .cb-speed { padding:1px 8px; font-size:calc(10px*var(--tscale)); font-weight:800; letter-spacing:.4px;
    border-color:var(--cyan); color:var(--cyan); margin:0 4px 0 0; }
  #combat.cb-shake.hard { animation:cbShakeHard .5s ease; }
  @keyframes cbShakeHard { 0%,100%{transform:translate(0,0)} 15%{transform:translate(-7px,4px) rotate(-.4deg)} 38%{transform:translate(7px,-3px) rotate(.4deg)} 60%{transform:translate(-5px,3px)} 82%{transform:translate(4px,-2px)} }
  .cb-weak-pop { position:absolute; z-index:90; pointer-events:none; transform:translate(-50%,-50%);
    font:900 calc(15px*var(--tscale))/1 ui-monospace,monospace; letter-spacing:2px; text-shadow:0 0 8px currentColor,0 0 3px #000;
    animation:cbWeakPop .82s cubic-bezier(.2,1.3,.3,1) forwards; }
  @keyframes cbWeakPop { 0%{opacity:0; transform:translate(-50%,-30%) scale(.4)} 20%{opacity:1; transform:translate(-50%,-70%) scale(1.25)} 70%{opacity:1} 100%{opacity:0; transform:translate(-50%,-150%) scale(1)} }
  body.no-motion .cb-weak-pop { animation:none !important; opacity:0 !important; }
  /* ---- COMBAT JUICE (body.cbjuice, localStorage cb_juice, default OFF) ------------------
     Ambient life over the DIORAMA plate, the Barrowdeep trick: the backdrop never moves, the
     motion is cheap layers stacked over it. These live INSIDE .cb-scene, not on #combat --
     .cbx is opaque (var(--panel)), so a #combat pseudo sits behind the panel unseen; the
     visible plate is .cb-scene's OWN background. .cb-scene rebuilds on every action, so an
     infinite loop here restarts each render -- which is fine: renderCombat fires on actions /
     auto-fight, not a timer, so idle breathes uninterrupted and under auto-fight the actor FX
     carry the screen anyway. Inert without the flag; body.no-motion (the `*,*::before,*::after
     {animation:none}` rule further down) already freezes every keyframe below. */
  .cb-scene-glow, .cb-scene-motes { position:absolute; inset:0; z-index:1; pointer-events:none; display:none; }
  body.cbjuice .cb-scene-glow, body.cbjuice .cb-scene-motes { display:block; }
  /* candle glow: ONE NODE PER FLAME, so each gutters on its own. A single element with eight
     background layers could only flicker as one lamp -- real candles are never in phase. The
     eight positions are MEASURED centroids of the warm-bright pixels in
     bg_f02_ossuary_vaults_a.jpg (the calm plate, shown most): both bright wall niches at the
     far edges, two mid sconces, four dim corridor candles. Radius + base alpha (--r/--o) scale
     with each flame's measured brightness; each node then picks a random duration + phase and
     one of three irregular flicker patterns (fk0/1/2) so no two rise and fall together. Opacity
     only -- a candle varies in brightness, not size, and the centring transform must hold.
     Anchored to the 'a' plate; b/c/d light candles slightly differently, so alignment is close
     but not exact there, and a frame far from the plate's 3:2 aspect cover-crops it and nudges
     these. Built once into _CB_CANDLES so the phases stay fixed across the per-action rebuilds. */
  body.cbjuice .cb-scene-glow > i { position:absolute; transform:translate(-50%,-50%);
    width:var(--r,130px); height:var(--r,130px); border-radius:50%; pointer-events:none;
    background:radial-gradient(circle, rgba(var(--gcr,255),var(--gcg,184),var(--gcb,96),var(--o,.3)), rgba(var(--gcr,255),var(--gcg,184),var(--gcb,96),calc(var(--o,.3)*.4)) 45%, transparent 68%);
    animation-duration:var(--dur,3.6s); animation-timing-function:ease-in-out;
    animation-iteration-count:infinite; animation-delay:var(--dly,0s); will-change:opacity; }
  body.cbjuice .cb-scene-glow > i.fk0 { animation-name:cbjFlk0; }
  body.cbjuice .cb-scene-glow > i.fk1 { animation-name:cbjFlk1; }
  body.cbjuice .cb-scene-glow > i.fk2 { animation-name:cbjFlk2; }
  @keyframes cbjFlk0 { 0%,100%{opacity:.85} 15%{opacity:1} 28%{opacity:.58} 44%{opacity:.95} 60%{opacity:.72} 78%{opacity:1} 88%{opacity:.64} }
  @keyframes cbjFlk1 { 0%,100%{opacity:.7} 12%{opacity:.96} 22%{opacity:.52} 38%{opacity:.88} 52%{opacity:1} 67%{opacity:.6} 83%{opacity:.9} }
  @keyframes cbjFlk2 { 0%,100%{opacity:.92} 18%{opacity:.58} 30%{opacity:1} 46%{opacity:.7} 58%{opacity:.9} 72%{opacity:.5} 86%{opacity:.86} }
  /* dust / embers: INDIVIDUAL particles, not a tiled texture -- a repeating dot field always
     reads as a lattice. Each <i> gets a random position / size / speed / drift, emitted once
     into _CB_MOTES (module load) so the field is stable across the per-action rebuilds and
     never jumps. Negative animation-delays scatter them across their cycle, so the scene is
     already full of embers on the first frame after a rebuild. transform+opacity only. */
  body.cbjuice .cb-scene-motes > i { position:absolute; border-radius:50%;
    width:var(--s,3px); height:var(--s,3px); will-change:transform,opacity;
    background:radial-gradient(circle, rgba(255,206,150,.95), rgba(255,168,88,.5) 55%, transparent 72%);
    animation:cbjMote var(--dur,12s) linear var(--dly,0s) infinite; }
  @keyframes cbjMote { 0%{transform:translate(0,0) scale(.7); opacity:0}
    14%{opacity:var(--o,.7)} 82%{opacity:var(--o,.7)}
    100%{transform:translate(var(--dx,6px),-130px) scale(1); opacity:0} }
  /* the fight has TURNED: on top of the a->b plate swap, dim + desaturate the whole scene so
     "you're losing" is felt in the light, not only the art. Static -- the node rebuilds, so a
     transition would not run; the cue is the end state, which persists across the rebuild. */
  body.cbjuice .cb-scene.turned { filter:brightness(.86) saturate(.9); }
  /* breathing removed (Barrowdeep does not use it) */
  /* ---- INITIATIVE HUD (body.cbinit, localStorage cb_view='initiative') -----------------
     A THIRD cb_view value, NOT an extension of cb_stage (one key = one meaning). It reuses
     the diorama scene wholesale -- so the FX contract (.cb-mob[data-mid], .cb-you) is intact,
     since those live on the scene figures -- and layers three flag-gated readouts over it: a
     top turn-order queue from p.initiative, a bottom avatar HP orb (mitigation on the rim,
     ward pip), and per-crew stance toggles. Every piece degrades to nothing when the server
     flag is off (p.initiative absent), so this view then renders exactly like the diorama. */
  .cb-queue { position:absolute; left:6px; right:6px; top:4px; z-index:4;
    display:flex; flex-direction:row; gap:3px; overflow-x:auto; pointer-events:auto;
    touch-action:pan-x; scrollbar-width:none; }
  .cb-queue::-webkit-scrollbar { display:none; }
  .cb-qchip { display:flex; align-items:center; gap:4px; padding:2px 5px; border-radius:5px;
    border:1px solid var(--line); background:rgba(6,10,8,.72); flex-shrink:0;
    font:700 calc(9px*var(--tscale))/1.25 ui-monospace,monospace; letter-spacing:.03em;
    color:var(--sub); opacity:.7; text-shadow:0 1px 3px #000; }
  .cb-qchip .cb-qk { flex:0 0 auto; }
  .cb-qchip .cb-qn { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .cb-qchip.foe { border-color:rgba(255,77,77,.4); }
  .cb-qchip.you { border-color:rgba(56,219,163,.4); }
  .cb-qchip.crew { border-color:rgba(143,233,255,.35); }
  .cb-qchip.active { opacity:1; border-color:var(--amber); color:var(--amber);
    box-shadow:0 0 8px rgba(255,183,51,.5); }
  .cb-qchip.acted { opacity:.34; }
  .cb-orb { position:absolute; left:50%; bottom:20px; transform:translateX(-50%); z-index:5;
    width:clamp(84px,22vw,120px); height:clamp(84px,22vw,120px); border-radius:50%;
    display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;
    border:2px solid rgba(255,255,255,.08); background:radial-gradient(circle at 50% 34%, rgba(30,14,14,.6), rgba(6,10,8,.95) 72%);
    box-shadow:0 0 14px rgba(0,0,0,.7), inset 0 0 12px rgba(255,77,77,.15); text-shadow:0 1px 3px #000; }
  .cb-orb-ring { position:absolute; inset:-3px; z-index:3; pointer-events:none; }
  .cb-orb-ring circle { fill:none; stroke-width:2.8; }
  .cb-orb-ring .cb-ring-bg { stroke:rgba(255,255,255,.08); }
  .cb-orb-ring .cb-ring-fg { stroke-linecap:round; transition:stroke-dashoffset .5s ease, stroke .5s ease; }
  .cb-orb-fill { position:absolute; inset:0; border-radius:50%; clip-path:inset(var(--hp-clip,0%) 0 0 0);
    pointer-events:none; transition:clip-path .45s ease, background .45s ease; }
  .cb-orb-hp, .cb-orb-mp { position:relative; z-index:2; }
  .cb-orb.down { filter:grayscale(.7) brightness(.7); }
  .cb-orb-rim { position:absolute; top:-8px; left:50%; transform:translateX(-50%);
    display:flex; align-items:center; gap:3px; padding:0 6px; border-radius:8px;
    border:1px solid var(--line); background:rgba(6,10,8,.92);
    font:800 calc(10px*var(--tscale))/1.4 ui-monospace,monospace; color:var(--cyan); }
  .cb-orb-ward { color:var(--amber); }
  .cb-orb-hp { display:flex; flex-direction:column; align-items:center; line-height:1; }
  .cb-orb-hp b { font:900 calc(20px*var(--tscale))/1 ui-monospace,monospace; color:#fff; }
  .cb-orb-hp span { font:700 calc(9px*var(--tscale))/1.3 ui-monospace,monospace; color:var(--dim); }
  .cb-orb-mana { display:flex; align-items:center; gap:5px; margin-top:4px; width:clamp(84px,22vw,120px); }
  .cb-orb-mana > b { flex:1; display:block; height:5px; border-radius:3px; overflow:hidden;
    background:rgba(255,255,255,.14); }
  .cb-orb-mana > b > i { display:block; height:100%; background:var(--cyan); }
  .cb-orb-mana > span { font:700 calc(9px*var(--tscale))/1 ui-monospace,monospace; color:var(--cyan);
    white-space:nowrap; }
  /* INITIATIVE bottom HUD: one full-width band under the scene, three zones. LEFT the avatar
     orb (the anchor), CENTER the ability bar, RIGHT the crew stance blocks. It STACKS on a
     phone and becomes a row at 900px -- the same breakpoint the card widens to 760px, so the
     three-zone row never has to fit inside the 440px phone column (no horizontal overflow). */
  .cb-hud { display:flex; flex-direction:column; gap:9px; margin:8px 0 4px; padding:9px 10px;
    border:1px solid var(--line); border-radius:10px;
    background:linear-gradient(180deg, rgba(10,16,13,.55), rgba(6,10,8,.32)); }
  .cb-hud-orb { display:flex; flex-direction:column; justify-content:center; align-items:center; gap:2px; }
  .cb-hud-orb .vitals-gauge { width:clamp(100px,20vw,140px); height:clamp(100px,20vw,140px); }
  .cb-hud-orb .cb-orb-rim { position:relative; top:auto; left:auto; transform:none; margin-top:-6px; }
  .cb-hud-acts { min-width:0; }
  .cb-hud-acts .cb-bar { margin-top:0; }
  .cb-hud-crew { display:flex; flex-wrap:wrap; gap:6px; justify-content:center; min-width:0; }
  /* The wrapper dissolves so each crew card is a direct flex child of .cb-hud-crew and can wrap. */
  .cb-crewzone { display:contents; }
  .cb-stcrew { display:flex; flex-direction:column; gap:4px; flex:1 1 118px; min-width:112px;
    max-width:184px; padding:5px 6px; border:1px solid var(--line); border-radius:8px;
    background:rgba(6,10,8,.5); }
  .cb-stcrew.down { opacity:.55; }
  .cb-stcrew-hd { display:flex; align-items:baseline; justify-content:space-between; gap:5px; }
  .cb-stcrew-hd .cb-stnm { font:700 calc(10px*var(--tscale))/1 ui-monospace,monospace;
    color:var(--sub); max-width:104px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .cb-sthp { font:700 calc(9px*var(--tscale))/1 ui-monospace,monospace; color:var(--txt); white-space:nowrap; }
  .cb-sthp i { color:var(--dim); font-style:normal; }
  .cb-sthpbar { height:5px; border-radius:3px; overflow:hidden; background:rgba(3,6,5,.8);
    box-shadow:0 0 0 1px rgba(0,0,0,.55); }
  .cb-sthpbar > i { display:block; height:100%; transition:width .2s;
    background:linear-gradient(90deg,var(--neon),#8fe86a); }
  .cb-sthpbar.down > i { background:linear-gradient(90deg,#5a5f5a,#888); }
  .cb-stbtns { display:flex; gap:3px; justify-content:center; }
  .cb-stbtn { flex:1 1 auto; padding:8px 5px; border-radius:5px; border:1px solid var(--line);
    background:#0b110e; color:var(--dim); cursor:pointer;
    font:700 calc(10px*var(--tscale))/1.2 ui-monospace,monospace; }
  .cb-stbtn.on { border-color:var(--neon); color:var(--neon); background:rgba(56,219,163,.14); }
  @media (min-width:900px){
    .cb-hud { flex-direction:row; align-items:stretch; gap:14px; }
    .cb-hud-orb { flex:0 0 auto; align-items:center; }
    .cb-hud-acts { flex:1 1 auto; display:flex; flex-direction:column; justify-content:center; }
    .cb-hud-crew { flex:0 1 auto; max-width:44%; justify-content:flex-end; align-content:center; }
    .cb-stcrew { flex:0 1 150px; }
  }
  /* A FIGHT HOLDS ITS FRAME. .cbx is content-sized, and a fight's content changes size
     constantly — mobs die and leave the rack, abilities come off cooldown and appear in
     the bar, the Break/wind-up/hype strips show and hide. Every one of those reflowed a
     centred box, so the whole window jumped under the cursor mid-combat. Pinned to one
     height for the duration; the box scrolls internally instead of resizing.
     ONLY the fight: events and the death/path cards are short, and a fixed tall frame
     would strand a 2-choice prompt in empty space.
     max-height is the guard rail. `height` on its own will out-grow a short window (and
     88vh lies on mobile), and #combat clips, so anything past the frame would be stranded
     with no scrollport. Capped to the real visible height the frame shrinks instead and
     the content scrolls within it.
     THE PIXEL CAP IS GENEROUS ON PURPOSE (was 760px): on a 1080p+ monitor the fight was a
     760px letterbox marooned in black while the mob rack, log, school chips, vitals and
     action bar all fought over that one short column. 1040px lets a tall screen show the
     whole fight at once. The dvh term still wins on anything short, and the
     `calc(100dvh - 24px)` guard rail below is UNTOUCHED -- a short window still shrinks the
     frame and scrolls inside it rather than stranding content past the clip. */
  /* ONE SIZE FOR THE WHOLE FIGHT. This used to size in `dvh` — the DYNAMIC viewport unit,
     which is defined to change as mobile browser chrome (the URL bar) collapses and expands.
     So the card visibly grew and shrank mid-fight just from scrolling, with nothing about the
     combat having changed. `svh` is the SMALL viewport height: it assumes the chrome is shown
     and stays put, so the panel keeps one height for the entire encounter. vh is the fallback
     for anything that doesn't know svh. */
  .cbx.fight { height:min(92vh,1040px); height:min(92svh,1040px); max-height:calc(100svh - 24px); }
  /* The log is a SECOND scroller sitting in the vertical middle of the card -- exactly where
     the cursor rests -- and every render pins it to its own bottom. Its height stays bounded
     (118px) so the CARD, not the log, is the primary scroll, and overscroll-behavior is
     left at `auto` ON PURPOSE: a wheel that reaches the log's end must chain up to the card
     or the fight looks frozen under the cursor. Never set `contain` here. */
  /* WHOLE LINES ONLY. The log auto-pins to the newest line, so its BOTTOM edge always sits
     flush with the last line — which means the top edge lands wherever `height` happens to
     fall. At a hard 118px against an 18px line that left a 10px sliver of the line above,
     and a half-height row of clipped ascenders reads as a rendering bug rather than as
     "there is more above". Sizing the box as an exact multiple of the line height puts the
     top edge on a line boundary instead, so a scrolled log always starts on a clean line.
     Both numbers ride --tscale together, so the text-size chooser can't break the ratio. */
  .cb-log { --cbl-lh:calc(18px*var(--tscale)); --cbl-lines:6;
            background:#06080a; border:1px solid var(--line); border-radius:6px; padding:8px;
            font-size:calc(12px*var(--tscale)); line-height:var(--cbl-lh);
            height:calc(var(--cbl-lines)*var(--cbl-lh) + 16px);
            overflow:auto; overscroll-behavior:auto; margin:10px 0; white-space:pre-wrap; color:#bfe0c0; }
  /* One class per line, all from the existing palette. The height maths above is an exact
     multiple of --cbl-lh, so these carry NO box of their own or the flush-bottom alignment
     drifts by a pixel per line. */
  .cb-log > div { margin:0; padding:0; }
  .cl-out  { color:var(--neon); }                       /* your side dealt it   */
  .cl-in   { color:var(--hp); }                         /* your side took it    */
  .cl-dot  { color:#ff8a3c; }                           /* burn / poison / bleed */
  .cl-heal { color:var(--teal); }
  .cl-mech { color:var(--violet); }                     /* cornered, berserk, break */
  .cl-kill { color:#7CFC54; font-weight:600; }
  .cl-flav { color:var(--dim); font-style:italic; }     /* crowd and showrunner  */
  .cl-crit { color:var(--amber); }
  .cl-num  { color:inherit; font-weight:700; }          /* weight, not a second colour */
  /* GEAR-17: a gear PROC. The server prefixes these with ✦ — they are the only log lines
     the player's ITEMS wrote, so they get their own colour instead of being classified by
     verb like everything else. Matched before the _CBL table, so a proc that says "hits"
     still reads as a proc. */
  .cl-proc { color:var(--title-gold); font-weight:600; }
  /* The log grows ONLY on a window tall enough to have spare room. It is the one block that
     can be starved without costing the player anything (it has its own scroller and pins to
     the newest line); the vitals and the action bar can not, so the log must never be the
     reason they leave the screen. Tied to viewport height, not to the card, because the
     card's height is itself viewport-derived. Now expressed in LINES, not pixels, so the
     flush-bottom alignment above survives every breakpoint. */
  @media (min-height: 900px)  { .cb-log { --cbl-lines:9; } }
  @media (min-height: 1100px) { .cb-log { --cbl-lines:11; } }
  @keyframes cbLogIn { from { opacity:0; transform:translateY(4px); } to { opacity:1; transform:translateY(0); } }
  .cb-log-new { animation:cbLogIn .2s ease both; }
  @media (prefers-reduced-motion:reduce) { .cb-log-new { animation:none; } }
  /* The `tell`: the one short non-numeric qualifier a choice may carry (#19). Right-aligned
     and dim so it reads as a footnote to the verb, never as part of it. A payout must never
     appear here — costs, requirements and a one-word moral tell only. */
  .ev-tell { float:right; color:var(--dim); font-weight:400; letter-spacing:0;
             font-size:calc(10px*var(--tscale)); text-transform:none; opacity:.85; }
  .ev-choice[disabled] .ev-tell { color:var(--amber); opacity:1; }
  /* The resolution body. Same measure as the prompt's prose so the two screens sit still
     under the shared header rather than jumping. */
  .ev-out { margin:11px 0 0; padding:11px 12px; background:#06080a; border:1px solid var(--line);
            border-radius:6px; font-size:calc(12px*var(--tscale)); line-height:1.62; }
  .ev-out > div + div { margin-top:5px; }
  /* EVENT PROSE — not a log. The choice popup borrowed .cb-log, which is tuned for a
     scrolling combat feed: 12px, log-green, and a FIXED 118px box with its own scroller.
     That is wrong for the one block of text you actually have to read before making a
     decision — on a big screen it was small type in a short box, with longer descriptions
     hidden behind an inner scrollbar nobody expects on a two-paragraph prompt.
     Auto height (no inner scroller) and a real reading size and line-height.
     NO max-width: a 62ch measure cap is right for a page of prose, but here it made the
     black box visibly narrower than the popup and the choice buttons stacked under it,
     which reads as a misaligned panel rather than a considered measure. The popup itself
     is already the measure — .cbx caps at 440px on a phone and 640px on desktop. */
  .ev-desc { background:#06080a; border:1px solid var(--line); border-radius:6px;
             padding:12px 14px; margin:10px 0; color:#d6ecd7; box-sizing:border-box; width:100%;
             font-size:calc(15px*var(--tscale)); line-height:1.6; }
  @media (min-width: 900px) { .ev-desc { font-size:calc(16px*var(--tscale)); } }
  /* The event's scene plate (icons/events/<group>_<band>.webp, 3:2) above the name, replayed into
     the resolution with the rest of the header. Height-capped so the choices stay above the fold
     on a phone; a 404 hides it and puts the node thumbnail back (onerror). Painterly, not pixel. */
  .ev-scene { display:block; width:100%; aspect-ratio:3/2; max-height:40vh; object-fit:cover; border-radius:6px;
              border:1px solid var(--line); margin:0 0 10px; }
  /* The stakes are the other thing you read here, so they get to grow too — still a step
     below the description so the prompt reads first. */
  .ev-choice { font-size:calc(13px*var(--tscale)) !important; line-height:1.45; padding:9px 11px; }
  @media (min-width: 900px) { .ev-choice { font-size:calc(14px*var(--tscale)) !important; } }
  .cb-party .pf { margin:5px 0; font-size:calc(11px*var(--tscale)); }
  .cb-mini { height:7px; background:#08120a; border:1px solid var(--line); border-radius:4px; overflow:hidden; display:inline-block; width:84px; vertical-align:middle; }
  .cb-mini > i { display:block; height:100%; transition:width .2s; }
  .cb-mini.hp > i { background:var(--neon); } .cb-mini.mp > i { background:var(--cyan); }
  /* combat polish: enemy emblem + party fighter cards */
  .cb-enemy { display:flex; align-items:center; gap:10px; }
  .cb-emblem { width:48px; height:48px; flex:0 0 48px; border-radius:9px; border:2px solid var(--red); display:flex; align-items:center; justify-content:center; color:var(--red); background:radial-gradient(circle at 50% 35%,#1c0c0c,#0a0506); box-shadow:0 0 11px rgba(255,49,49,.4); }
  .cb-emblem img { width:80%; height:80%; object-fit:contain; }
  .cb-emblem.boss { width:60px; height:60px; flex:0 0 60px; overflow:hidden; }
  .cb-emblem img.foe { width:100%; height:100%; object-fit:cover; border-radius:7px; }
  .cb-val { color:var(--amber); font-weight:800; }
  .cb-val.h { color:var(--neon); }
  .cb-pips { color:var(--cyan); font-size:calc(10px*var(--tscale)); letter-spacing:1px; }
  .cb-mini.hp.hurt i { background:var(--amber) !important; }
  .cb-mini.hp.crit i { background:var(--red) !important; }
  .cb-bar button.showbtn { border-color:var(--pink); color:var(--pink); }
  .cb-bar button.showbtn.lit { position:relative; box-shadow:0 0 7px var(--pink); }
  .cb-bar button.showbtn.lit::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; box-shadow:0 0 17px var(--pink); animation:sspulse 1.1s ease-in-out infinite; }
  /* sspulse is now the shared opacity ramp for every pre-rendered ::after glow layer */
  @keyframes sspulse { 0%,100%{opacity:.25} 50%{opacity:1} }
  body.no-motion *, body.no-motion *::before, body.no-motion *::after { animation:none !important; transition:none !important; }
  /* GRAPHICS QUALITY (#54/#55). Low and Medium drop the ambient animations that keep the
     compositor busy while nothing is happening: the LIVE pulse, boss breathing, the floor-
     modifier washes and the tear band. Low also stills the torch flames. reduce_motion above
     stays the sledgehammer; this is the scalpel, and it never touches feedback animation
     (HP bars, toasts, the walk tween). */
  body.gfx-low .live::before, body.gfx-med .live::before,
  body.gfx-low .mob-breathe-boss, body.gfx-med .mob-breathe-boss,
  body.gfx-low[data-mod] #combat::before, body.gfx-med[data-mod] #combat::before,
  body.gfx-low[data-mod] .cb-scene::before, body.gfx-med[data-mod] .cb-scene::before,
  body.gfx-low[data-mod] .cb-hero::after, body.gfx-med[data-mod] .cb-hero::after,
  body.gfx-low[data-tear] #combat::after, body.gfx-med[data-tear] #combat::after,
  body.gfx-low[data-tear] .cb-scene::after, body.gfx-med[data-tear] .cb-scene::after,
  body.gfx-low .tflk { animation:none !important; }
  /* COLOUR-BLIND PALETTE (#57). Red-green mode swaps the tokens that carry good / bad / warn
     for Okabe-Ito anchors (sky = good, purple = bad, yellow = warn) that survive protanopia and
     deuteranopia. The JS palettes (rarity, node tints, threat band, wanderers, schools) swap
     in core.js applyCvd; this is the CSS half. HP stays red: the bar's length carries it. */
  body.cvd-rg { --neon:#56B4E9; --red:#CC79A7; --amber:#F0E442; }
  body.cvd-rg .fl-good { color:var(--neon); } body.cvd-rg .fl-bad { color:var(--red); }
  /* Guild upgrades. Were flex rows with name+tier+effect crammed into one squeezed span
     (effect on a <br>) and the button floated right, so tiers and costs lined up with
     nothing — you couldn't scan "what do I already have" or "what can I afford" down a
     column, which is the only reason to look at this list. */
  .gutbl { width:100%; border-collapse:collapse; table-layout:fixed; font-size:calc(11px*var(--tscale)); }
  .gutbl td { padding:4px 6px 4px 0; vertical-align:middle; border-bottom:1px solid var(--line); }
  .gutbl tr:last-child td { border-bottom:0; }
  /* Width is explicit because table-layout:fixed splits the leftover EVENLY between the
     two unsized columns, which is not what either wants. 96px is measured: it's where
     "Training Hall" (the longest) stops ellipsing — 86px clipped it.
     The effect column WRAPS to two lines at 360 and that's accepted, not overlooked:
     34 (tier) + 92 (action) + 18 (padding) leaves ~212 for name+effect, and
     "+3% stamina regen per tier" alone wants ~140. No split fits both on one line, so
     the width buys an un-clipped NAME instead of a doomed one-line effect. The old flex
     version was two lines per row anyway (effect sat under the name on a <br>), so the
     table costs no height — it just aligns. */
  .gutbl td.gun { width:96px; color:var(--neon); font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .gutbl td.gut { width:34px; text-align:center; color:var(--dim); font-size:calc(10px*var(--tscale)); white-space:nowrap; }
  .gutbl td.gud { color:var(--dim); font-size:calc(10px*var(--tscale)); line-height:1.3; }
  /* Sized to the WORST case, "Buy ⛁150,000" — upgrade costs are computed (10k→150k), so
     the top tier carries the widest label and a narrower column clips a live button. */
  .gutbl td.gua { width:112px; text-align:right; padding-right:0; white-space:nowrap; }
  .gutbl td.gua button { padding:2px 8px; }
  /* The Hoard (guild vault) -- item/request rows reuse the JOIN REQUESTS flex idiom
     (name+meta left, actions right, wraps on a phone) rather than a fixed-width table:
     gear names run much longer than upgrade names and table-layout:fixed would either
     clip a name or starve the action buttons. */
  .vlt-row{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}
  .vlt-row:last-child{border-bottom:0}
  .vlt-info{flex:1 1 160px;min-width:0}
  .vlt-acts{display:flex;gap:5px;align-items:center;flex-wrap:wrap;flex:0 0 auto}
  .vlt-acts button{margin:0;padding:2px 8px}
  .vlt-acts input[type=number]{width:60px;padding:2px 4px;font-size:calc(11px*var(--tscale));background:var(--inset);border:1px solid var(--line);color:var(--txt);border-radius:4px}
  .vlt-log-row{font-size:calc(10.5px*var(--tscale));padding:3px 0;border-bottom:1px solid var(--line);line-height:1.5;color:var(--sub)}
  .vlt-log-row:last-child{border-bottom:0}
  @keyframes vltFlash{0%,100%{background:transparent}30%{background:rgba(57,255,20,.22)}}
  .vlt-flash{animation:vltFlash 1.6s ease}
  /* Achievement UNLOCKED celebration (marquee unlocks get a held, glowing splash) */
  .achcele { position:fixed; inset:0; z-index:200; display:flex; align-items:center; justify-content:center; background:rgba(3,5,8,.55); animation:achFade .2s ease; }
  .achcard { text-align:center; padding:14px 18px; background:var(--panel); border:2px solid; border-radius:14px; max-width:340px; margin:12px; animation:achPop .45s cubic-bezier(.2,1.3,.4,1); }
  .achcele-ic { font-size:46px; line-height:1; animation:achGlow 1.6s ease-in-out infinite; }
  .achcele-ttl { font-size:calc(20px*var(--tscale)); font-weight:800; margin:6px 0 2px; letter-spacing:.5px; }
  @keyframes achFade { from{opacity:0} to{opacity:1} }
  @keyframes achPop { 0%{transform:scale(.6);opacity:0} 100%{transform:scale(1);opacity:1} }
  @keyframes achGlow { 0%,100%{filter:drop-shadow(0 0 8px currentColor)} 50%{filter:drop-shadow(0 0 18px currentColor)} }
  /* Even 4-col grid so every school chip is the same width regardless of name length
     or tier; tier renders as a constant 5-pip meter (filled + faint) so the content
     doesn't change the width either. */
  .cb-schools { display:grid; grid-template-columns:repeat(4,1fr); gap:5px; margin:7px 0 2px; }
  .cb-schools .sch { position:relative; overflow:hidden; text-align:center; white-space:nowrap; font-size:calc(10px*var(--tscale)); font-weight:800; letter-spacing:1px;
    padding:2px 4px 4px; border:1px solid; border-radius:5px; background:rgba(0,0,0,.45); }
  .cb-schools .sch .pips { letter-spacing:0; margin-left:1px; }
  .cb-schools .sch .pips .off { opacity:.28; }
  .cb-schools .sch i { position:absolute; left:0; bottom:0; height:2px; display:block; opacity:.9; }
  /* Desktop jump-nav — a row of section links inside the sticky status bar so you can
     jump straight to any section of the one-screen dashboard. Hidden on mobile (base
     display:none); the desktop media query flips it on only when the game is on screen. */
  #deskjump { display:none; flex-wrap:wrap; justify-content:center; gap:5px; margin-top:6px; padding-top:6px; border-top:1px solid var(--line); }
  #deskjump a { cursor:pointer; font-size:calc(10px*var(--tscale)); font-weight:800; letter-spacing:.6px; text-transform:uppercase; color:var(--jc); border:1px solid transparent; border-radius:6px; padding:2px 9px; opacity:.72; transition:opacity .12s,border-color .12s,box-shadow .12s; }
  #deskjump a:hover { opacity:1; border-color:var(--jc); box-shadow:0 0 10px -3px var(--jc); }
  .cb-bar button.gearbtn { border-color:var(--cyan); }
  .cb-bar button.gearbtn:disabled { opacity:.55; }
  .cb-trait { font-size:calc(10px*var(--tscale)); color:var(--amber); margin-top:2px; cursor:pointer; opacity:.9; }
  .cb-tip { font-size:calc(10px*var(--tscale)); color:var(--cyan); margin-top:2px; font-style:italic; line-height:1.45; }
  .cb-enemy .en { flex:1; min-width:0; }
  .cb-party { display:grid; grid-template-columns:1fr 1fr 1fr; gap:5px; margin-top:8px; }  /* smaller: 3-up, tighter */
  /* YOU: the one fighter whose HP ends the run, given its own card above the crew.
     Was a 3-up chip identical to 16 recruits, marked with a small star. */
  .cb-crewlbl { font-size:calc(10px*var(--tscale)); letter-spacing:1.5px; color:var(--dim); margin-top:10px; }
  /* YOUR VITALS ARE THE HUD, NOT A ROSTER ROW. They used to be a thin 15px/11px pair of
     bars sitting BELOW the school chips (and, on desktop, below the action bar) — read as
     less important than the enemy rack, and on a short window they were off-screen entirely
     while you were taking damage.
     PINNED: the block is position:sticky; bottom:0 inside the card (the card IS the
     scrollport, see .cbx.fight), so your HP/MP never leaves the screen mid-fight no matter
     where the card is scrolled. It is rendered immediately ABOVE the action bar, so the only
     thing it can ever overlap is the bar you are actively scrolling towards — scroll to the
     end and it un-sticks into normal flow with the actions fully clear below it. It is
     deliberately NOT sticky once the fight is over (.cb-vitals only gets .pin while
     status==='active'), so it can never sit on top of VICTORY / CONTINUE. */
  .cb-vitals { margin-top:9px; }
  .cb-vitals.pin { position:sticky; bottom:0; z-index:4;
     /* opaque backing + a fade above it: content scrolling underneath must not bleed through */
     background:linear-gradient(rgba(6,10,8,0) 0, #060a08 9px, #060a08 100%);
     padding:9px 0 8px; margin-top:0; }
  .cb-you { display:flex; gap:11px; align-items:center; padding:9px 11px; border-radius:10px;
     background:#080f0b; border:2px solid var(--neon); box-shadow:0 0 18px rgba(57,255,20,.22); }
  .cb-you.danger { position:relative; border-color:var(--red); box-shadow:0 0 20px rgba(255,77,77,.34); }
  .cb-you.danger::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; box-shadow:0 0 26px rgba(255,77,77,.6); animation:youPulse 1.4s ease-in-out infinite; }
  .cb-you.down { border-color:var(--red); opacity:.6; filter:grayscale(.6); }
  .cb-you-pf { width:58px; height:58px; flex:0 0 58px; border-radius:8px; object-fit:cover;
     border:1px solid var(--neon); background:#050a07; }
  .cb-you.danger .cb-you-pf { border-color:var(--red); }
  .cb-you-body { flex:1; min-width:0; display:flex; flex-direction:column; gap:5px; }
  .cb-you-top { display:flex; align-items:baseline; gap:6px; flex-wrap:wrap; }
  .cb-you-nm { color:var(--neon); font-size:calc(14px*var(--tscale)); letter-spacing:.4px; }
  .cb-you-lv { color:var(--dim); font-size:calc(10px*var(--tscale)); }
  .cb-you-down { color:var(--red); font-weight:800; font-size:calc(10px*var(--tscale)); letter-spacing:1px; }
  /* the bar IS the readout — the number rides on it, so there's one thing to look at.
     Thick enough (26px HP / 19px MP) that the value is legible at a glance without hunting
     for a caption, with a fixed-width HP/MP tag on the left so the two bars scan as a pair. */
  .cb-you-bar { position:relative; height:26px; border-radius:6px; overflow:hidden;
     background:#0a0f0c; border:1px solid var(--line); }
  .cb-you-bar i { display:block; height:100%; background:var(--neon); transition:width .28s ease; }
  .cb-you-bar.hp { border-color:rgba(57,255,20,.55); }
  .cb-you-bar.hp i { background:linear-gradient(90deg,var(--neon),#8fe86a); }
  .cb-you-bar.hp.hurt { border-color:rgba(255,176,0,.65); }
  .cb-you-bar.hp.hurt i { background:linear-gradient(90deg,#ffb000,var(--title-gold)); }
  .cb-you-bar.hp.crit { border-color:var(--hp); }
  .cb-you-bar.hp.crit i { background:linear-gradient(90deg,#ff3131,#ff7a7a); }
  .cb-you-bar.mp { height:19px; border-color:rgba(34,211,238,.5); }
  .cb-you-bar.mp i { background:linear-gradient(90deg,#22d3ee,#8fe9ff); }
  .cb-you-bar span { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
     font-size:calc(14px*var(--tscale)); font-weight:900; color:#eafff0; text-shadow:0 1px 3px #000,0 0 4px #000;
     letter-spacing:.6px; font-family:var(--mono); }
  .cb-you-bar.mp span { font-size:calc(11px*var(--tscale)); font-weight:800; color:#d6f6ff; letter-spacing:.4px; }
  /* BLOOD: a Vampire's mana pool, recoloured crimson. Same geometry as .mp so the vitals
     block doesn't shift height between races — only the palette and the label change. */
  /* Blood reads as a DEEP arterial crimson (--bld var(--bld)), deliberately DARKER than the
     bright HP red (var(--hp)) so the two bars are never mistaken for a second health bar.
     The gradient stays dark→mid crimson and never brightens to pink. */
  .cb-you-bar.bld { height:19px; border-color:rgba(138,20,36,.6); }
  .cb-you-bar.bld i { background:linear-gradient(90deg,#4a0a14,var(--bld) 55%,#b02234); }
  .cb-you-bar.bld span { font-size:calc(11px*var(--tscale)); font-weight:800; color:#f0c6cc; letter-spacing:.4px; }
  /* the little HP / MP tag sits OUTSIDE the fill so it reads even at 0% */
  .cb-you-bar b.vt { position:absolute; left:7px; top:0; bottom:0; display:flex; align-items:center;
     font-size:calc(10px*var(--tscale)); font-weight:900; letter-spacing:1.4px; color:#eafff0;
     text-shadow:0 1px 3px #000,0 0 4px #000; opacity:.92; }
  .cb-you-bar.mp b.vt { color:var(--cyan); }
  /* LOW HP MUST BE UNMISSABLE — reuses the file's existing urgency idiom (collapsePulse,
     opacity-only, compositor-friendly) on the number itself, on top of the card's red glow. */
  .cb-you-bar.hp.crit span, .cb-you-bar.hp.crit b.vt { color:#ffe9ea; animation:collapsePulse 1s ease-in-out infinite; }
  body.no-motion .cb-you-bar.hp.crit span, body.no-motion .cb-you-bar.hp.crit b.vt { animation:none; }
  /* PERF: opacity-only — the peak glow is pre-rendered on .cb-you.danger::after */
  @keyframes youPulse { 0%,100%{ opacity:.2; } 50%{ opacity:1; } }
  /* taking a hit shakes YOUR portrait — bars stay put */
  .cb-you.hit .cb-you-pf { animation:cbHit .3s ease; }
  .pf2 { display:flex; gap:5px; align-items:center; border:1px solid var(--line); border-radius:7px; padding:3px 5px; background:#0b110e; }
  .pf2.down { opacity:.5; }
  .pf2 .pav { width:28px; height:28px; flex:0 0 28px; }
  .pf2 .pfi { min-width:0; flex:1; font-size:calc(10px*var(--tscale)); line-height:1.2; }
  .pf2 .pfn { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:700; }
  .pf2 .cb-mini { display:block; width:100%; height:5px; margin:1px 0 0; }
  /* combat rebuild: big foreground portrait + WoW-style icon action bar */
  .cb-hero { position:relative; height:140px; border:2px solid var(--red); border-radius:12px; overflow:hidden; background:#0a0f0c; display:flex; align-items:flex-end; }
  /* Marquee foes (elite / mini-boss / boss) get a WIDE stage matched to the art's aspect.
     That aspect is 11:6, not 16:9 — measured across every shipped asset: 188/188 foe jpgs
     are 704x384 and 20/20 boss webps are 1408x768. At 16/9 the art letterboxed and the
     zone plate behind it showed through as a crimson band (15% of the hero at a 448px
     viewport), which reads as a second room abutting the first — the elite jpgs are full
     painted scenes that already carry their own environment. */
  .cb-hero.big { height:auto; aspect-ratio:11/6; max-height:52vh; }
  /* object-position:top so a tall foe portrait crops from the BOTTOM (hidden behind the
     name gradient anyway) instead of the default centre crop, which lopped the head off. */
  .cb-hero-img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; object-position:center top; }
  /* .full = show the WHOLE creature, uncropped, standing on the floor line (contain +
     bottom). Used for every marquee foe (scan-art boss AND painted elite/mini portraits)
     so the art is fully visible over the zone-themed stage behind it. */
  /* cover, not contain: the 2px border means the padding box can never be exactly 11/6,
     and contain leaves a measured 1.68px crimson hairline down each side. cover shaves
     1.83px (0.8%, ~6 of 768 source rows) off the top instead. The drop-shadow went with
     the padding: edge-to-edge inside overflow:hidden it painted entirely outside the
     content box and was clipped on all four sides — a filter pass over a 1408x768 image,
     on an element renderCombat rebuilds every round, that could not draw a pixel. */
  .cb-hero-img.full { object-fit:cover; object-position:center bottom; padding:0; }
  /* Traced scan-art is transparent + full-body — contain (not cover) so nothing gets
     cropped, and a dark radial glow fills the letterboxed space behind it. */
  .cb-hero-img.scanart { object-fit:contain; object-position:center; background:radial-gradient(ellipse at center,#0d1512 0%,#050806 75%); }
  /* Trash-mob family icon shown as a lit CREATURE (contain + glow) over a biome-tinted
     panel, instead of the old empty stone doorway. */
  .cb-hero-img.cb-hero-mob { object-fit:contain; object-position:center; padding:22px 22px 30px; image-rendering:auto;
    filter:drop-shadow(0 0 13px rgba(255,183,51,.5)) drop-shadow(0 0 3px rgba(0,0,0,.8));
    background:radial-gradient(ellipse at center 62%, rgba(255,183,51,.10), transparent 62%), #0a0f0c; }
  .cb-hero-sh { position:absolute; left:0; right:0; bottom:0; height:62%; background:linear-gradient(transparent,rgba(5,8,7,.88)); }
  .cb-hero-info { position:relative; padding:12px; z-index:1; }
  .cb-kind { font-size:calc(11px*var(--tscale)); font-weight:900; letter-spacing:2px; margin-bottom:2px; text-shadow:0 1px 4px #000; }
  .cb-ann-bg { position:absolute; inset:0; background:rgba(0,0,0,.55); z-index:11; pointer-events:none; animation:cbAnnBg 2.3s ease both; }
  .cb-ann-bg.static { animation:none; pointer-events:auto; cursor:pointer; }
  @keyframes cbAnnBg { 0%{opacity:0} 12%{opacity:1} 72%{opacity:1} 100%{opacity:0} }
  .cb-announce { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; z-index:12; pointer-events:none; animation:cbAnn 2.3s ease both; white-space:nowrap; }
  .cb-announce-kind { font-size:calc(13px*var(--tscale)); letter-spacing:4px; font-weight:900; text-shadow:0 2px 10px #000,0 0 20px rgba(0,0,0,.6); }
  .cb-announce-epithet { font-size:calc(12px*var(--tscale)); font-variant-caps:small-caps; letter-spacing:2px; color:var(--dim); margin-bottom:1px; }
  .cb-announce-name { font-size:calc(26px*var(--tscale)); font-weight:700; font-style:italic; color:var(--amber); letter-spacing:.5px; text-shadow:0 2px 14px rgba(0,0,0,.85),0 0 30px rgba(255,183,51,.3); line-height:1.15;  }
  @keyframes cbAnn { 0%{opacity:0;transform:translate(-50%,-50%) scale(.85)} 12%{opacity:1;transform:translate(-50%,-50%) scale(1)} 72%{opacity:1} 100%{opacity:0;transform:translate(-50%,-50%) scale(1.03)} }
  /* #07: floor line for the title card, and the reduce-motion variant -- no animation,
     no auto-dismiss timer, tap it instead. `.static` overrides both the keyframe and the
     base pointer-events:none above. */
  .cb-announce-floor { font-size:calc(11px*var(--tscale)); letter-spacing:2px; color:var(--dim); margin-top:2px; }
  .cb-announce.static { animation:none; pointer-events:auto; cursor:pointer; }
  .cb-announce-tap { font-size:var(--fs-tiny); color:var(--dim); margin-top:6px; opacity:.8; }
  .cb-hero-nm { color:#fff; font-weight:900; font-size:calc(22px*var(--tscale)); letter-spacing:.3px; line-height:1.1; text-shadow:0 2px 7px #000; }
  .cb-wave { color:var(--amber); font-size:calc(11px*var(--tscale)); font-weight:800; margin-top:3px; }
  .cb-hero .cb-intent { position:absolute; top:8px; left:8px; z-index:2; margin:0; background:rgba(20,6,6,.85); }
  .cb-hpbar.big { height:18px; margin-top:10px; }
  /* --- The mob rack: a GRID of square foe tiles, only when there's more than one (a
     solo fight has nothing to pick, so it keeps its banner + single bar — mirrors the
     server's shape rule in services/combat_session._enemy_swing).
     Square tiles rather than full-width rows: 3 foes cost one compact row instead of
     three stacked lines plus a banner, which is what was eating the combat window. --- */
  .cb-rack { display:grid; grid-template-columns:repeat(auto-fit,minmax(84px,1fr)); gap:6px; margin:6px 0 4px; }
  /* Flex column so the HP bar/number can be pinned to the BOTTOM (see .cb-mob-bar):
     tiles carry a different number of WEAK/RESIST badges, and letting the bar sit in
     normal flow put it at a different height in every tile — the rack read as ragged. */
  .cb-mob { border:1px solid var(--line); border-radius:8px; background:rgba(8,14,12,.72);
    cursor:pointer; overflow:hidden; transition:border-color .15s, background .15s;
    display:flex; flex-direction:column; }
  @media (hover:hover) { .cb-mob:hover { border-color:var(--amber); } }
  .cb-mob.on { border-color:var(--amber); background:rgba(255,178,44,.12); box-shadow:0 0 0 1px rgba(255,178,44,.45) inset; }
  .cb-mob.dead { opacity:.4; cursor:default; filter:grayscale(1); }
  @media (hover:hover) { .cb-mob.dead:hover { border-color:var(--line); } }
  /* contain (not cover) so a sprite is never clipped at the tile edge, and pixelated so
     the 320px pixel-art sprite stays CRISP when upscaled on a 3x phone instead of being
     smoothed into mush. */
  .cb-mob-por { display:block; width:100%; aspect-ratio:1/1; object-fit:contain; background:#0a0f0d;
    image-rendering:pixelated; image-rendering:crisp-edges; }
  .cb-mob-x { position:absolute; inset:0; display:grid; place-items:center; font-size:26px; color:var(--red); opacity:.85; }
  .cb-mob-at { position:absolute; left:3px; top:3px; font:800 calc(10px*var(--tscale))/1.4 ui-monospace,monospace;
    letter-spacing:.06em; padding:1px 4px; border:1px solid; border-radius:3px; background:rgba(6,10,8,.82); }
  .cb-mob-taint { position:absolute; left:3px; bottom:3px; font:800 calc(8px*var(--tscale))/1.3 ui-monospace,monospace;
    letter-spacing:.04em; padding:1px 3px; border-radius:2px; background:rgba(6,10,8,.82); color:#e07848; }
  .cb-mob-pw { position:absolute; right:3px; top:3px; font-size:calc(10px*var(--tscale)); font-weight:800;
    color:#ffb02c; background:rgba(4,8,6,.82); border:1px solid rgba(255,178,44,.5);
    border-radius:4px; padding:0 3px; line-height:1.4; }
  .cb-mob-im { position:relative; }
  /* ---- STAGE PROTOTYPE (body.cbstage, localStorage cb_stage) -------------------------
     The zone PLATE is already composited as #combat's backdrop (see _cbBgNew) — the rack
     was simply sitting on top of it in opaque cards, so a painted room read as wallpaper
     behind a dashboard. This lets the sprites stand ON the plate instead: tiles go
     transparent, a floor line grounds them, and the acting fighter lunges using the
     keyframes the ARENA has had all along (vaLungeR/vaLungeL, ~981-984).
     Nothing here changes layout or the FX positioning context, so every existing effect
     still resolves .cb-mob[data-mid] and lands exactly where it did. */
  body.cbstage .cb-mob { background:transparent; border-color:transparent; overflow:visible; }
  body.cbstage .cb-mob-por { background:transparent; filter:drop-shadow(0 3px 5px rgba(0,0,0,.75)); }
  body.cbstage .cb-mob.on { border-color:var(--amber); background:rgba(255,178,44,.10); }  /* targeting must stay legible */
  @media (hover:hover) { body.cbstage .cb-mob:hover { border-color:var(--amber); } }
  /* the footing: a soft shadow under the rack so the sprites read as standing, not floating */
  body.cbstage .cb-rack { position:relative; }
  body.cbstage .cb-rack::after { content:""; position:absolute; left:4%; right:4%; bottom:2px; height:14px;
    pointer-events:none; border-radius:50%; background:radial-gradient(ellipse at center, rgba(0,0,0,.55), transparent 70%); }
  body.cbstage .cb-you.lunge   { animation:vaLungeR .3s cubic-bezier(.3,1.4,.6,1); }
  body.cbstage .cb-mob.lunge   { animation:vaLungeL .3s cubic-bezier(.3,1.4,.6,1); }
  /* ---- DIORAMA (body.cbdio, localStorage cb_view) --------------------------------------
     Ticket 18. This is NOT an extension of cb_stage and must never become one. cb_stage
     makes a better-looking RACK -- three cards in a row at the top, the player still a
     card underneath. The gap it leaves is not styling: THE PLAYER IS NOT IN THE SCENE.
     So this is a second layout, with its own flag, and the two are never combined --
     conflating them is how cb_stage stops meaning anything.

     One frame. Both sides standing on the same floor, yours facing right. A thin bar
     under each figure and nothing else -- no card, no border, no portrait frame.

     The enemy figures keep `.cb-mob[data-mid]` because that is the selector the whole FX
     layer positions against (cast projectiles, enemy lunges, floating damage) and the
     targeting handler reads. A diorama that renamed them would land every effect at the
     scene's centre. */
  /* The frame is sized BY the figures, not by a fixed aspect ratio. Measured at 16/9 and
     again at 21/9: both left 45% of the frame as empty backdrop above the figures' heads,
     because an aspect ratio has no idea how tall a figure is. Barrowdeep fills that space
     with a receding arch; we have a flat zone plate, so empty space just reads as empty.
     Padding gives a fixed headroom band instead, which holds at every width. */
  /* THE SCENE IS A STAGE, NOT A STRIP (2026-09-13, Barrowdeep reference). It was content-sized
     at min-height:180px, so the zone plate (a 1024-square painting) was cropped to a thin band
     just tall enough to hold the figures and the fight had no room above it. A fixed 3:2 shows
     ~67% of the plate's height instead of ~53% and gives the figures air. Deliberately NOT a
     figure-size change: .cb-fig / .cb-fig-sprite widths are untouched, so the same crew now
     stands in a taller room. Capped so it cannot eat the log on a short phone. */
  .cb-scene { position:relative; margin:4px 0 6px; border:2px solid var(--line); border-radius:10px;
    overflow:hidden; aspect-ratio:3/2; min-height:200px; max-height:min(46svh,380px); padding:30px 3% 24px;
    display:flex; align-items:flex-end; justify-content:space-between; gap:3%;
    background:#070c0a center/cover no-repeat; }
  /* The horizontal initiative queue owns the scene header when the server supplies it. */
  body.cbinit .cb-scene { padding-top:32px; }
  body.cbinit .cb-queue { padding-bottom:3px; border-bottom:1px solid rgba(255,255,255,.07); }
  body.cbinit .cb-scene:has(.cb-queue) .cb-scene-hud { display:none; }
  /* Depth. Barrowdeep's frame has an arch receding back; we have a flat zone plate, so the
     recession is faked with two cheap layers -- a dark rim that pulls the corners back, and
     a floor band the figures stand ON. Both are pointer-events:none so they never eat a tap
     meant for a mob. */
  .cb-scene-vig { position:absolute; inset:0; pointer-events:none;
    background:radial-gradient(ellipse 74% 62% at 50% 38%, transparent 0%, rgba(3,6,5,.34) 62%, rgba(3,6,5,.82) 100%); }
  .cb-scene-fl { position:absolute; left:0; right:0; bottom:0; height:34%; pointer-events:none;
    background:linear-gradient(transparent, rgba(4,8,6,.55) 38%, rgba(4,8,6,.86)); }
  .cb-scene-hud { position:absolute; left:0; right:0; top:0; z-index:3; display:flex;
    justify-content:space-between; align-items:flex-start; gap:8px; padding:6px 8px;
    font:800 calc(10px*var(--tscale))/1.35 ui-monospace,monospace; letter-spacing:.09em;
    pointer-events:none; text-shadow:0 1px 4px #000; }
  .cb-scene-hud .r { color:var(--cyan); }
  .cb-scene-cap { position:absolute; left:0; right:0; bottom:0; z-index:4; padding:4px 8px 5px;
    text-align:center; font:700 calc(10px*var(--tscale))/1.3 ui-monospace,monospace;
    color:var(--sub); letter-spacing:.08em; pointer-events:none; text-shadow:0 1px 4px #000; }
  /* The two ranks. align-items:flex-end puts every figure's FEET on one line regardless of
     how tall its art is, which is what makes them read as standing in the same room. */
  .cb-side { position:relative; z-index:2; display:flex; align-items:flex-end; gap:3%;
    max-width:49%; min-width:0; }
  /* row-reverse so the FRONT of the enemy rank -- the one nearest you -- is the one closest
     to the middle, mirroring your own file. */
  .cb-side-foe { flex-direction:row-reverse; gap:0; }
  .cb-side-foe .cb-fig + .cb-fig { margin-right:-18px; }
  .cb-side-foe .cb-fig:nth-child(1) { z-index:4; }
  .cb-side-foe .cb-fig:nth-child(2) { z-index:3; }
  .cb-side-foe .cb-fig:nth-child(3) { z-index:2; }
  .cb-side-foe .cb-fig:nth-child(4) { z-index:1; }
  .cb-fig { position:relative; flex:0 1 auto; min-width:0; display:flex; flex-direction:column;
    align-items:center; gap:3px; width:clamp(56px,20vw,200px); }
  .cb-fig-im { position:relative; width:100%; aspect-ratio:1/1; }
  /* #08: floating status tags ON the figure (vs. the panel chips, which stay put for the
     detail view) -- a compact glyph+rounds pair, same keys/thresholds as _badges(). */
  .cb-status-tags { position:absolute; top:2px; left:2px; right:2px; z-index:3;
    display:flex; gap:2px; flex-wrap:wrap; pointer-events:none; }
  .cst { font:800 calc(9px*var(--tscale))/1 ui-monospace,monospace; color:var(--txt);
    background:rgba(3,6,5,.78); border:1px solid rgba(255,255,255,.14); border-radius:3px;
    padding:1px 3px; white-space:nowrap; }
  /* `cover`, NOT `contain`, and the two art libraries are why. Trash mobs are 396 sprites at
     a uniform 256x256, where cover and contain are identical -- a square source in a square
     slot crops nothing either way. But all 204 NAMED/elite foe paintings are 704x384 (1.83:1),
     authored for the old wide hero-banner strip, and `contain` letterboxed each one into a
     thin band across the middle of a square slot: a landscape scene floating where a figure
     should stand. Reported from a phone against the Kiln Prophet. `cover` centre-crops the
     banner instead, which keeps the subject (these paintings frame it centred and full-height)
     and discards its painted background -- no loss, since the diorama lays its own zone plate
     behind the figures and two backgrounds were competing anyway. */
  .cb-fig-img { width:100%; height:100%; object-fit:cover; object-position:center bottom;
    filter:drop-shadow(0 4px 6px rgba(0,0,0,.8)); }
  .cb-fig-you .cb-fig-img { border-radius:7px; }
  .cb-fig-sprite .cb-fig-im { aspect-ratio:1/1; }
  .cb-fig-sprite .cb-fig-img { object-fit:contain; object-position:center bottom; border-radius:0 !important; }
  .cb-fig-sprite { width:clamp(56px,18vw,130px); }
  .cb-side:not(.cb-side-foe) { gap:0; }
  .cb-side:not(.cb-side-foe) .cb-fig-sprite + .cb-fig-sprite { margin-left:-18px; }
  @media (max-width:480px) {
    .cb-fig-sprite { width:clamp(44px,14vw,96px); }
    .cb-side:not(.cb-side-foe) .cb-fig-sprite + .cb-fig-sprite { margin-left:-8px; }
    .cb-side-foe .cb-fig + .cb-fig { margin-right:0; }
  }
  .cb-side:not(.cb-side-foe) .cb-fig-sprite:nth-child(1) { z-index:4; }
  .cb-side:not(.cb-side-foe) .cb-fig-sprite:nth-child(2) { z-index:3; }
  .cb-side:not(.cb-side-foe) .cb-fig-sprite:nth-child(3) { z-index:2; }
  .cb-side:not(.cb-side-foe) .cb-fig-sprite:nth-child(4) { z-index:1; }
  /* Footing. Without a contact shadow a cut-out sprite hovers; this is the whole trick that
     makes a square portrait read as "a character standing there". */
  .cb-fig-im::after { content:""; position:absolute; left:12%; right:12%; bottom:-5px; height:9px;
    border-radius:50%; background:radial-gradient(ellipse at center, rgba(0,0,0,.62), transparent 72%); }
  .cb-fig-bar { width:60%; height:5px; border-radius:3px; overflow:hidden; background:rgba(3,6,5,.8);
    box-shadow:0 0 0 1px rgba(0,0,0,.55); }
  .cb-fig-bar > i { display:block; height:100%; transition:width .2s; }
  .cb-fig-bar.you > i { background:linear-gradient(90deg,var(--neon),#8fe86a); }
  .cb-fig-bar.foe > i { background:linear-gradient(90deg,#ff3131,#ff7b00); }
  /* The name gets TWO LINES and no ellipsis. At a figure width of 40-96px, nowrap+ellipsis
     turned every real foe into "Hulking..." / "Rabid B..." -- and a name you cannot read is
     not a label. It is allowed to run wider than the figure (negative margin) because the
     ranks have gap between them and a centred two-line name is the thing the eye actually
     uses to tell the file apart. */
  .cb-fig-nm { width:132%; margin-inline:-16%; font:700 calc(9px*var(--tscale))/1.15 ui-monospace,monospace;
    color:var(--sub); text-align:center; text-shadow:0 1px 3px #000;
    white-space:normal; overflow-wrap:anywhere; display:-webkit-box; -webkit-box-orient:vertical;
    -webkit-line-clamp:2; overflow:hidden; }
  /* The 132% overhang is for a LONE figure. In a rank of two or more the figures already
     overlap by 18px, so two overhanging names land on top of each other ("Hulking Dumpster
     Ghoul" printed through "Blighted Dumpster Ghoul", seen at 375px). Any figure with a
     neighbour keeps its name inside its own width; the two-line clamp still applies. */
  .cb-side .cb-fig:has(+ .cb-fig) .cb-fig-nm, .cb-side .cb-fig + .cb-fig .cb-fig-nm { width:100%; margin-inline:0; }
  /* Everything that ISN'T the name lives here instead of on the figure: level, archetype,
     discovered weakness/resistance. One line under the scene, for the foe you are AIMED at
     -- the rack could afford a badge per tile, a diorama cannot without becoming a rack
     again. */
  .cb-scene-tgt { display:flex; flex-wrap:wrap; align-items:center; justify-content:center;
    gap:4px 9px; margin:0 0 6px; padding:4px 9px; min-height:22px;
    font:800 calc(10px*var(--tscale))/1.5 ui-monospace,monospace; letter-spacing:.06em;
    border:1px solid var(--line); border-radius:6px; background:rgba(8,14,12,.6); }
  .cb-scene-tgt .t-nm { color:var(--txt); letter-spacing:.02em; }
  .cb-scene-tgt .t-lv { color:var(--dim); }
  .cb-scene-tgt .t-none { color:var(--dim); font-weight:700; letter-spacing:.04em; }
  .cb-fig.dead { opacity:.36; filter:grayscale(1); }
  .cb-fig.dead .cb-fig-nm { text-decoration:line-through; }
  .cb-fig.on::before { content:""; position:absolute; inset:-5px -3px 12px; border-radius:8px;
    border:1px solid var(--amber); box-shadow:0 0 12px rgba(255,178,44,.45); pointer-events:none; }
  .cb-fig.hit .cb-fig-im { animation:cbHit .28s ease; }
  .cb-fig-you.lunge .cb-fig-im { animation:vaLungeR .3s cubic-bezier(.3,1.4,.6,1); }
  .cb-fig-sprite.lunge .cb-fig-im { animation:vaLungeR .3s cubic-bezier(.3,1.4,.6,1); }
  .cb-fig.cb-mob.lunge .cb-fig-im { animation:vaLungeL .3s cubic-bezier(.3,1.4,.6,1); }
  /* The enemy figure IS a .cb-mob, so the rack's card chrome would follow it into the scene.
     Strip it here rather than restructuring the rack -- the rack is still the other layout. */
  body.cbdio .cb-scene .cb-mob { border:0; background:transparent; padding:0; cursor:pointer;
    border-radius:0; box-shadow:none; overflow:visible; }
  body.cbdio .cb-scene .cb-mob.on { background:transparent; box-shadow:none; }
  /* Same for the avatar figure, which carries .cb-you so the FX layer finds it unchanged
     (see the DOM-order note in _cbScene). Its vitals-card styling has no place in a scene. */
  body.cbdio .cb-scene .cb-you { display:flex; flex-direction:column; padding:0; border:0;
    background:transparent; border-radius:0; box-shadow:none; gap:3px; align-items:center; }
  body.cbdio .cb-scene .cb-you.danger::after { display:none; }
  /* Schools parked behind one chip. Eight bars are already the widest rows on the screen
     (see f3d82715) and a diorama cannot afford a second dashboard under it. */
  .cb-schwrap > summary { list-style:none; cursor:pointer; display:inline-flex; align-items:center; gap:5px;
    margin:6px 0 2px; padding:2px 9px; border:1px solid var(--line); border-radius:999px;
    font:800 calc(10px*var(--tscale))/1.6 ui-monospace,monospace; letter-spacing:.1em; color:var(--sub); }
  .cb-schwrap > summary::-webkit-details-marker { display:none; }
  .cb-schwrap[open] > summary { color:var(--cyan); border-color:rgba(34,211,238,.5); }
  .cb-schwrap > summary:hover { border-color:var(--amber); }
  .cb-mob-nm { font-size:calc(10px*var(--tscale)); font-weight:700; color:var(--txt); padding:3px 4px 0;
    white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-align:center; }
  .cb-mob.dead .cb-mob-nm { text-decoration:line-through; color:var(--dim); }
  .cb-mob-lv { color:var(--dim); font-size:.82em; font-weight:700; letter-spacing:.3px; }
  /* margin-top:auto = pinned to the bottom of the flex column, so every tile's bar and
     HP number line up across the rack no matter how many badges sit above them. */
  .cb-mob-bar { height:7px; background:#1a0a0a; border-top:1px solid rgba(255,49,49,.4); overflow:hidden; margin-top:auto; }
  .cb-mob-bar > i { display:block; height:100%; background:linear-gradient(90deg,#ff3131,#ff7b00); transition:width .2s; }
  .cb-mob-hp { font-size:calc(11px*var(--tscale)); color:var(--dim); text-align:center; padding:1px 0 2px; }
  /* A downed tile has no bar to carry the margin-top:auto, so pin its DOWN label instead. */
  .cb-mob.dead .cb-mob-hp { margin-top:auto; }
  .cb-mob.hit { animation:cbHit .28s ease; }
  /* Discovered elemental weakness — surfaced once the mob has been struck with the school
     it's weak to (server sets weak_school). Tinted in that school's color (SCHOOL_COLORS). */
  .cb-mob-wk { font:800 calc(11px*var(--tscale))/1.35 ui-monospace,monospace; letter-spacing:.03em;
    text-align:center; margin:2px 3px 0; padding:0 3px; border:1px solid; border-radius:3px;
    background:rgba(4,8,10,.72); text-shadow:0 0 5px currentColor; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .cb-solo-wk { display:block; width:fit-content; margin:6px auto 0; padding:2px 10px; }
  /* RESIST is the bad-news mirror: dashed, no glow, slightly dimmed — the eye still goes to
     the bright ⚡WEAK first (chase that), and reads RESIST as "don't bother with this school". */
  .cb-mob-rs { border-style:dashed; text-shadow:none; opacity:.82; }
  .cb-repick { font-size:calc(10px*var(--tscale)); color:var(--amber); text-align:center; padding:3px 0; }
  /* Potion cool-down (canon): a bar per party frame. Empties as the timer burns down, so
     "ready" reads at a glance without a number to parse mid-fight. */
  .cb-cdbar { height:4px; background:#0a1016; border-radius:2px; overflow:hidden; margin-top:3px; }
  .cb-cdbar > i { display:block; height:100%; background:linear-gradient(90deg,var(--violet),#22d3ee); transition:width .3s; }
  .cb-cdlbl { font-size:calc(10px*var(--tscale)); color:var(--violet); }
  /* One-line header that replaces the banner in a multi-mob room. */
  .cb-slimhead { display:flex; align-items:center; gap:6px; flex-wrap:wrap; padding:2px 0 4px; }
  .cb-slimnm { font-size:calc(12px*var(--tscale)); font-weight:800; color:var(--txt); }
  .cb-slimhead .cb-intent { position:static; margin-left:auto; }
  /* UI-01: 4px gutters and a 6px lead-in, not 6/12. The mana strip above the row costs
     vertical space; the row takes it back so the whole bar still fits above the fold. */
  .cb-bar { display:flex; flex-wrap:wrap; gap:4px; justify-content:center; margin-top:6px; }
  /* MANA GAUGE — docked directly above the action tiles, break meter directly above it. */
  .cb-mana { display:flex; align-items:center; gap:6px; margin:5px 0 0; }
  .cb-mana .l { font-size:var(--fs-tiny); font-weight:800; letter-spacing:.5px; color:var(--cyan); }
  .cb-mana .t { flex:1; height:5px; border-radius:3px; background:var(--bar-track); overflow:hidden; position:relative; }
  .cb-mana .t > i { position:absolute; left:0; top:0; bottom:0; background:var(--cyan); border-radius:3px; transition:width .3s; }
  .cb-mana .n { font-size:var(--fs-tiny); color:var(--cyan); font-variant-numeric:tabular-nums; }
  .cb-mana.bld .l, .cb-mana.bld .n { color:var(--hp); }
  .cb-mana.bld .t > i { background:var(--hp); }
  .cb-bar.cb-acting { opacity:.45; pointer-events:none; transition:opacity .08s; }   /* instant tap feedback */
  .cb-tile { position:relative; width:66px; height:74px; margin:0; padding:5px 3px 4px; display:flex; flex-direction:column; align-items:center; gap:1px; border:1px solid var(--neon); border-radius:10px; background:rgba(10,15,12,.92); overflow:hidden; color:var(--txt); }
  .cb-tile:disabled { opacity:.42; cursor:not-allowed; }
  /* Weapon-gated: dimmer and desaturated so it reads as UNAVAILABLE-BY-RULE rather than
     merely on-cooldown, which is a temporary state the player would otherwise wait out. */
  .cb-tile.locked { opacity:.3; filter:grayscale(1); border-color:#4a5060 !important; }
  .cb-tile .ct-lk { position:absolute; top:2px; left:3px; font-size:calc(9px*var(--tscale)); line-height:1; opacity:.95; }
  .cb-tile .ct-ic { height:26px; display:flex; align-items:center; justify-content:center; font-size:calc(19px*var(--tscale)); }
  .cb-tile .ct-ic img { width:24px; height:24px; object-fit:contain; }
  .cb-tile .ct-nm { font-size:calc(10px*var(--tscale)); font-weight:700; color:var(--sub); max-width:100%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .cb-tile .ct-val { font-size:calc(11px*var(--tscale)); font-weight:900; }
  .cb-tile .ct-val.h { color:var(--neon); }
  .cb-tile.sig { border-color:var(--amber); box-shadow:0 0 8px rgba(255,176,0,.35); }
  .cb-tile.gear { border-color:var(--cyan); }
  .cb-tile.show { border-color:var(--pink); color:var(--pink); }
  .cb-tile.show.lit { box-shadow:0 0 12px var(--pink); }
  /* overflow:hidden host — inset glow layer, opacity-only pulse */
  .cb-tile.show.lit::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; box-shadow:inset 0 0 12px var(--pink); animation:sspulse 1.1s ease-in-out infinite; }
  .ct-lvl { position:absolute; top:2px; left:4px; font-size:calc(10px*var(--tscale)); font-weight:900; color:var(--neon); }
  .ct-mp { position:absolute; top:2px; right:4px; font-size:calc(10px*var(--tscale)); font-weight:900; color:var(--cyan); }
  .ct-rd { position:absolute; bottom:2px; left:4px; font-size:calc(10px*var(--tscale)); filter:drop-shadow(0 0 3px #000); }
  .ct-dur { font-size:calc(10px*var(--tscale)); color:var(--cyan); }
  .ct-cd { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:conic-gradient(rgba(3,6,4,.72) var(--cd), transparent 0); }
  .ct-cd b { font-size:calc(22px*var(--tscale)); font-weight:900; color:#fff; text-shadow:0 0 5px #000; }
  .cb-members { display:flex; gap:4px; flex-wrap:wrap; justify-content:center; margin-bottom:8px; }
  .cb-mtab { padding:8px 10px; font-size:calc(11px*var(--tscale)); font-weight:700; border:1px solid var(--line); border-radius:6px; background:rgba(10,15,12,.85); color:var(--dim); cursor:pointer; }
  .cb-mtab.active { border-color:var(--neon); color:var(--neon); background:rgba(57,255,20,.08); }
  .cb-mtab.done { border-color:var(--cyan); color:var(--cyan); }
  .cb-mtab.active.done { border-color:var(--neon); color:var(--neon); }
  .cb-tile.planned { box-shadow:0 0 10px var(--cyan); border-color:var(--cyan) !important; }
  .cb-resolve { padding:6px 18px; font-size:calc(13px*var(--tscale)); font-weight:800; letter-spacing:1px; border:2px solid var(--neon); border-radius:8px; background:rgba(57,255,20,.12); color:var(--neon); cursor:pointer; }
  .cb-resolve:hover { background:rgba(57,255,20,.22); }
  .cb-barfoot { display:flex; align-items:center; justify-content:space-between; margin-top:10px; }
  .cb-hold { font-size:calc(10px*var(--tscale)); color:var(--dim); font-style:italic; }
  /* Action tooltip: a real styled card (the native title= tooltip is slow + unthemed,
     and never said what an action actually DOES). */
  #cb-tt { position:fixed; z-index:120; pointer-events:none; white-space:nowrap;
    background:#0b1118; border:1px solid var(--neon); border-radius:9px; padding:7px 9px;
    box-shadow:0 6px 22px rgba(0,0,0,.65), 0 0 12px rgba(57,255,20,.18); font-family:var(--mono); }
  #cb-tt .tt-n { color:var(--neon); font-weight:800; font-size:calc(12px*var(--tscale)); letter-spacing:.4px; }
  #cb-tt .tt-d { color:var(--sub); font-size:calc(11px*var(--tscale)); margin-top:3px; line-height:1.45; }
  #cb-tt .tt-m { color:var(--dim); font-size:calc(10px*var(--tscale)); margin-top:4px;
    border-top:1px solid rgba(150,178,214,.14); padding-top:4px; }
  #cb-tt .tt-m b { color:var(--cyan); }
  /* Potions bar — quaff mid-fight. Costs the round, so it's a real decision. */
  .cb-pots { display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-top:7px;
    border-top:1px solid var(--line); padding-top:7px; }
  .cb-pots .pl { font-size:calc(10px*var(--tscale)); color:var(--dim); letter-spacing:1px; margin-right:2px; }
  .cb-pot { position:relative; width:38px; height:38px; border-radius:8px; cursor:pointer; padding:0;
    border:1px solid var(--rc,var(--line)); background:#0a0f14; display:flex; align-items:center; justify-content:center; }
  .cb-pot img { width:24px; height:24px; }
  .cb-pot .uic { width:24px; height:24px; vertical-align:0; }
  .cb-pot .pc { position:absolute; right:-3px; bottom:-3px; background:#0b1118; border:1px solid var(--rc,var(--line));
    border-radius:6px; font-size:calc(10px*var(--tscale)); padding:0 3px; color:var(--txt); font-weight:800; }
  .cb-pot.sel { box-shadow:0 0 0 2px var(--neon), 0 0 12px rgba(57,255,20,.5); }
  .cb-pothint { font-size:calc(10px*var(--tscale)); color:var(--neon); font-style:italic; }
  /* A party card you can quaff at while a potion is selected */
  .cb-party.picking .pf2:not(.down) { cursor:pointer; outline:1px dashed rgba(57,255,20,.55); outline-offset:2px; }
  .cb-party.picking .pf2.down { opacity:.45; }
  /* The armed heal-ability tile (same glow as a selected potion) */
  .cb-tile.sel { box-shadow:0 0 0 2px var(--neon), 0 0 12px rgba(57,255,20,.5); }
  /* Any eligible ally target while a heal is armed — the pf2 crew card, the avatar's
     vitals block, or a diorama figure, whichever is on screen for this view. */
  .heal-target { cursor:pointer; outline:1px dashed rgba(57,255,20,.55); outline-offset:2px; }
  .cb-fleebtn { width:auto; }
  /* Auto-attack toggle: quiet when OFF, amber + a pre-painted opacity-only pulse when ON
     (perf rule — the glow layer is painted once and only its opacity animates). */
  .cb-autobtn { width:auto; }
  .cb-autobtn.on { border-color:var(--amber); color:var(--amber); position:relative; }
  .cb-autobtn.on::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none;
    box-shadow:0 0 10px var(--amber); opacity:0; animation:autopulse 1.3s ease-in-out infinite; }
  @keyframes autopulse { 0%,100%{opacity:0} 50%{opacity:.55} }
  body.no-motion .cb-autobtn.on::after { animation:none; opacity:.3; }
  /* Gear target picker — same "pick who" language as the potion bar above, so the
     dashed neon outline reads as a target on both surfaces. */
  .eqpick { display:grid; grid-template-columns:1fr; gap:5px; max-height:46vh; overflow-y:auto; }
  .eqt { display:flex; gap:7px; align-items:center; padding:4px 6px; border-radius:7px; background:#0b110e;
         cursor:pointer; outline:1px dashed rgba(57,255,20,.55); outline-offset:-1px; }
  /* House War on a small phone: the bar gets out of the way entirely. With the name's
     124px floor there is only ~9px left for it at 360px — a stub that reads as a broken
     element, not a comparison. The Fame·Members column already carries that information
     as text, so below 430px the bar is dropped and the name takes the room. !important
     beats the template renderFactions sets inline on every row. */
  @media (max-width:430px){
    #faction-board .dth, #faction-board .dtr {
      grid-template-columns:22px 24px minmax(112px,1fr) 0px 100px !important; }
    #faction-board .tbar { display:none; }
  }
  @media (hover:hover) { .eqt:hover { background:#0f1a12; outline-color:var(--neon); } }
  /* A crawler who CAN'T use this consumable: still listed (you need to see that Wei is the
     one who knows Backstab), but not tappable — the server would refuse and the bottle is
     destroyed on use, so a mis-tap is expensive. */
  .eqt-off, .eqt-off:hover { cursor:default; outline:1px dashed var(--line); background:#0b110e; opacity:.55; }
  .eqt img { width:32px; height:32px; flex:0 0 32px; border-radius:6px; object-fit:cover; border:1px solid var(--line); }
  .eqt .eqti { min-width:0; flex:1; }
  .eqt .eqtn { font-weight:700; font-size:calc(12px*var(--tscale)); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .eqt .eqts { font-size:calc(10px*var(--tscale)); color:var(--dim); }
  .eqerr { border:1px solid var(--red); background:rgba(255,77,77,.09); color:var(--red); border-radius:6px;
           padding:5px 7px; font-size:calc(11px*var(--tscale)); margin-bottom:8px; line-height:1.4; }
  /* Hand-me-down report — reuses the .eqt tile shape (same "gear moved to a crawler"
     language), minus the dashed target outline: this is a receipt, not a picker. */
  .hmd { max-height:52vh; overflow-y:auto; }
  .hmd .eqt { cursor:default; outline:none; border:1px solid var(--line); }
  @media (hover:hover) { .hmd .eqt:hover { background:#0b110e; } }
  .hmdh { font-size:calc(10px*var(--tscale)); letter-spacing:1px; color:var(--dim); margin:9px 0 4px; }
  .hmdg { color:var(--neon); font-weight:700; }
  .hmdw { font-size:calc(10px*var(--tscale)); color:var(--dim); line-height:1.35; }
  /* Safe-room "waystation" tavern (full-screen rest hub) */
  /* The safe-room tavern AND the market share the centered-modal chrome. #market reuses
     .tavx/.tav-scrim, so it needs the same positioned container (it had none — the card
     was pinning to the top-left instead of centering). Its own bg, no tavern hall. */
  #tavern, #market { position:fixed; inset:0; z-index:62; display:flex; align-items:center; justify-content:center; padding:12px; }
  #tavern { background:linear-gradient(rgba(6,5,3,.62),rgba(6,5,3,.8)), url('/icons/tavern/hall.jpg') center/cover no-repeat; }
  /* AN OVERLAY YOU CAN SEE AND SCROLL THE GAME THROUGH IS NOT AN OVERLAY.
     #market was rgba(4,10,8,.66)->(.85): never opaque, so the whole dashboard read straight
     through the stalls. #tavern was worse -- no background at ALL on the container, relying on
     a .tav-scrim child at .55. #combat has been solid #05080a since it shipped, which is why
     only these two showed the bug. Same treatment now, each keeping its own tint. */
  #market { background:#050a08; }
  #tavern { background:#0a0806; }
  /* ...and nothing locked the page. There was no body-overflow toggle anywhere in this file,
     so every overlay let you scroll the HUD behind it. Keyed on :has() rather than a class
     toggled by each open/close pair, because there are six of these and a hand-maintained
     class is one missed close() away from a permanently frozen page. */
  body:has(#combat:not(.hide)),
  body:has(#market:not(.hide)),
  body:has(#tavern:not(.hide)),
  body:has(#camp:not(.hide)),
  body:has(#compare:not(.hide)) { overflow:hidden; }
  /* ...AND THAT IS NOT ENOUGH ON iOS. `overflow:hidden` on <body> does not stop touch
     scrolling in Safari -- WebKit keeps scrolling the document under the overlay anyway.
     Reported from an iPad on 2026-08-10 ("I'm also able to scroll the HUD in arena"), which
     is the SAME defect the market fix was supposed to close; that fix worked on desktop and
     did nothing on the device the game is actually played on.
     `position:fixed` is what WebKit honours. It also drops the scroll position to the top,
     so _scrollLock below restores it on close -- CSS alone cannot remember where you were.
     Kept as a body CLASS driven by one observer rather than by each open/close pair, for the
     same reason the :has() rule above exists: a hand-maintained toggle is one missed close()
     away from a permanently frozen page. */
  body.lockscroll { position:fixed; left:0; right:0; width:100%; overflow:hidden;
                    overscroll-behavior:none; }
  #tavern.hide, #market.hide { display:none !important; }
  /* ---- SAFE ROOM HUB (body-level overlay, flag camp_hub) ---------------------------
     Identifiers keep the `camp` prefix; the player-facing name is SAFE ROOM. Renaming the
     flag would strand the setting for anyone who has already turned it on.
     Stations are TILES that each carry their own state, so the screen answers "is there
     anything to do here" before you open anything. Every tile delegates to the entry point
     that already exists -- this is a front door, not a reimplementation. */
  #camp:not(.hide) { position:fixed; inset:0; z-index:64; display:flex; align-items:stretch;
    justify-content:center; background:rgba(3,5,7,.82); }
  .cmpx { position:relative; width:100%; max-width:440px; display:flex; flex-direction:column;
    background:linear-gradient(180deg,#0a0f12,#060a0c); border-left:1px solid var(--line);
    border-right:1px solid var(--line); overflow:hidden; }
  .cmp-head { display:flex; align-items:center; gap:8px; padding:10px 13px;
    border-bottom:1px solid var(--line); background:linear-gradient(180deg,#0c1317,#080d10); }
  .cmp-head .tk { width:3px; height:15px; background:var(--neon); box-shadow:0 0 7px var(--neon); }
  .cmp-head .t { font-size:calc(12px*var(--tscale)); letter-spacing:2px; color:var(--fg); }
  .cmp-head .r { margin-left:auto; font-size:calc(11px*var(--tscale)); color:var(--cyan); letter-spacing:1px; }
  .cmp-x { background:none; border:1px solid var(--line); color:var(--dim); border-radius:7px;
    padding:2px 9px; margin:0 0 0 8px; width:auto; font-size:calc(13px*var(--tscale)); }
  .cmp-flav { padding:10px 13px; border-bottom:1px solid var(--line); background:#070c0f;
    color:var(--dim); font-size:calc(11px*var(--tscale)); line-height:1.55; font-style:italic; }
  .cmp-flav b { color:var(--cyan); font-style:normal; }
  .cmp-body { flex:1 1 auto; overflow:auto; -webkit-overflow-scrolling:touch; }
  .cmp-grid { padding:11px; display:grid; grid-template-columns:1fr 1fr; gap:9px; }
  .cmp-st { background:var(--panel); border:1px solid var(--line); border-radius:10px;
    padding:11px 10px 10px; position:relative; overflow:hidden; min-height:96px; text-align:left;
    display:flex; flex-direction:column; width:auto; margin:0; cursor:pointer; }
  .cmp-st::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px;
    background:var(--cc,var(--neon)); opacity:.85; }
  .cmp-st .g { font-size:calc(19px*var(--tscale)); line-height:1; margin-bottom:6px; }
  .cmp-st .n { font-size:calc(12px*var(--tscale)); letter-spacing:1.6px; color:var(--cc,var(--neon)); }
  .cmp-st .s { font-size:calc(10px*var(--tscale)); color:var(--dim); margin-top:4px; line-height:1.45; }
  .cmp-st .bdg { position:absolute; top:8px; right:8px; font-size:calc(9px*var(--tscale));
    letter-spacing:.8px; padding:2px 6px; border-radius:9px; border:1px solid var(--cc,var(--neon));
    color:var(--cc,var(--neon)); }
  .cmp-fl { margin:0 11px 11px; padding:10px 11px; border:1px solid var(--line); border-radius:9px;
    background:#080d10; }
  .cmp-fl .h { font-size:calc(9px*var(--tscale)); letter-spacing:2px; color:var(--cyan); margin-bottom:7px; }
  .cmp-fl .r { display:flex; justify-content:space-between; font-size:calc(10.5px*var(--tscale));
    padding:3px 0; color:var(--dim); }
  .cmp-fl .r b { font-weight:400; color:var(--fg); }
  .cmp-prime { margin:0 11px 12px; padding:14px; border-radius:10px; text-align:center;
    border:1px solid var(--amber); width:auto;
    background:linear-gradient(180deg,rgba(255,183,51,.13),rgba(255,183,51,.05)); }
  .cmp-prime .k { font-size:calc(14px*var(--tscale)); letter-spacing:3px; color:var(--amber); }
  .cmp-prime .v { font-size:calc(10px*var(--tscale)); color:var(--dim); margin-top:4px; }
  .cmp-prime[disabled] { opacity:.45; }
  /* Room-view hub (image with clickable hotspots) */
  .cmp-room { position:relative; margin:11px; border-radius:9px; overflow:hidden; }
  .cmp-room img { width:100%; display:block; border-radius:9px; }
  .cmp-room .hs { position:absolute; cursor:pointer; border:1.5px solid transparent;
    border-radius:4px; transition:border-color .2s, background .2s; }
  .cmp-room .hs:hover, .cmp-room .hs:active {
    border-color:rgba(255,200,80,.3);
    background:radial-gradient(ellipse at center 60%,rgba(255,200,80,.14),transparent 70%); }
  .cmp-room .hs-l { position:absolute; bottom:calc(100% + 3px); left:50%;
    transform:translateX(-50%); font-size:calc(9px*var(--tscale)); letter-spacing:1.5px;
    color:#fff; text-transform:uppercase; white-space:nowrap; opacity:0;
    transition:opacity .2s; pointer-events:none;
    text-shadow:0 1px 6px rgba(0,0,0,.95); padding:2px 5px;
    background:rgba(0,0,0,.7); border-radius:3px; }
  .cmp-room .hs:hover .hs-l, .cmp-room .hs:active .hs-l { opacity:1; }
  .cmp-room .hs-b { position:absolute; top:-3px; right:-3px;
    font-size:calc(8px*var(--tscale)); letter-spacing:.6px;
    padding:1px 4px; border-radius:3px; color:#fff; pointer-events:none; }
  /* FIRST-PERSON VIEWPORT. This lived in an inline style as width:100%;height:300px, which
     is fine on the phone it was authored for (448x300 = 1.49:1) and runs away on anything
     wider: measured on a 2560px desktop the same element was 2003x300 = 6.68:1.
     THREE's `fov` is the VERTICAL angle and horizontal grows with aspect, so 6.68 turned a
     72-degree camera into a 157-degree fisheye -- the corridor did not get wider, it got
     stretched. Capping the RATIO is the fix; no camera setting rescues a 6.7:1 box, it can
     only trade the horizontal stretch for a crushed vertical.
     1.9 keeps horizontal FOV near 108 degrees. Phones are unaffected: 448px is already under
     the cap at this height, so the box stays exactly 448x300. */
  #fp-view { width:100%; height:220px; max-width:570px; margin-inline:auto;
             position:relative; overflow:hidden; border-radius:8px; }
  @media (min-height:900px) { #fp-view { height:300px; max-width:570px; } }
  .tav-scrim { position:absolute; inset:0; background:rgba(4,6,8,.55); }
  .tavx { position:relative; width:100%; max-width:440px; max-height:94vh; overflow:auto; background:linear-gradient(180deg,#141009,#0a0806); border:1px solid var(--amber); border-radius:12px; padding:16px; box-shadow:0 0 40px rgba(255,176,0,.18); }
  .tav-head { display:flex; align-items:center; justify-content:space-between; }
  .tav-badge { font-size:calc(10px*var(--tscale)); font-weight:900; letter-spacing:2px; color:var(--neon); border:1px solid var(--neon); border-radius:999px; padding:2px 9px; }
  .tav-x { background:none; border:none; color:var(--red); font-size:calc(18px*var(--tscale)); cursor:pointer; padding:2px 6px;
    min-width:40px; min-height:40px; display:inline-flex; align-items:center; justify-content:center;
    text-shadow:0 0 8px rgba(255,50,50,.7),0 0 16px rgba(255,0,0,.35); filter:drop-shadow(0 0 4px rgba(255,0,0,.3)); }
  .tav-name { font-size:calc(22px*var(--tscale)); font-weight:900; color:#f4e9c9; letter-spacing:.3px; margin-top:8px; text-shadow:0 2px 8px #000; }
  .tav-flavor { font-size:calc(12px*var(--tscale)); color:var(--sub); line-height:1.5; font-style:italic; margin-top:6px; border-left:2px solid var(--amber); padding-left:9px; }
  .tav-econ { display:flex; gap:16px; margin-top:10px; font-size:calc(12px*var(--tscale)); color:var(--amber); font-weight:700; }
  .tav-sub { font-size:calc(10px*var(--tscale)); letter-spacing:1.5px; color:var(--dim); margin-top:14px; font-weight:800; }
  /* The safe room's own log — outcomes (haggles, meals) surface HERE, since the main
     feed sits behind this full-screen overlay and can't be read while it's open. */
  .tav-log { margin-top:6px; border:1px solid var(--line); border-radius:8px; background:var(--inset);
    padding:7px 9px; max-height:118px; overflow-y:auto; font-family:var(--mono); }
  .tav-line { font-size:calc(11px*var(--tscale)); color:var(--sub); padding:2px 0; line-height:1.45; }
  .tav-line + .tav-line { border-top:1px solid rgba(150,178,214,.08); }
  .tav-line.fresh { color:var(--txt); }
  .tav-line.fresh::before { content:"▸ "; color:var(--neon); }
  body.no-motion .tav-line.fresh { animation:none; }
  .tav-acts { display:flex; flex-direction:column; gap:8px; margin-top:12px; }
  .tav-acts button { width:100%; text-align:left; }
  /* Haggle toggle — a quiet opt-in under the Order button, not a competing CTA. */
  .mealhag { display:flex; align-items:center; gap:8px; margin:-2px 0 2px 2px; cursor:pointer;
    font-size:calc(11.5px*var(--tscale)); color:var(--dim); user-select:none; }
  .mealhag:hover { color:var(--sub); }
  .mhbox { flex:none; width:16px; height:16px; border:1.5px solid var(--line); border-radius:4px;
    display:flex; align-items:center; justify-content:center; font-size:calc(11px*var(--tscale)); font-weight:900;
    /* was #062 -- 2.47:1 on --panel, under even the 3:1 floor for a UI glyph */
    color:#00812b; background:var(--panel); }
  .mhbox.on { background:var(--amber); border-color:var(--amber); }
  .tav-ft { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
  .tav-boxes { display:flex; flex-wrap:wrap; gap:6px; margin:6px 0 2px; }
  .tav-leave { width:100%; margin-top:14px; border-color:var(--neon) !important; color:var(--neon); }
  /* Personal Space rows: the room fills the row, its haggle rides beside it. */
  .tav-rooms { display:flex; flex-direction:column; gap:6px; margin-top:6px; }
  .tav-room { display:flex; gap:6px; align-items:stretch; }
  .tav-room > button:first-child { flex:1; margin:0; }
  .tav-room > button.hag { flex:0 0 44px; margin:0; }
  /* The room's plate (icons/rooms/<room>_<tier>.webp, 3:2) tops its card and the size plate tops
     the ladder. Painterly, so no pixelated rendering; a 404 hides itself (onerror) and the card
     reads exactly as it did without one. */
  .ps-plate { display:block; width:100%; aspect-ratio:3/2; object-fit:cover; border-radius:6px; margin:0 0 6px; }
  .ps-plate.size { aspect-ratio:2/1; margin:6px 0 2px; }
  .cb-result { display:flex; align-items:center; justify-content:center; gap:12px; padding:8px 12px; flex-wrap:wrap; }
  .mbanner { border-radius:6px; padding:6px 9px; margin-bottom:7px; font-size:calc(11px*var(--tscale)); }
  .mbanner.amber { background:#161100; border:1px solid #5a4612; color:var(--amber); }
  .mbanner.red { background:#1a0606; border:1px solid #5a1212; color:var(--red); }
  button:disabled { opacity:.5; cursor:not-allowed; }
  /* dashboard */
  .dvit { background:#0c1310; border:1px solid var(--line); border-radius:8px; padding:8px 11px; margin-bottom:9px; }
  .drow { margin:3px 0; }
  .dgrid { display:grid; grid-template-columns:repeat(3,1fr); gap:6px; }
  .dcell { background:#0c1310; border:1px solid var(--line); border-radius:7px; padding:6px 4px; text-align:center; }
  .dcell .dl { font-size:calc(10px*var(--tscale)); color:var(--dim); letter-spacing:1px; }
  .dcell .dv { font-size:calc(15px*var(--tscale)); color:var(--txt); font-weight:bold; }
  .filterbar { display:flex; flex-wrap:nowrap; gap:3px; margin-bottom:8px; overflow-x:auto; }
  .filterbar button { margin:0; padding:2px 6px; font-size:calc(10px*var(--tscale)); background:#0c1310; color:var(--dim); border-color:var(--line); white-space:nowrap; }
  .filterbar button.on { color:var(--neon); border-color:var(--neon); background:#0e1a0e; }
  .stat { display:inline-block; background:#0e141a; border:1px solid var(--line); border-radius:20px; padding:2px 9px; margin:2px 6px 2px 0; }
  /* Collapse = the doomsday clock; distinct from the red LIVE broadcast dot */
  .stat.collapse { color:var(--amber); border-color:#5a4612; background:#161100; }
  .stat.collapse b { color:var(--title-gold); text-shadow:0 0 7px rgba(255,176,0,.55); }
  .stat.collapse::before { content:"\23F3 "; animation:collapsePulse 2s ease-in-out infinite; }
  @keyframes collapsePulse { 0%,100%{ opacity:1; } 50%{ opacity:.4; } }   /* already opacity-only — compositor-friendly as-is */
  /* Persistent top HUD — flat, no pills */
  .hud1 { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-start; gap:8px; font-size:calc(12px*var(--tscale)); }
  .hud1 .hudk { color:var(--dim); font-size:calc(10px*var(--tscale)); letter-spacing:.5px; }
  .hud1 b { color:#fff; }
  /* Audience metrics wrapper (views/followers/subs/credits): transparent on desktop so
     chips flow in .hud1 as before; collapses to a compact secondary row on mobile. */
  .hud-aud { display:contents; }
  /* persistent player identity card (top of the HUD) */
  .hud-id { display:flex; align-items:center; gap:9px; margin-bottom:7px; }
  .hud-id .por-wrap { margin:0 !important; flex-basis:42px !important; overflow:hidden; }
  .por-wrap > img:first-child { width:calc(100% - 4px) !important; height:calc(100% - 4px) !important; margin:2px; }
  .hud-id img.hud-av { width:42px; height:42px; border-radius:7px; border:1px solid var(--neon); object-fit:cover; background:#0a0f0c; flex:none; }
  /* "Increase size of left side text" — the identity is the thing you look at, and it was
     the smallest type in the bar. The name leads at 15px; the title line sits under it and
     CLIPS rather than wraps, so a long title can never push the bar taller (this is a
     fixed frame — it grows into the map). */
  .hud-id-txt { font-size:calc(12px*var(--tscale)); line-height:1.35; min-width:0; }
  .hud-id-txt b { color:#fff; font-size:calc(15px*var(--tscale)); }
  .hud-id-txt > :not(b) { white-space:nowrap; }
  /* The second line is one clipped row (the title). Long values truncate with an
     ellipsis instead of wrapping to a third line. */
  .hud-id-txt { overflow:hidden; text-overflow:ellipsis; }
  .hud2 { display:flex; align-items:center; gap:5px; flex-wrap:wrap; margin-top:5px; font-size:calc(11px*var(--tscale)); }
  .hud2 .vk { font-weight:700; }
  .hud2 .vv { color:var(--dim); margin-right:6px; }
  .hud-sub { color:#c4d2c6; font-size:calc(11px*var(--tscale)); }   /* L / race / class — brighter than muted */
  .hud-title { color:var(--pink); font-size:calc(11px*var(--tscale)); font-weight:700; }
  /* Affiliation line: guild · House (sigil+name) · Club badge. */
  .hud-affil { display:inline-flex; align-items:center; gap:9px; flex-wrap:wrap; font-size:calc(11px*var(--tscale)); font-weight:700; }
  .hud-affil .af { display:inline-flex; align-items:center; gap:3px; padding:1px 5px; border-radius:3px; background:rgba(255,255,255,.06); }
  .hud-affil .af + .af { border-left:1px solid rgba(255,255,255,.12); }
  .hud-affil .af svg { vertical-align:-2px; }
  /* Regen + XP-to-next line under the vitals stack. */
  .hud-spx { font-size:calc(10px*var(--tscale)); color:var(--dim); text-align:right; margin-top:1px; white-space:nowrap; }
  /* Floor · Zone in the header (fills the dead middle). */
  /* The collapse clock is flex:1 and absorbs all the free space, so the brand and the floor
     label were butted together with a 0px gap (measured). A left margin holds them apart —
     it can't be a header `gap` because that would also pad the clock and the controls. */
  .hdr-floorzone { color:var(--cyan); font-size:calc(12px*var(--tscale)); letter-spacing:1px; opacity:.85; white-space:nowrap; margin-left:calc(22px*var(--tscale)); }
  .hdr-floorzone b { color:#bfefff; }
  /* Status-bar two-column shell: identity + econ stat row (left) vs HP/SP/MP vitals (right) */
  .hudmain { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; flex-wrap:wrap; }
  .hud-left { min-width:0; flex:1 1 auto; }
  .hud-right { flex:none; display:flex; flex-direction:column; gap:6px; align-items:flex-end; padding-top:2px; }
  /* compact stacked vitals bars inside the player card */
  .hbar { display:flex; align-items:center; gap:6px; font-size:calc(10px*var(--tscale)); margin-top:3px; }
  .hbar .hbk { width:20px; font-weight:700; flex:none; }
  .hbar .hbt { flex:1; height:12px; background:#0a0f0c; border:1px solid var(--line); border-radius:5px; overflow:hidden; }
  .hbar .hbt > i { display:block; height:100%; transition:width .2s; }
  .hbar .hbv { flex:none; width:74px; color:var(--txt); font-size:calc(10px*var(--tscale)); text-align:right; white-space:nowrap; overflow:hidden; }
  .hbar-sub { text-align:right; color:var(--dim); font-size:calc(10px*var(--tscale)); line-height:1.3; margin-top:1px; }
  /* sticky top region: title bar + player card + quick feed stay pinned while tabs scroll */
  #topstick { position:sticky; top:0; z-index:50; background:var(--bg); box-shadow:0 5px 9px -6px rgba(0,0,0,.7); }
  #topbar:empty, #statusfeed:empty { display:none; }
  /* SP lives in its own updatable wrapper — full-width so its stacked bar matches HP/MP */
  #hud-sp { display:block; }
  /* #06 ZONE PROGRESS BAR -- floor completion as one number: cleared/total FIGHT nodes
     across the WHOLE floor, not just what fog has revealed (node TYPE already rides every
     hex regardless of state -- same spoiler level "% mapped" and the camp panel's totals
     already show). It never grows or shrinks as you explore, only as you clear (the
     per-encounter pip strip that used to sit above it was removed 2026-09-13 as confusing).
     A one-shot width transition, like
     .cb-mob-bar > i -- no reduce-motion guard needed for the same reason those don't have one. */
  .zone-bar { display:none; }
  .zone-bar .zb-track { position:relative; flex:1; height:6px; border-radius:3px; background:var(--bar-track); }
  .zone-bar .zb-fill { display:block; height:100%; border-radius:3px; background:var(--neon); transition:width .3s; }
  .zone-bar .zb-mk { position:absolute; top:50%; width:6px; height:6px; margin:-3px 0 0 -3px; border-radius:50%; background:var(--dim); border:1px solid rgba(0,0,0,.4); }
  .zone-bar .zb-mk.elite { background:var(--violet); }
  .zone-bar .zb-mk.miniboss, .zone-bar .zb-mk.boss { background:var(--red); }
  .zone-bar .zb-mk.done { opacity:.4; }
  .zone-bar .zb-chest { font-size:var(--fs-sm); color:var(--dim); }
  .zone-bar .zb-chest.lit { color:var(--neon); text-shadow:0 0 4px var(--neon); }
  /* UI-02 — MECHANIC CARD. The one line that says what an effect DOES, split out of the lore
     it used to be glued to: a dark inset, a school-coloured left edge, the trigger in caps,
     then the derived mechanic with its numbers lit. The Fable line follows as .mlore. */
  .mcard { background:var(--inset); border-radius:var(--radius-md); border-left:3px solid var(--title-gold);
           padding:5px 8px; margin-top:5px; }
  .mcard .mtrig { font-size:var(--fs-tiny); font-weight:800; text-transform:uppercase; letter-spacing:.5px; }
  .mcard .mdesc { font-size:var(--fs-sm); color:var(--sub); line-height:1.45; margin-top:1px; }
  .mcard .mnum  { font-weight:800; }
  .stag { display:inline-block; font-size:var(--fs-tiny); font-weight:700; padding:0 4px;
          border-radius:var(--radius-sm); border:1px solid; vertical-align:middle; margin-left:3px; }
  .mlore { font-size:var(--fs-tiny); color:var(--dim); font-style:italic; line-height:1.45;
           border-left:2px solid var(--line); padding-left:6px; margin-top:4px; }
  .rpill { font-size:var(--fs-tiny); text-transform:uppercase; letter-spacing:1px; font-weight:800;
           padding:1px 5px; border-radius:var(--radius-sm); border:1px solid; white-space:nowrap; }
  /* Map: never let hexes/icons get text-selected (drag-highlight) */
  /* The map is ONE instrument: viewport, legend and actions inside a single border.
     .mapframe draws that border and must NOT clip — the action row's buttons live in it.
     .mapview is the pannable window and keeps the overflow:hidden, and it's what the
     absolutely-positioned zoom/recenter buttons anchor to (against .mapframe they'd sink
     to the bottom of the whole box, below the actions). */
  .mapframe { background:#06080a; border:1px solid var(--line); border-radius:6px; }
  .mapview { position:relative; overflow:hidden; border-radius:6px 6px 0 0; background:#000; }

  /* ---- FULL-SCREEN MAP ----------------------------------------------------------------
     The map is the game's main instrument and it was living in a panel with its controls in
     a column beside it, so going "full screen" naively would have left every action behind.
     Everything the map needs comes WITH it: the objective banner, resume-travel, fast
     travel, engage/descend and the legend all ride on top of the map as its own HUD.

     The app header is deliberately LEFT VISIBLE and the map starts below it (--fsTop is
     measured at toggle time). That header carries HP/MP/SP, the collapse countdown and the
     floor — a full-screen map that hides your health bar to show more corridor is a worse
     instrument, not a better one, and re-rendering those vitals into an overlay would mean
     two copies of the same state to keep in sync.

     #map-actions and the Actions panel become translucent overlays anchored to .mapframe,
     which is why it needs position:relative here. .mapview keeps its overflow:hidden (it is
     what clips the panning SVG), so the overlays are its SIBLINGS, not its children. */
  body.mapfs, body.mapfs html { overflow:hidden; }
  html:has(body.mapfs) { overflow:hidden; }
  /* SPECIFICITY, the hard way: the desktop layout rule is `#game > #tab-dungeon` — TWO ids —
     so `body.mapfs #tab-dungeon` (one id + a class) loses to it and the tab stayed a 2-column
     grid while claiming to be full screen. Measured mid-build: display still `grid`, columns
     still `2074px 460px`. These selectors match that shape so they actually win. */
  body.mapfs #game > #tab-dungeon { position:fixed; left:0; right:0; top:var(--fsTop,52px); bottom:0;
    z-index:700; display:block; padding:0; margin:0; background:#05080a; overflow:hidden; }
  body.mapfs #game > #tab-dungeon > .panel { margin:0; border:0; border-radius:0; background:transparent; padding:0; }
  body.mapfs #game > #tab-dungeon > .panel.full { height:100%; }
  body.mapfs #game > #tab-dungeon h2 { display:none; }
  /* SPECIFICITY, AGAIN (#72). Same trap as #tab-dungeon above, one level down: the desktop
     rule is `#game > #tab-dungeon .mapview` — TWO ids — so `body.mapfs .mapview` (two
     classes) LOST, the map kept its condensed `height:min(66vh,720px)` cap in full screen,
     and the bottom third of the screen stayed empty ("full screen viewer is still not
     extending to the bottom of the window"). These selectors match that shape so they win. */
  body.mapfs #game > #tab-dungeon .mapframe { position:relative; height:100%; border:0; border-radius:0; }
  body.mapfs #game > #tab-dungeon .mapview { height:100%; border-radius:0; }
  body.mapfs .mapframe { position:relative; height:100%; border:0; border-radius:0; }
  body.mapfs .mapview { height:100%; border-radius:0; }
  body.mapfs #map-svg { height:100% !important; touch-action:none; }
  /* The overlays. Scrollable and height-capped so a long fast-travel list or a boss's
     action stack can never push its own buttons off the bottom of the screen. */
  /* #map-actions HAS TWO HOMES. deskDungeonRail() MOVES it (and the non-hero panels) into
     #dungrail above 1500px, and puts it back inside the map frame below that — so an
     overlay written against one parent silently does nothing in the other layout. Measured
     mid-build: the rule matched nothing and the actions rendered 1,271px down the page,
     off-screen. Both containers get the same treatment.
     The rail is absolute against #tab-dungeon; the in-frame copy against .mapframe. */
  body.mapfs #game > #tab-dungeon > #dungrail,
  body.mapfs .mapframe > #map-actions { position:absolute; right:10px; top:10px; z-index:6;
    width:min(320px,32vw); max-height:calc(100% - 20px); overflow:auto;
    background:rgba(5,9,12,.82); border:1px solid var(--line); border-radius:10px;
    padding:8px 10px; backdrop-filter:blur(4px); }
  /* inside the rail the children are already boxed, so they lose their own chrome */
  body.mapfs #game > #tab-dungeon > #dungrail > #map-actions,
  body.mapfs #game > #tab-dungeon > #dungrail > .panel { border:0; background:transparent; padding:0; margin:0; }
  body.mapfs .mapframe > #map-legend { position:absolute; left:10px; bottom:10px; z-index:6;
    max-width:min(560px,60vw); background:rgba(5,9,12,.82); border:1px solid var(--line);
    border-radius:9px; padding:5px 9px; backdrop-filter:blur(4px); }
  body.mapfs #game > #tab-dungeon > .panel:not(.full) { position:absolute; left:10px; top:10px; z-index:6;
    /* only reached in the narrow layout — above 1500px these live in #dungrail instead */
    background:rgba(5,9,12,.82); border:1px solid var(--line); border-radius:10px;
    padding:6px 9px; backdrop-filter:blur(4px); max-width:min(420px,46vw); }
  body.mapfs #game > #tab-dungeon > .panel:not(.full):not(:has(button:not([style*="display:none"]))) { display:none; }
  /* the zoom cluster sits above the legend so they never collide in a short window */
  /* Base anchoring lives here, NOT inline: an inline `bottom` outranks every rule below
     and left the cluster with both top and bottom resolved, stretching it to full map
     height. */
  #map-zoombtns { right:6px; bottom:6px; }
  body.mapfs #map-zoombtns { bottom:10px; right:10px; z-index:7; }
  #map-fs { position:absolute; right:6px; top:6px; z-index:7; }
  body.mapfs #map-fs { display:none; }
  /* Fail-safe exit for full-screen map (mobile has no Esc). #map-fs above is a
     DESCENDANT of #tab-dungeon, which body.mapfs promotes to its own stacking context
     (position:fixed + z-index:700) -- and #combat/#tavern/#market/#compare are lifted
     to z:760+ in mapfs, deliberately, ABOVE that whole context (so a fight isn't
     hidden behind the map). That containment means no z-index on #map-fs itself can
     ever put it back on top of those overlays. #map-fs-exit lives outside #tab-dungeon
     (a sibling of #combat et al, see markup) so it escapes that cap entirely -- pinned
     above everything, including #toasts (785), so it is ALWAYS reachable in one tap.
     It hangs just BELOW the header, not at the top of the viewport. Both it and the collapse
     HUD are centred, so a viewport-centred pill lands straight on the countdown: measured on
     prod at 2550px it covered "⏸ SAF" of the status line and the leading digit of 01:07:19.
     Since the header is kept visible precisely to carry that countdown (see --fsTop above),
     the pill drops into the map area instead, where nothing else is centred. */
  #map-fs-exit { display:none; }
  body.mapfs:has(#combat:not(.hide)) #map-fs-exit, body.in-combat #map-fs-exit { display:none !important; }
  body.mapfs #map-fs-exit { display:block; position:fixed; left:50%; transform:translateX(-50%);
    top:calc(var(--fsTop,52px) + 10px); z-index:900; padding:4px 16px;
    background:#1a0606; border:1px solid var(--red); color:#fff; font-weight:800;
    font-size:calc(12px*var(--tscale)); letter-spacing:.3px; border-radius:20px;
    box-shadow:0 2px 12px rgba(0,0,0,.6); }
  /* The full-screen map tab sits at z-700, but the true full-screen overlays default far
     below it (#combat 60, tavern/market 62, compare 66, tooltip 120, toasts 130) — so a
     fight started while the map is full-screen (auto-engage fires constantly) rendered
     BEHIND the map and never showed. Lift them above the map in full-screen only, keeping
     their normal order (modals < tooltip < toasts). Scoped to body.mapfs so ordinary
     stacking is untouched. */
  body.mapfs #combat, body.mapfs #tavern, body.mapfs #market, body.mapfs #compare { z-index:760; }
  body.mapfs #cb-tt { z-index:775; }
  /* #72: the shift-to-compare hover tip is appended to <body> at z-index 70, so over a
     full-screen map (700) — and over the Market sheet lifted to 760 — it rendered BEHIND
     both and Shift read as dead. Same lift the combat tooltip gets, one step higher. */
  body.mapfs .wtip { z-index:780; }
  body.mapfs #toasts { z-index:785; }
  /* #hero-tile isn't a .panel and #tab-dungeon goes display:block in mapfs (see above), so
     it stacks ABOVE the map in normal flow and steals height that never comes back --
     full screen means the map; the HUD header still carries HP/MP/the clock. */
  body.mapfs #hero-tile, body.mapfs #btn-daily, body.mapfs #btn-food, body.mapfs #t-daily, body.mapfs #safe-hint { display:none !important; }
  body.mapfs .hdr-side-l { flex-wrap:wrap; }
  /* Phone: action rail collapsed by default, toggle handle at the bottom. */
  #mapfs-rail-toggle { display:none; }
  /* The primary-action pill. Full-screen was meant to be the view you navigate in, but every
     action lived inside the rail, so acting meant giving 46% of the screen straight back —
     the map still did not dominate at the one moment it mattered. This floats the single
     action the current tile offers (ENGAGE, DESCEND, ENTER SAFE ROOM...) over the map, so the
     rail is only needed for the genuinely multi-option cases (fast-travel, field powers). */
  #mapfs-primary { display:none; }
  @media(max-width:760px){
    body.mapfs #mapfs-primary:not(:empty) { display:block; position:absolute; left:8px;
      right:8px; bottom:42px; z-index:7; }
    /* Rail open: the same button is visible inside it, so drop the duplicate. */
    body.mapfs.mapfs-rail-open #mapfs-primary { display:none; }
    body.mapfs #mapfs-primary button { width:100%; margin:0; padding:9px 12px;
      background:#0c0f12ee; backdrop-filter:blur(2px); box-shadow:0 2px 14px #000a; }
    body.mapfs #game > #tab-dungeon > #dungrail,
    body.mapfs .mapframe > #map-actions { right:8px; left:8px; top:auto; bottom:40px; width:auto;
      max-height:0; overflow:hidden; padding-top:0; padding-bottom:0;
      transition:max-height .25s ease, padding .2s ease; }
    body.mapfs.mapfs-rail-open #game > #tab-dungeon > #dungrail,
    body.mapfs.mapfs-rail-open .mapframe > #map-actions { max-height:46%; overflow-y:auto;
      padding-top:6px; padding-bottom:8px; }
    body.mapfs #mapfs-rail-toggle { display:block; position:absolute; bottom:8px; left:50%;
      transform:translateX(-50%); z-index:7; padding:4px 18px; margin:0;
      font-size:calc(11px*var(--tscale)); letter-spacing:.5px; border-radius:14px 14px 0 0;
      background:#0c0f12dd; border:1px solid var(--line); border-bottom:0; }
    body.mapfs .mapframe > #map-legend { display:none; }
    body.mapfs #map-zoombtns { bottom:auto; top:10px; right:10px; }
    body.mapfs #map-fs { display:none; }
  }
  /* first-person corridor view (BETA) */
  .fpnav{ position:absolute; z-index:6; display:flex; flex-direction:column; align-items:center; justify-content:center;
    min-width:calc(46px*var(--tscale)); height:calc(46px*var(--tscale)); padding:0 8px; margin:0;
    border:1.5px solid #6a4a2c; border-radius:11px; background:#170d19cc; color:#ffce8a; font-size:calc(17px*var(--tscale));
    box-shadow:0 0 12px #ffb44a1f; backdrop-filter:blur(2px); cursor:pointer; line-height:1; pointer-events:auto }
  .fpnav small{ font-size:calc(10px*var(--tscale)); letter-spacing:.04em; color:#c8b48f; margin-top:3px; max-width:98px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
  @media (hover:hover) { .fpnav:hover{ border-color:#ffb44a } }
  .fpmini{ position:absolute; left:8px; top:8px; z-index:6; width:calc(96px*var(--tscale)); background:#0c060ee6;
    border:1px solid #3a2e22; border-radius:9px; padding:6px }
  .fpmh{ font:700 calc(10px*var(--tscale))/1 ui-monospace,Menlo,monospace; letter-spacing:.1em; color:#ffb44a; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
  .fpgrid{ display:grid; grid-template-columns:repeat(9,1fr); gap:1.5px }
  .fpgrid i{ aspect-ratio:1; border-radius:1px; background:#150c17 }
  .fpgrid i.seen{ background:#3a2440 } .fpgrid i.p{ background:#ffb44a; box-shadow:0 0 5px #ffb44a }
  .fpgrid i.s{ background:#5adcf5 } .fpgrid i.foe{ background:#8a3030 } .fpgrid i.w{ background:#120a13 }
  .fpbanner{ position:absolute; left:0; right:0; top:calc(96px*var(--tscale)); z-index:6; text-align:center;
    font-size:calc(11px*var(--tscale)); color:#c8b48f; text-shadow:0 2px 6px #000; pointer-events:none; padding:0 12px }
  .fpbanner b{ color:#ff8f6a; letter-spacing:.08em }
  .fpfoe{ position:absolute; left:0; right:0; bottom:calc(64px*var(--tscale)); z-index:6; text-align:center;
    font:800 calc(13px*var(--tscale))/1.1 ui-sans-serif; color:#f3ecdf; text-shadow:0 2px 8px #000; pointer-events:none }
  /* Inside the frame the legend/actions need their own gutter — they used to get it from
     the panel's margin, which they no longer sit in. */
  .mapframe > #map-legend { padding:6px 8px 0; }
  .mapframe > #map-actions:not(:empty) { padding:6px 8px 8px; }
  /* Nothing to do here = no dead strip of border. */
  .mapframe > #map-actions:empty { display:none; }
  #map-svg, #map-svg *, #map-legend { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
  /* Embedded map touch-action:pan-y (was :none): a mostly-VERTICAL one-finger swipe starting on the
     map now scrolls the PAGE — with :none, a thumb landing on the 300px full-width map
     near the top of CRAWL couldn't scroll at all. Horizontal-first drags are ones the
     browser can't claim under pan-y, so setupMapPan's pointermove keeps firing and the
     map still pans (incl. any vertical component once the drag is captured). When the
     browser does take a vertical scroll it fires pointercancel, which the pan code
     already treats as drag-end. The map has no touch pinch handlers (zoom = wheel and
     the +/− buttons), and pan-y does NOT include pinch-zoom, so pinch on the map can't
     become a page-zoom either. Desktop mouse/wheel are unaffected by touch-action. */
  /* height lives here, NOT inline: an inline style beats the desktop rule below and
     pinned the map to 300px, which on a 2000px-wide panel is a 6.7:1 letterbox for a
     square dungeon -- you could not zoom out far enough to see the floor. */
  /* 300 -> 230 (2026-08-02): condensed so the map doesn't own the phone screen. The
     full-screen ⛶ view is the one to navigate in; this one is a glance. */
  /* will-change: the svg is its own compositor layer, so a drag/pinch/wheel moves it with a
     CSS transform and no layout (map-feel #02); overflow:visible shows the pre-drawn cull
     margin while it slides (.mapview clips). */
  #map-svg { -webkit-tap-highlight-color:transparent; touch-action:pan-y; height:180px; will-change:transform; overflow:visible; }

  /* ============================ CYBER-HACKER THEME ============================
     HUD panels, glowing neon borders, terminal headers, segmented bars.
     Per-section accent via data-accent on a .panel (defaults to neon green, the
     house colour). Corners are SQUARE — the angled chamfer was removed 2026-07-11. */
  /* PERF: the theme's grid-line + nebula backdrop moved onto the position:fixed
     body::after starfield (see top of file) — `background-attachment:fixed` here was
     an Android scroll-jank trigger. body stays a flat colour. */
  .panel {
    /* NOTE: color-mix(in srgb, ...) doesn't resolve consistently on every real device's
       browser (some silently fail the whole declaration and fall back to something
       opaque) — so every "N% of the accent, mixed to transparent" value here is a
       PRECOMPUTED literal rgba(), not a color-mix() call, for guaranteed rendering. */
    --accent: var(--neon);
    --accent-soft: rgba(57,255,20,.62); --accent-glow: rgba(57,255,20,.38);
    --accent-faint: rgba(57,255,20,.28); --accent-tglow: rgba(57,255,20,.55);
    --accent-wash: rgba(57,255,20,.085);
    border-radius:0; border:1px solid var(--accent-soft);
    /* Flat OPAQUE fill (#10182a) to MATCH the native app's cards — native killed the
       frosted glass on-device (it banded/seamed) and went flat; web follows so the two
       clients read as the same app. Angled-corner chamfer REMOVED 2026-07-11 (user
       call) — square corners, continuous border, plain box-shadow glow.
       The accent wash tints the WHOLE card toward its section colour, edge to edge —
       two identical stops, i.e. a flat overlay, not a fade. It was a 157deg gradient
       falling to transparent at 68%, which only coloured one corner and left the rest
       plain navy; the ask was for the entire background to carry the border's colour,
       so the fade is gone (user call, 2026-07-16).

       Still a LAYER OVER the opaque literal in the same shorthand, never a transparency
       on the element — the fill stays 100% opaque, so the grey seam the flat pass was
       chasing has nothing to seam against. That part is load-bearing; don't "simplify"
       it to an rgba background-color on .panel.

       The old note here said to keep the wash under ~.10 or it "starts reading as a
       coloured card" — that threshold was calibrated for the corner fade, where .085
       peaked in one spot and averaged far less. A flat wash carries the full value
       across the card, so it deliberately reads as a tinted card now. Retune by eye,
       not against that number. */
    background:linear-gradient(var(--accent-wash, transparent), var(--accent-wash, transparent)), #10182a;
    box-shadow:0 0 7px var(--accent-glow);
    position:relative;
  }
  /* The MAP panel matches its own frame (#06080a, the same value .mapframe and the SVG
     use) instead of the theme slab above, so the black-bordered map does not sit on a
     lighter card. Scoped to that one tab — .panel is every card in the app.
     Lives HERE, at global scope beside the rule it overrides, and NOT in the ≥1080px
     block where it was first written: inside that breakpoint it only ever applied on
     desktop, so on a phone the grey slab this is meant to remove was still there.
     Verified live at 390px and 1280px. */
  #game > #tab-dungeon > .panel { background:#06080a; }
  .panel[data-accent="amber"]  { --accent:var(--amber);  --accent-soft:rgba(255,183,51,.62);  --accent-glow:rgba(255,183,51,.38);  --accent-faint:rgba(255,183,51,.28);  --accent-tglow:rgba(255,183,51,.55); --accent-wash:rgba(255,183,51,.085); }
  .panel[data-accent="cyan"]   { --accent:var(--cyan);   --accent-soft:rgba(52,219,242,.62);  --accent-glow:rgba(52,219,242,.38);  --accent-faint:rgba(52,219,242,.28);  --accent-tglow:rgba(52,219,242,.55); --accent-wash:rgba(52,219,242,.085); }
  .panel[data-accent="red"]    { --accent:var(--red);    --accent-soft:rgba(255,77,77,.62);   --accent-glow:rgba(255,77,77,.38);   --accent-faint:rgba(255,77,77,.28);   --accent-tglow:rgba(255,77,77,.55); --accent-wash:rgba(255,77,77,.085); }
  .panel[data-accent="violet"] { --accent:var(--violet); --accent-soft:rgba(169,107,255,.62); --accent-glow:rgba(169,107,255,.38); --accent-faint:rgba(169,107,255,.28); --accent-tglow:rgba(169,107,255,.55); --accent-wash:rgba(169,107,255,.085); }
  .panel[data-accent="pink"]   { --accent:var(--pink);   --accent-soft:rgba(255,43,214,.62);  --accent-glow:rgba(255,43,214,.38);  --accent-faint:rgba(255,43,214,.28);  --accent-tglow:rgba(255,43,214,.55); --accent-wash:rgba(255,43,214,.085); }
  .panel[data-accent="teal"]   { --accent:var(--teal);   --accent-soft:rgba(0,212,184,.62);   --accent-glow:rgba(0,212,184,.38);   --accent-faint:rgba(0,212,184,.28);   --accent-tglow:rgba(0,212,184,.55); --accent-wash:rgba(0,212,184,.085); }
  /* TAB-ACCENT CARDS: each tab's un-accented panels border to that tab's nav colour
     (the canonical --tc hexes above, NOT the theme palette) so a tab reads as one hue.
     :not([data-accent]) preserves deliberate semantic panels (e.g. Overview Season/Standings).
     Dashboard is skipped (default green already == its tab), and SOCIAL is EXEMPT (no rule) —
     its panels keep their own per-section accents. Precomputed rgba, never color-mix. */
  #tab-crawler .panel:not([data-accent]) { --accent:#22d3ee; --accent-soft:rgba(34,211,238,.62); --accent-glow:rgba(34,211,238,.38); --accent-faint:rgba(34,211,238,.28); --accent-tglow:rgba(34,211,238,.55); --accent-wash:rgba(34,211,238,.085); }
  #tab-party   .panel:not([data-accent]) { --accent:#b07cff; --accent-soft:rgba(176,124,255,.62);--accent-glow:rgba(176,124,255,.38);--accent-faint:rgba(176,124,255,.28);--accent-tglow:rgba(176,124,255,.55); --accent-wash:rgba(176,124,255,.085); }
  #tab-dungeon .panel:not([data-accent]) { --accent:#ffc23a; --accent-soft:rgba(255,194,58,.62); --accent-glow:rgba(255,194,58,.38); --accent-faint:rgba(255,194,58,.28); --accent-tglow:rgba(255,194,58,.55); --accent-wash:rgba(255,194,58,.085); }
  #tab-bags    .panel:not([data-accent]) { --accent:#ff5db1; --accent-soft:rgba(255,93,177,.62); --accent-glow:rgba(255,93,177,.38); --accent-faint:rgba(255,93,177,.28); --accent-tglow:rgba(255,93,177,.55); --accent-wash:rgba(255,93,177,.085); }
  #tab-hub     .panel:not([data-accent]) { --accent:#ff8a3c; --accent-soft:rgba(255,138,60,.62); --accent-glow:rgba(255,138,60,.38); --accent-faint:rgba(255,138,60,.28); --accent-tglow:rgba(255,138,60,.55); --accent-wash:rgba(255,138,60,.085); }
  /* (corner tick removed — cards read cleaner without it) */
  /* (chamfer corner-patch pseudo-elements removed with the chamfer — the 1px
     border is continuous again, no diagonal gaps to repaint) */
  /* terminal header: left-aligned "> HEADER_" with a blinking cursor */
  .panel h2 {
    text-align:left; color:var(--accent); padding-left:2px; padding-bottom:7px;
    border-bottom:1px solid var(--accent-faint);
    text-shadow:0 0 10px var(--accent-tglow);
  }
  .panel h2::before { content:"> "; opacity:.85; }
  /* Card-header icons dropped (user call) — the "> TITLE" terminal chrome carries
     the section. Only inside h2; nav/button/legend icons are untouched. */
  h2 .uic { display:none; }
  .panel h2::after { content:none; }  /* trailing "_" cursor removed */
  @keyframes cyBlink { 0%,55%{opacity:1} 56%,100%{opacity:0} }
  @keyframes beaconPulse { 0%,100%{opacity:1} 50%{opacity:.4} }   /* already opacity-only — compositor-friendly as-is */
  .beacon { color:var(--amber); font-weight:800; animation:beaconPulse 1s infinite; }
  /* onboarding uses a flex head row — keep it clean (no underline/cursor break) */
  .ob-head h2 { border-bottom:none; }
  .ob-head h2::after { content:none; }
  /* segmented "LED" fill on every bar (HP/MP/SP/XP + table bars) */
  .hbar .hbt, .dtbl .tbar, .bar {
    border-radius:0; border-color:rgba(57,255,20,.42);
    box-shadow:0 0 5px rgba(57,255,20,.14);
  }
  .hbar .hbt > i, .dtbl .tbar > i, .bar > i {
    background-image:repeating-linear-gradient(90deg, rgba(0,0,0,.5) 0 1.5px, transparent 1.5px 5px);
  }
  /* buttons (never the nav tabs, never the bare glyph chrome): sharp, uppercase, neon
     glow (chamfer removed). .hud-tog is excluded so the gear/HUD-toggle glyphs read as
     chrome — this rule's specificity (0,2,1) otherwise beats `.hud-tog{border:none}`
     (0,1,0) and re-painted the green box the toggle was meant to lose. */
  button:not([data-tab]):not(.ob-x):not(.hud-tog) {
    border-radius:0;
    border:1px solid rgba(57,255,20,.52);
    box-shadow:0 0 8px rgba(57,255,20,.18), inset 0 0 12px rgba(57,255,20,.05);
    text-transform:uppercase; letter-spacing:.6px;
  }
  /* recruit cards + sponsor banners: sharp, square (chamfer removed) */
  .rtcard, .sbanner {
    border-radius:0;
  }
  .rtcard .figbox { border-radius:0; }
  .dtbl .dtr { border-radius:0; }
  /* the compact sticky HUD stays a plain bar (no clip/glow that fights the pin) */
  #topbar { clip-path:none; filter:none; }

  /* ===== gear glow-up: rarity tiles + hero paper-doll ===== */
  /* Tiles set --rc (rarity hex) inline; these rules read it and stay inert when unset. */
  .itile,.dslot{position:relative;isolation:isolate}
  .itile img{transform:scale(1.06)}                 /* ease the crop so more art shows */
  .dslot img.itemico{transform:scale(1.06)}
  /* rarity glow ring painted OVER the tile art (inline box-shadow can't be extended, so use ::after) */
  .itile::after,.dslot:not(.empty)::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;z-index:2;
    box-shadow:inset 0 0 12px var(--rc-glow, transparent);
    background:radial-gradient(120% 85% at 50% 122%, var(--rc-bg, transparent), transparent 68%);}
  /* Legendary / Celestial: a slow diagonal sheen sweep.
     TRANSFORM-only, and it has to stay that way. This animated `background-position` over a
     260%-wide gradient, which is a main-thread property: every frame re-rasterised the
     gradient for EVERY rare tile on screen, so a bag with a row of legendaries stuttered and
     tore the band mid-sweep ("the shine on legendary clips/lags/glitches"). The band is now a
     narrow strip that is painted once and only moved, which the compositor does on the GPU and
     off the main thread — the cost stops scaling with how many rare tiles are visible.
     No clip is declared here because .itile and .dslot are both already overflow:hidden;
     if that ever changes, the strip will slide out past the tile edge. */
  .itile.rare::before,.dslot.rare::before{content:"";position:absolute;top:0;bottom:0;left:0;width:70%;
    pointer-events:none;z-index:3;
    /* no will-change here: the running transform animation promotes the layer by
       itself, and a 20-slot legendary bag was pinning 20 compositor layers */
    background:linear-gradient(115deg,transparent 25%, rgba(255,255,255,.30) 50%, transparent 75%);
    mix-blend-mode:screen;animation:tsheen 3.4s linear infinite}
  /* Offsets are percentages of the STRIP (70% of a tile), not the tile: -110% parks it fully
     off the left edge, 165% carries its trailing edge past the right. */
  @keyframes tsheen{0%{transform:translate3d(-110%,0,0)}100%{transform:translate3d(165%,0,0)}}
  @media (prefers-reduced-motion:reduce){ .itile.rare::before,.dslot.rare::before{animation:none} }
  .itile .q,.dslot .q{z-index:4}
  .dslot.empty{opacity:.55}                          /* recede empty slots so worn gear pops */

  /* hero paper-doll: rarity aura + broadcast scanlines + vignette + pedestal */
  /* frameless: no border, background, scanlines, vignette, or pedestal — just the model, big */
  @keyframes figfloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}

  /* ================= UX efficiency pass ================= */
  /* In-tab segmented sub-nav for the dense tabs (Overview / Social / Exchange) */
  .subnav { display:flex; flex-wrap:wrap; gap:6px; margin:0 0 12px; }
  .subnav button { margin:0; padding:6px 13px; font-size:calc(11px*var(--tscale)); letter-spacing:.4px; background:#0c1310;
     color:var(--dim); border:1px solid var(--line); border-radius:999px; transition:color .12s, border-color .12s, background .12s; }
  .subnav button.on { color:#06120a; background:var(--sc,var(--neon)); border-color:var(--sc,var(--neon));
     font-weight:700; box-shadow:0 0 10px var(--sc-glow, rgba(57,255,20,.45)); }
  @media (hover:hover) { .subnav button:not(.on):hover { color:var(--sc,var(--neon)); border-color:var(--sc,var(--neon)); } }
  /* Club membership hiding is a CLASS, not an inline display — the desktop breakpoint
     forces `> [data-sub] { display:block !important }` on every sub-panel, which would
     steamroll an inline `display:none` and show the Club card to non-members. Three ids
     of specificity so this wins that fight on both breakpoints. */
  #game #tab-social > #club-panel.noclub { display:none !important; }
  .social-net { display:flex; flex-wrap:wrap; gap:4px 12px; padding:8px 10px; margin-bottom:10px;
     background:var(--card); border:1px solid var(--line); border-radius:8px; font-size:calc(11px*var(--tscale)); }
  .social-net .sn-s { display:inline-flex; align-items:baseline; gap:4px; }
  .social-net .sn-k { color:var(--dim); letter-spacing:.8px; font-size:calc(9px*var(--tscale)); text-transform:uppercase; }
  .social-net .sn-v { font-weight:700; font-variant-numeric:tabular-nums; }

  /* Inventory as a responsive grid of art tiles (tap a tile -> detail sheet with actions) */
  .invgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(66px,1fr)); gap:9px; margin-top:4px; }
  .invgrid .itile { width:100%; height:auto; aspect-ratio:1/1; flex:none; }
  /* .ign (the tile's name caption) is GONE — name + stars came off the tile 2026-07-16.
     The art, the rarity border and the hover/tap sheet identify an item better than a
     10px caption that ellipsed to nothing at 64px anyway. */
  /* .igq now carries ONLY "✖ BROKEN" (the stars used to share it). Kept red + top-centre:
     broken gear is the one thing a tile still has to shout. */
  .invgrid .itile .igq { position:absolute; left:0; right:0; top:2px; z-index:4; text-align:center; font-size:calc(10px*var(--tscale));
     color:var(--title-gold); letter-spacing:-.5px; text-shadow:0 0 3px #000; }
  /* stack count on a consumable tile: bottom-right pill, reads at a glance */
  .invgrid .itile .istk { position:absolute; right:2px; bottom:2px; z-index:5; padding:0 4px; border-radius:7px;
     background:rgba(6,10,8,.9); border:1px solid var(--cyan); color:var(--cyan);
     font-size:calc(10px*var(--tscale)); font-weight:800; line-height:1.5; text-shadow:0 0 3px #000; }
  /* ▲ upgrade mark: this beats what you're wearing in that slot (invUpgrade()).
     Top-LEFT because ★ quality owns the top strip and ×N owns bottom-right — the only
     corner left, and it keeps all three readable on one tile. Neon, but small and
     unglowing: on a full bag of 20 the point is to scan for arrows, and a badge on every
     other tile would be the noise this was meant to cut through. */
  .invgrid .itile .iup { position:absolute; left:2px; top:1px; z-index:5; line-height:1;
     font-size:calc(11px*var(--tscale)); font-weight:900; color:var(--neon); text-shadow:0 0 4px #000,0 0 2px #000; }
  .invgrid .itile.isup { box-shadow:inset 0 0 8px rgba(0,0,0,.55), 0 0 0 1px rgba(57,255,20,.5) inset; }
  /* empty bag slot: a dim dashed placeholder so the bag always shows all slots */
  .itile.empty { border-style:dashed; border-color:var(--line); background:radial-gradient(circle at 50% 40%,#0c1116,#070a0d); cursor:default; }
  .itile.empty:hover { transform:none; filter:none; }

  /* Slim HUD: identity + vitals only; the econ stat row folds away (chevron toggle in header).
     The XP bottom-border and the condensed My Feed below the bar are permanent fixtures now —
     they don't fold with slim (see #topstick.slim .hud1 below). */
  /* Bare glyph controls — the border was the only one in the status bar; removed so
     the gear/HUD toggles read as chrome, not buttons. Hover = neon glow, no box. */
  .hud-tog { position:relative; flex:none; background:none; border:none; box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 -1px 0 rgba(255,255,255,.05); color:var(--dim); border-radius:6px;
     padding:0 3px; margin-right:3px; font-size:calc(15px*var(--tscale)); line-height:1; cursor:pointer;
     min-width:28px; min-height:28px; display:inline-flex; align-items:center; justify-content:center;
     transition:color .12s, text-shadow .12s, box-shadow .12s; }
  @media (hover:hover) { .hud-tog:hover { background:none; color:var(--neon); box-shadow:inset 0 1px 0 var(--neon), inset 0 -1px 0 var(--neon); text-shadow:0 0 6px rgba(57,255,20,.4); } }
  /* Unread badge on the header bell -- hidden entirely at zero rather than rendering "0". */
  .notif-badge{position:absolute;top:2px;right:1px;min-width:15px;height:15px;padding:0 3px;border-radius:8px;
     background:var(--red);color:#2a0505;font-size:calc(9px*var(--tscale));font-weight:800;line-height:15px;
     text-align:center;box-shadow:0 0 6px rgba(255,77,77,.65);pointer-events:none}
  /* Slim HUD: identity + vitals stay put; the econ stat row AND the 3-line statusfeed
     fold away. The feed used to be exempt ("standing fixture"), but the pinned chrome
     measured ~40-45% of a 667px phone screen — slim wasn't slim. Default (expanded)
     behaviour is unchanged; folding is the player's opt-in via the ▸ toggle. */
  #topstick.slim .hud1 { display:none; }
  #topstick.slim #statusfeed { display:none; }
  /* Notched-phone PWA (viewport-fit=cover): #topstick sits at top:0 OUTSIDE #game's
     safe-area padding, so the header/gear button drew under the iOS status bar. */
  #topstick { padding-top:env(safe-area-inset-top, 0px); }
  /* Compact phones: the 6-cell econ row wrapped to 2-3 lines AND grew mid-crawl when
     "+N risk" appeared — the sticky header reflowed under the player's thumb. One
     nowrap line with quiet horizontal scroll; the risk detail stays in the gold tooltip. */
  /* Phones (<=560px, covers big phones the old 430 breakpoint missed): the labelled chips
     were too wide — they wrapped to two tall rows. Drop the LABEL to a compact glyph+value
     chip so all six sit on one or two tidy rows; the label lives in the title tooltip. Also
     tighten the identity + SP/XP lines so the bar isn't half the screen. */
  @media (max-width:560px){
    header { padding:4px 8px; }
    h1 { font-size:calc(11px*var(--tscale)); }
    .hdr-side-l { flex-wrap:wrap; gap:0 6px; }
    .hdr-floorzone { margin-left:0; font-size:calc(10px*var(--tscale)); }
    .hdr-season { font-size:calc(8px*var(--tscale)); }
    .hdr-collapse b { font-size:calc(16px*var(--tscale)); }
    .echip { padding:4px 8px; gap:5px; }
    .echip .ec-l { display:none; }
    .echip b { font-size:calc(14px*var(--tscale)); }
    #topbar .hud1 { gap:6px; justify-content:center; }
    .riskchip { display:none; }
    /* Audience row: collapses from individual chips to a compact secondary line on phones.
       Border-top separates the primary resources (gold/power) from the audience counters. */
    .hud-aud { display:flex; flex-wrap:wrap; gap:3px 8px; width:100%; margin-top:2px;
      padding-top:4px; border-top:1px solid var(--line); }
    .hud-aud .echip { border:none; background:none; padding:0; gap:3px; border-radius:0; }
    .hud-aud .echip b { font-size:calc(11px*var(--tscale)); color:var(--sub); font-weight:600; }
    .hud-aud .echip .uic { width:12px; height:12px; }
    .hud-affil { gap:7px; font-size:calc(10px*var(--tscale)); }
    .hud-spx { font-size:calc(9px*var(--tscale)); }
    .hudmain { gap:8px; }
    .hud-right { align-items:stretch; width:100%; gap:2px; }
    .hbar { gap:4px; margin-top:1px; }
    .hbar .hbt { height:8px; }
    .hbar .hbv { width:auto; }
    .panel { padding:10px; }
    .panel h2 { margin:0 0 4px; padding-bottom:3px; }
  }

  /* On-brand UI iconography: white silhouette PNGs tinted to context colour via CSS mask.
     Use uic('name') in JS; the icon inherits the surrounding text colour (per-section accent). */
  .uic{ display:inline-block; width:1em; height:1em; vertical-align:-0.14em; background-color:currentColor;
     -webkit-mask:var(--ui) center/contain no-repeat; mask:var(--ui) center/contain no-repeat; }
  .uic.big{ width:1.2em; height:1.2em; vertical-align:-0.2em; }
  .uic-arena{--ui:url(/icons/ui/arena.png)} .uic-broadcast{--ui:url(/icons/ui/broadcast.png)}
  .uic-clapper{--ui:url(/icons/ui/clapper.png)} .uic-companion{--ui:url(/icons/ui/companion.png)}
  .uic-cosmetics{--ui:url(/icons/ui/cosmetics.png)} .uic-credits{--ui:url(/icons/ui/credits.png)}
  .uic-faction{--ui:url(/icons/ui/faction.png)} .uic-guild{--ui:url(/icons/ui/guild.png)}
  .uic-haggle{--ui:url(/icons/ui/haggle.png)} .uic-home{--ui:url(/icons/ui/home.png)}
  .uic-loot{--ui:url(/icons/ui/loot.png)} .uic-market{--ui:url(/icons/ui/market.png)}
  .uic-narrator{--ui:url(/icons/ui/narrator.png)} .uic-patch{--ui:url(/icons/ui/patch.png)}
  .uic-potion{--ui:url(/icons/ui/potion.png)} .uic-recruit{--ui:url(/icons/ui/recruit.png)}
  .uic-reforge{--ui:url(/icons/ui/reforge.png)} .uic-rest{--ui:url(/icons/ui/rest.png)}
  .uic-safe{--ui:url(/icons/ui/safe.png)}
  .uic-scroll{--ui:url(/icons/ui/scroll.png)}   /* (.uic-salvage removed — Salvage retired 2026-07-01) */
  .uic-season{--ui:url(/icons/ui/season.png)} .uic-shop{--ui:url(/icons/ui/shop.png)}
  .uic-skull{--ui:url(/icons/ui/skull.png)} .uic-sponsor{--ui:url(/icons/ui/sponsor.png)}
  .uic-ticker{--ui:url(/icons/ui/ticker.png)}
  /* ?v=2: the first-cut generic versions were briefly live — bust their cache */
  .uic-fight{--ui:url(/icons/ui/fight.png?v=2)} .uic-armor{--ui:url(/icons/ui/armor.png?v=2)}
  .uic-jewelry{--ui:url(/icons/ui/jewelry.png?v=2)} .uic-sell{--ui:url(/icons/ui/sell.png?v=2)}
  .uic-trophy{--ui:url(/icons/ui/trophy.png?v=2)} .uic-timer{--ui:url(/icons/ui/timer.png?v=2)}
  .uic-collapse{--ui:url(/icons/ui/collapse.png?v=2)}
  .uic-hp{--ui:url(/icons/ui/hp.png)} .uic-mp{--ui:url(/icons/ui/mp.png)}
  .uic-sp{--ui:url(/icons/ui/sp.png)} .uic-power{--ui:url(/icons/ui/power.png)}
  .uic-gold{--ui:url(/icons/ui/gold.png)} .uic-views{--ui:url(/icons/ui/views.png)}
  .uic-followers{--ui:url(/icons/ui/followers.png)} .uic-subs{--ui:url(/icons/ui/subs.png)}

  /* Getting Started: collapsed one-line progress chip once the crawler is underway */
  .ob-chip { display:flex; align-items:center; gap:9px; cursor:pointer; }
  .ob-chip .obbar { flex:1; height:7px; background:#0a0f0c; border:1px solid var(--line); border-radius:5px; overflow:hidden; }
  .ob-chip .obbar > i { display:block; height:100%; background:var(--neon); box-shadow:0 0 7px #39ff1466; transition:width .3s; }
  .ob-chip .obpct { font-size:calc(11px*var(--tscale)); color:var(--neon); font-weight:700; flex:none; }
  .ob-chip .obnx { font-size:calc(11px*var(--tscale)); color:var(--dim); flex:none; }

  /* Settings sheet: borderless, compact option chips (selected = .on) */
  .setrow { display:flex; gap:5px; margin:6px 0 12px; flex-wrap:wrap; }
  .setrow button { border:none !important; clip-path:none !important; box-shadow:none !important; margin:0;
     padding:4px 11px; font-size:calc(11px*var(--tscale)); background:rgba(255,255,255,.055); color:var(--dim); border-radius:6px; }
  .setrow button.on { background:rgba(57,255,20,.16); color:var(--neon); }
  .setrow button.on.danger { background:rgba(255,46,77,.16); color:#ff8a8a; }
  .setrow input { flex:1; min-width:0; padding:4px 9px; font-size:calc(12px*var(--tscale)); }
  @media (max-width:400px){ .statgrid{grid-template-columns:repeat(3,1fr)} }
  @media (max-width:400px){ .cb-schools{grid-template-columns:repeat(2,1fr)} }
  @media (max-width:360px){ .cmp-grid{grid-template-columns:1fr} }
  /* --- Bestiary --- */
  .bst-unmet{opacity:.45}
  .bst-sil{filter:brightness(0) saturate(0);opacity:.25}
  .bst-weak{padding:1px 6px;border-radius:3px;font-size:calc(10px*var(--tscale));font-weight:600}
  .lr-row{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--line);min-height:52px}
  .lr-row:last-child{border-bottom:none}
  .lr-row[onclick]{cursor:pointer}
  .lr-icon{width:40px;height:40px;flex:0 0 40px;position:relative;border-radius:var(--radius-sm);border:1px solid var(--line);overflow:hidden;display:flex;align-items:center;justify-content:center}
  .lr-icon img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}
  .lr-body{flex:1;min-width:0}
  .lr-name{font-weight:700;font-size:var(--fs-base);color:var(--txt)}
  .lr-meta{display:block;font-size:var(--fs-sm);color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .lr-bar{height:4px;border-radius:2px;background:var(--bar-track);overflow:hidden;margin-top:3px}
  .lr-bar>div{height:100%}
  .lr-fig{flex:none;display:flex;align-items:center;gap:3px;font-size:var(--fs-md);color:var(--txt)}
  .lr-badge{position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;border-radius:var(--radius-pill);background:var(--neon);color:var(--bg);font-size:var(--fs-tiny);font-weight:700;text-align:center;line-height:16px;padding:0 2px}
  .lr-chev{display:inline-block;transition:transform .15s}
  .lr-row.lr-exp .lr-chev{transform:rotate(90deg)}
  .lr-detail{padding:2px 0 6px 48px}
  .lr-seg{display:flex;gap:2px;background:var(--inset);border-radius:var(--radius-pill);padding:2px;margin-bottom:12px}
  .lr-seg button{flex:1;padding:5px 0;border:none;border-radius:var(--radius-pill);background:transparent;color:var(--dim);font-family:inherit;font-size:var(--fs-sm);font-weight:700;letter-spacing:.5px;text-transform:uppercase;cursor:pointer;transition:background .12s,color .12s}
  .lr-seg button.on{background:var(--panel);color:var(--txt);box-shadow:var(--shadow-card)}
  /* --- Loadout bar --- */
  .lo-row{display:flex;gap:4px;margin-bottom:4px}
  .lo-row button:first-child{flex:1;text-align:left}
  .lo-del{padding:2px 8px !important;color:var(--red) !important;border-color:var(--red) !important}
  /* --- Zone local quest card --- */
  .lq-card{padding:8px 10px}
  .lq-head{display:flex;align-items:center;gap:10px;margin-bottom:6px}
  .lq-port{flex-shrink:0}
  .lq-who{flex:1;min-width:0}
  .lq-name{font-weight:700;font-size:calc(13px*var(--tscale))}
  .lq-title{font-size:calc(10px*var(--tscale));color:var(--dim)}
  .lq-beat{display:inline-block;padding:0 5px;border-radius:3px;font-size:calc(9px*var(--tscale));font-weight:700;letter-spacing:.5px;margin-left:6px;vertical-align:middle}
  .lq-line{font-style:italic;font-size:calc(11px*var(--tscale));color:var(--dim);margin-bottom:8px;line-height:1.45}
  .lq-task{font-size:calc(11px*var(--tscale));margin-bottom:4px}
  .lq-bar{height:6px;border-radius:3px;background:rgba(255,255,255,.08);overflow:hidden;margin:4px 0 8px}
  .lq-fill{height:100%;border-radius:3px;transition:width .3s}
  .lq-reward{font-size:calc(10px*var(--tscale));color:var(--dim);margin-bottom:6px}
  /* --- Hunt cards (timed expeditions) --- */
  .hunt-card{padding:8px 10px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px}
  .hunt-head{display:flex;align-items:center;gap:8px;margin-bottom:6px}
  .hunt-por{width:36px;height:36px;border-radius:6px;object-fit:cover;object-position:50% 14%;flex:none;background:#0a0f16}
  .hunt-info{flex:1;min-width:0}
  .hunt-name{font-weight:700;font-size:calc(11px*var(--tscale));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .hunt-target{font-size:calc(10px*var(--tscale));color:var(--dim)}
  .hunt-bar{height:6px;border-radius:3px;background:rgba(255,255,255,.08);overflow:hidden;margin:4px 0 6px}
  .hunt-fill{height:100%;border-radius:3px;transition:width .3s}
  .hunt-time{font-size:calc(10px*var(--tscale));color:var(--dim);display:flex;justify-content:space-between}
  .hunt-loot{font-size:calc(10px*var(--tscale));color:var(--dim);margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}
  .hunt-loot-tag{padding:1px 5px;border-radius:3px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08)}
  .hunt-dur{display:flex;gap:6px;margin:6px 0}
  .hunt-dur button{flex:1;padding:6px;font-size:calc(11px*var(--tscale))}
  .hunt-dur button.on{background:var(--neon);color:#06120a;border-color:var(--neon)}
  .hunt-fam-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid var(--line);border-radius:6px;margin-bottom:4px;cursor:pointer}
  .hunt-fam-row:hover{border-color:var(--neon)}
  .hunt-fam-row.on{border-color:var(--neon);background:rgba(57,255,20,.06)}
  .hunt-fam-ico{width:28px;height:28px;object-fit:contain;image-rendering:pixelated;flex:none}
  /* --- m-header + m-row: unified list grammar --- */
  .m-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;font-family:'Rajdhani',sans-serif;font-weight:700;font-size:calc(13px*var(--tscale));letter-spacing:2px;color:var(--neon);text-transform:uppercase;border-bottom:1px solid rgba(57,255,20,.13)}
  .m-header .right{font-size:calc(11px*var(--tscale));color:var(--sub);letter-spacing:1px;font-weight:400}
  .m-row{display:flex;align-items:center;gap:10px;padding:7px 10px;border-bottom:1px solid var(--line);min-height:40px}
  .m-row:last-child{border-bottom:none}
  .m-ico{width:32px;height:32px;border-radius:4px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:18px;background:rgba(255,255,255,.06)}
  .m-ico img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}
  .m-body{flex:1;min-width:0}
  .m-name{font-weight:600;font-size:calc(12px*var(--tscale));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .m-meta{font-size:calc(10px*var(--tscale));color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .m-fig{flex-shrink:0;text-align:right;font-weight:700;font-size:calc(14px*var(--tscale))}
  .m-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:calc(10px*var(--tscale));font-weight:600}
  /* --- Hero stage dashboard --- */
  #hero-stage{display:block}
  .stage{position:relative;min-height:200px;border-radius:6px;overflow:hidden;margin-bottom:12px;padding:16px;display:flex;flex-direction:column;align-items:center;justify-content:center}
  .stage-bg{position:absolute;inset:0;z-index:0;opacity:.7}
  .stage-overlay{position:absolute;inset:0;z-index:1;background:linear-gradient(180deg,rgba(0,0,0,.3) 0%,rgba(0,0,0,.55) 100%)}
  .stage-zone{position:relative;z-index:2;font-family:'Rajdhani',sans-serif;font-weight:700;font-size:calc(10px*var(--tscale));letter-spacing:3px;text-transform:uppercase;color:rgba(255,255,255,.7);margin-bottom:4px}
  .stage-floor{position:relative;z-index:2;font-family:'Rajdhani',sans-serif;font-weight:700;font-size:calc(28px*var(--tscale));color:#fff;letter-spacing:2px;margin-bottom:8px;text-shadow:0 2px 8px rgba(0,0,0,.5)}
  .stage-avatar{position:relative;z-index:2;width:64px;height:64px;border-radius:50%;border:2px solid var(--neon);object-fit:cover;box-shadow:0 0 12px rgba(57,255,20,.3);margin-bottom:10px}
  .stage-pills{position:relative;z-index:2;display:flex;gap:6px;flex-wrap:wrap;justify-content:center;margin-bottom:10px}
  .stage-pill{padding:3px 10px;border-radius:12px;font-family:'Rajdhani',sans-serif;font-weight:700;font-size:calc(11px*var(--tscale));letter-spacing:.5px;background:rgba(0,0,0,.5);border:1px solid rgba(255,255,255,.15);backdrop-filter:blur(4px);white-space:nowrap}
  .stage-cta{position:relative;z-index:2;display:flex;gap:8px;width:100%;max-width:320px}
  .stage-cta button{flex:1;font-family:'Rajdhani',sans-serif;font-weight:700;letter-spacing:1px;font-size:calc(11px*var(--tscale));padding:8px 12px}
  .dashhero{position:relative;min-height:320px;border-radius:8px;overflow:hidden;margin-bottom:12px;background:var(--bg)}
  .dashhero:empty{display:none}
  .dh-plate{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0;transition:opacity .3s}
  .dh-plate.loaded{opacity:1}
  .dh-vignette{position:absolute;left:0;right:0;bottom:0;height:40%;background:linear-gradient(180deg,rgba(18,21,29,0) 0%,rgba(18,21,29,.85) 100%);pointer-events:none}
  .dh-avatar-wrap{position:absolute;left:50%;bottom:84px;transform:translateX(-50%);z-index:2}
  .dh-avatar{display:block;width:96px;height:96px;border-radius:50%;border:2px solid var(--neon);object-fit:cover;box-shadow:0 0 14px rgba(57,255,20,.35)}
  .dh-pill{position:absolute;z-index:2;display:flex;align-items:center;gap:6px;height:32px;padding:4px 14px;border-radius:999px;background:rgba(28,38,55,.75);backdrop-filter:blur(12px);border:1px solid rgba(57,255,20,.2);font-family:'Rajdhani',sans-serif;font-weight:700;font-size:calc(11px*var(--tscale));letter-spacing:.5px;white-space:nowrap;color:var(--txt)}
  .dh-floor{top:12px;left:12px}
  .dh-gold{top:12px;right:12px;color:var(--title-gold)}
  .dh-hp{top:56px;left:12px}
  .dh-sp{top:94px;left:12px}
  .dh-bar{display:inline-block;width:60px;height:6px;border-radius:3px;background:rgba(255,255,255,.15);overflow:hidden;vertical-align:middle}
  .dh-bar i{display:block;height:100%;transition:width .4s ease-out}
  .dh-cta{position:absolute;z-index:2;left:16px;right:16px;bottom:16px;padding:14px 18px;border-radius:10px;background:var(--panel);border:1px solid var(--line);cursor:pointer;transition:border-color .2s,box-shadow .2s}
  .dh-cta.descend{border-color:var(--neon);box-shadow:0 0 12px rgba(57,255,20,.4)}
  .dh-cta-label{font-family:'Rajdhani',sans-serif;font-weight:700;font-size:calc(14px*var(--tscale));letter-spacing:2px;color:var(--sub)}
  .dh-cta.descend .dh-cta-label{color:var(--neon)}
  .dh-cta-sub{font-size:calc(10px*var(--tscale));color:var(--sub);margin-top:2px}
  @media (max-width:400px){ .dh-avatar{width:76px;height:76px} .dashhero{min-height:280px} }
  @media (prefers-reduced-motion:reduce){ .dh-plate,.dh-bar i,.dh-cta{transition:none} }
