/* Space Grotesk (variable wght 300–700, OFL — license alongside the file) is both
   the display and UI face; the stack below it is the no-font fallback. */
@font-face {
  font-family: 'Space Grotesk';
  src: url('/assets/fonts/space-grotesk-vf.woff2') format('woff2-variations');
  font-weight: 300 700;
  font-style: normal;
  font-display: swap;
}

:root {
  /* Dark-only chrome: tells the UA so native form controls / scrollbars (confirm
     inputs, number spinners) render dark instead of default light-on-dark. */
  color-scheme: dark;

  /* Design tokens — modern glass + glow over the dark + molten-amber identity.
     DOM chrome only; the in-game world (canvas) reads its palette from config.js. */

  /* surfaces */
  --bg-0: #0b0c10; /* page bg behind canvas / first paint */
  --bg-1: #12141a;
  --glass-1: rgba(16, 18, 24, 0.55);
  --glass-2: rgba(18, 20, 27, 0.72);
  --glass-3: rgba(21, 23, 31, 0.88);
  --stroke-1: rgba(255, 255, 255, 0.08);
  --stroke-2: rgba(255, 255, 255, 0.16);
  --stroke-accent: rgba(255, 138, 31, 0.45);
  --glass-hilite: inset 0 1px 0 rgba(255, 255, 255, 0.07);

  /* ink */
  --ink: #f2f5f8;
  --ink-dim: #a8b2c2;
  /* Faint hint ink: ≥4.5:1 on the dark glass surfaces (WCAG AA for the 11–13px
     hint text it carries; the old #6b7484 measured ~4:1). */
  --ink-faint: #77818f;

  /* brand */
  --accent: #ff8a1f;
  --accent-hi: #ffb347;
  --accent-deep: #b85600;
  --accent-08: rgba(255, 138, 31, 0.08);
  --accent-16: rgba(255, 138, 31, 0.16);
  --accent-32: rgba(255, 138, 31, 0.32);
  --glow-accent: 0 0 18px rgba(255, 138, 31, 0.35);
  --glow-accent-lg: 0 0 42px rgba(255, 138, 31, 0.45);

  /* functional */
  --info: #5b9cff;
  --info-hi: #9bd1ff;
  --gold: #ffd23f;
  --danger: #ff5b5b;
  --danger-hi: #ff8a5b;
  --success: #5fe06a;
  --success-soft: #b6f4c2;
  --danger-glow: 0 0 16px rgba(255, 91, 91, 0.4);
  --success-glow: 0 0 16px rgba(95, 224, 106, 0.35);
  --discord: #5865f2;
  --discord-hi: #7984f5;
  --discord-deep: #3b45c4;

  /* chat channel tints */
  --chan-global: #8fe3ff;
  --chan-local: #cfd8e3;
  --chan-party: #7fffb0;
  --chan-guild: #ff9f6b;
  --chan-whisper: #c79bff;
  --chan-system: #ffd23f;

  /* elevation */
  --elev-1: 0 4px 14px rgba(0, 0, 0, 0.35);
  --elev-2: 0 12px 32px rgba(0, 0, 0, 0.4);
  --elev-3: 0 24px 60px rgba(0, 0, 0, 0.5);
  --blur-1: 6px;
  --blur-2: 12px;
  --blur-3: 20px;

  /* radius */
  --r-xs: 6px;
  --r-sm: 10px;
  --r-md: 14px;
  --r-lg: 18px;
  --r-xl: 24px;
  --r-full: 999px;

  /* spacing */
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 24px;
  --sp-6: 32px;

  /* type */
  --font-display: 'Space Grotesk', ui-rounded, 'Segoe UI', system-ui, sans-serif;
  --font-ui: 'Space Grotesk', ui-rounded, 'Segoe UI', system-ui, -apple-system, sans-serif;
  --fs-xs: 12px;
  --fs-sm: 13px;
  --fs-md: 15px;
  --fs-lg: 18px;
  --fs-xl: 24px;
  --fs-hero: clamp(48px, 9vw, 100px);

  /* motion */
  --t-fast: 120ms;
  --t-med: 200ms;
  --t-slow: 320ms;
  --t-enter: 260ms;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-inout: cubic-bezier(0.65, 0, 0.35, 1);

  /* hero backdrop (start / dev / death overlays) */
  --bg-hero: radial-gradient(120% 120% at 50% 30%, #1d2230, #14161c 55%, #0d0e12);

  /* canvas world (read by render.js via getCss — not chrome styling) */
  --grid: rgba(255, 138, 31, 0.08); /* pre-map fallback field grid lines */
  --border: #3a3f4a; /* world boundary stroke */

  --btn-ink: #2a1500; /* dark text on bright amber buttons */

  /* diamond (the soft currency's cyan family — readouts, quest bars) */
  --diamond: #6fd3ff;
  --diamond-hi: #a5e4ff;
  --diamond-pale: #d9f4ff;

  /* on-color inks: text sitting ON a bright fill (--btn-ink stays for amber buttons) */
  --ink-hi: #ffffff;
  --ink-on-gold: #1a1206;
  --ink-on-quality: #10131a;
  --ink-on-success: #06210f;

  /* brand alpha steps (static rgba, the --accent-08/16/32 precedent — an
     unsupported color-mix() would drop the whole declaration, these can't) */
  --gold-12: rgba(255, 210, 63, 0.12);
  --gold-25: rgba(255, 210, 63, 0.25);
  --gold-50: rgba(255, 210, 63, 0.5);
  --glow-gold: 0 0 14px rgba(255, 210, 63, 0.25);
  --success-10: rgba(95, 224, 106, 0.1);
  --success-16: rgba(95, 224, 106, 0.16);
  --success-28: rgba(95, 224, 106, 0.28);
  --glow-discord: 0 0 18px rgba(88, 101, 242, 0.45);

  --fs-2xs: 11px; /* the most-used micro size (~35 declarations) */

  /* z bands — every expression below maps to the EXACT integer used before the
     migration, so stacking is bit-for-bit unchanged. Audit (selector → value):
       5  #verTag                                   → --z-under
       20 #hud, #sfxMute                            → --z-hud
       21 #loadoutBar                               → calc(--z-hud + 1)
       22 #chatPanel                                → calc(--z-hud + 2)
       24 #touchControls                            → calc(--z-hud + 4)
       25 #dead                                     → --z-dead
       27 #menuDock                                 → --z-dock
       28 #menuLayer                                → --z-menu
       29 #loadoutBar.lifted, #slotPicker           → calc(--z-menu + 1) / --z-pick
          (the tie is resolved by DOM order — #slotPicker is declared later in index.html)
       30 #start, #mini.fullscreen, #placeModal/#shopConfirmModal/#confirmModal → --z-modal
          (the 30-ties stay ties on purpose: DOM order decides, as before)
       31 #devModal                                 → calc(--z-modal + 1)
       32 #nameModal                                → calc(--z-modal + 2)
       33 #kickedModal, #mergeModal, #reconnectOverlay → calc(--z-modal + 3)
       34 #screenFlash                              → --z-fx
       35 #toast, #achBanner                        → --z-toast
       36 #itemTip                                  → --z-tip
       40 #conn, #challenge                         → --z-top
       41 #perfOverlay                              → calc(--z-top + 1) */
  --z-under: 5;
  --z-hud: 20;
  --z-dead: 25;
  --z-dock: 27;
  --z-menu: 28;
  --z-pick: 29;
  --z-modal: 30;
  --z-fx: 34;
  --z-toast: 35;
  --z-tip: 36;
  --z-top: 40;
}

* {
  box-sizing: border-box;
}
html,
body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  background: var(--bg-0);
  font-family: var(--font-ui);
  color: var(--ink);
  -webkit-user-select: none;
  user-select: none;
  touch-action: none;
  /* No pull-to-refresh / rubber-band scroll to compete with touch controls. */
  overscroll-behavior: none;
}
canvas {
  display: block;
  position: fixed;
  inset: 0;
}

/* Keyboard focus is always visible; pointer focus stays quiet. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-xs);
}
button,
a,
input,
select {
  outline: none;
}

/* ---- dev-build disclaimer (sits above the start overlay) ---- */
#devModal {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-modal) + 1);
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-hero);
}
#devModal h2 {
  margin: 0; /* full-width header above the .cardCols split (was an inline style) */
}
/* Discord link in the dev gate — secondary button, blurple so it reads as
   a distinct link rather than the primary "I understand" CTA. */
#devDiscord {
  font: inherit;
  font-weight: 700;
  font-size: 17px;
  text-align: center;
  text-decoration: none;
  padding: 12px;
  border-radius: var(--r-md);
  color: var(--ink);
  background: linear-gradient(180deg, var(--discord-hi), var(--discord));
  box-shadow: var(--elev-1), var(--glass-hilite);
  transition:
    transform var(--t-fast) var(--ease-out),
    box-shadow var(--t-fast) var(--ease-out);
}
#devDiscord:hover {
  transform: translateY(-1px);
  box-shadow: var(--elev-1), var(--glow-discord);
}
#devDiscord:active {
  transform: scale(0.97);
}
/* Dev-age caption + roadmap. The Roadmap button is a NEUTRAL glass secondary — it
   overrides the accent `.card button` gradient so it doesn't compete with the primary
   "I understand" CTA. The panel reuses the .devWarn glass look. */
.devAge {
  text-align: center;
  font-size: var(--fs-xs);
  font-weight: 700;
  color: var(--ink-dim);
}
#devRoadmapBtn {
  font-size: 17px;
  color: var(--ink);
  background: var(--glass-2);
  border: 1px solid var(--stroke-2);
  box-shadow: var(--elev-1), var(--glass-hilite);
}
#devRoadmapBtn:hover {
  box-shadow: var(--elev-1), var(--glass-hilite);
}
#devRoadmap {
  border: 1px solid var(--stroke-2);
  background: var(--glass-2);
  border-radius: var(--r-md);
  padding: 11px 13px;
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}
.devRoadmapDay {
  font-size: var(--fs-sm);
  font-weight: 800;
  color: var(--gold);
}
.devRoadmapList {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-size: var(--fs-sm);
  line-height: 1.4;
}
.devRoadmapList li[data-status='done'] {
  color: var(--ink-faint);
}
.devRoadmapList li[data-status='ongoing'] {
  color: var(--gold);
  font-weight: 700;
}

/* ---- start overlay ---- */
#start {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 22px;
  background: var(--bg-hero);
}
/* Returning signed-in boot: gate.js sets html[data-auto-login] from the fc.signedIn hint
   before main.js loads, so the sign-in card never flashes for a returning player. The card
   (buttons keep wiring up inside display:none) swaps for the "Signing you in…" line until
   welcome hides #start, or a fallback (session.js clearAutoLoginPending / gate.js reveal
   timeout) drops the attribute and restores the card. */
#autoLoginNote {
  display: none;
}
html[data-auto-login='1'] #start .card {
  display: none;
}
html[data-auto-login='1'] #autoLoginNote {
  display: block;
}
#start::before {
  /* Ambient accent glow behind the logo — pure decoration. */
  content: '';
  position: absolute;
  top: 12%;
  left: 50%;
  width: min(720px, 90vw);
  height: 340px;
  transform: translateX(-50%);
  background: radial-gradient(50% 50% at 50% 50%, var(--accent-16), transparent 70%);
  filter: blur(30px);
  pointer-events: none;
}
#start h1 {
  position: relative;
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-hero);
  font-weight: 700;
  letter-spacing: -3px;
  color: var(--ink);
  text-shadow: 0 10px 34px rgba(0, 0, 0, 0.5);
}
#start h1 span {
  background: linear-gradient(180deg, var(--accent-hi), var(--accent));
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  text-shadow: none;
  filter: drop-shadow(0 0 22px var(--accent-32));
}
#start p {
  margin: 0;
  color: var(--ink-dim);
  opacity: 0.9;
  font-weight: 600;
}
.card {
  position: relative;
  background: var(--glass-3);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-xl);
  padding: var(--sp-4) var(--sp-4) 20px;
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  width: min(360px, 90vw);
  backdrop-filter: blur(var(--blur-2)) saturate(1.3);
  box-shadow: var(--elev-3), var(--glass-hilite);
}
.card input {
  font: inherit;
  font-size: var(--fs-lg);
  padding: 12px 14px;
  border-radius: var(--r-md);
  border: 1px solid var(--stroke-1);
  background: rgba(0, 0, 0, 0.3);
  box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.25);
  color: var(--ink);
  transition: border-color var(--t-fast) var(--ease-out);
}
.card input::placeholder {
  color: var(--ink-faint);
}
.card input:focus {
  border-color: var(--accent);
  box-shadow:
    inset 0 2px 6px rgba(0, 0, 0, 0.25),
    0 0 0 3px var(--accent-16);
}
.card button {
  font: inherit;
  font-weight: 700;
  font-size: 19px;
  padding: 13px;
  border: none;
  border-radius: var(--r-md);
  cursor: pointer;
  color: var(--btn-ink);
  background: linear-gradient(180deg, var(--accent-hi), var(--accent));
  box-shadow: var(--elev-1), var(--glass-hilite);
  transition:
    transform var(--t-fast) var(--ease-out),
    box-shadow var(--t-fast) var(--ease-out);
}
.card button:hover {
  transform: translateY(-1px);
  box-shadow: var(--elev-1), var(--glow-accent);
}
.card button:active {
  transform: scale(0.97);
  box-shadow: var(--elev-1);
}
/* Landscape-only column wrappers (index.html: #dead / #devModal cards). Transparent
   by default so portrait/desktop keep one stacked column; the short-landscape blocks
   below flip them to flex so the primary buttons stay on-screen without scrolling. */
.cardCols,
.cardCol {
  display: contents;
}
.hint {
  font-size: var(--fs-sm);
  color: var(--ink-dim);
  line-height: 1.5;
}
/* Sign-in error line on the start card. Collapses when empty so it takes no space in the
   normal (no-error) layout; turns red via the shared danger colour when hud.js fills it. */
#authError {
  color: var(--danger);
  text-align: center;
}
#authError:empty {
  display: none;
}
.orSep {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: var(--fs-xs);
  color: var(--ink-dim);
  text-transform: uppercase;
  letter-spacing: 1px;
}
.orSep::before,
.orSep::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--stroke-1);
}
.hint b {
  color: var(--ink);
}

/* ---- in-game HUD ---- */
#hud {
  position: fixed;
  inset: 0;
  z-index: var(--z-hud);
  pointer-events: none;
}
.panel {
  position: absolute;
  background: var(--glass-2);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-lg);
  padding: 10px 13px;
  backdrop-filter: blur(var(--blur-2)) saturate(1.3);
  box-shadow: var(--elev-1), var(--glass-hilite);
}
/* Top-left cluster: the player frame + the End Run button beside it (on MOBILE the
   button is re-parented into the ☰ strip, leaving the frame + that strip). The wrapper
   is anchored where #stats used to be and lays them out in a row, so the button hugs
   the panel's real width instead of a magic pixel offset. */
#hudTopLeft {
  position: absolute;
  left: 14px;
  top: 14px;
  display: flex;
  align-items: flex-start;
  gap: var(--sp-3);
}
#stats {
  position: static;
  min-width: 220px;
}
#stats .row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-3);
}
#stats .name {
  font-weight: 800;
  font-size: 16px;
}
#stats .lvl {
  font-size: var(--fs-xs);
  color: var(--gold);
  font-weight: 700;
  padding: 1px 8px;
  border-radius: var(--r-full);
  background: var(--gold-12);
  border: 1px solid var(--gold-25);
}
/* "End Run" button — a flex sibling of #stats inside #hudTopLeft, so it sits
   beside the panel's actual right edge (no magic offset that overlaps when the
   panel grows). The HUD is pointer-events:none, so the button opts back in. A
   two-click confirm flips data-confirming (wired in main.js). */
#endRun {
  flex: none;
  pointer-events: auto;
  background: var(--glass-2);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-md);
  padding: 9px 14px;
  color: var(--ink-dim);
  font: inherit;
  font-weight: 700;
  font-size: var(--fs-sm);
  cursor: pointer;
  backdrop-filter: blur(var(--blur-1));
  transition:
    color var(--t-fast) var(--ease-out),
    border-color var(--t-fast) var(--ease-out),
    box-shadow var(--t-fast) var(--ease-out);
}
#endRun:hover {
  color: var(--danger);
  border-color: var(--danger);
  box-shadow: var(--danger-glow);
}
#endRun[data-confirming='1'] {
  background: rgba(255, 91, 91, 0.85);
  border-color: var(--danger);
  color: var(--ink);
  box-shadow: var(--danger-glow);
}
/* Mid-session sign-in: the HUD is pointer-events:none, so the block (and the
   Google button inside it) must opt back in to be clickable. */
#hudSignin {
  margin-top: 9px;
  pointer-events: auto;
}
#hudSignin .hint {
  font-size: var(--fs-2xs);
  margin-bottom: 6px;
}
/* The guest sign-in buttons (Google + Discord). Plain accent pills, compact for the HUD;
   the HUD is pointer-events:none so they opt back in (their #hudSignin wrapper already does,
   but keep it explicit in case the wrapper styling changes). */
#hudSignin button {
  display: block;
  width: 100%;
  margin-top: 6px;
  pointer-events: auto;
  font: inherit;
  font-weight: 700;
  font-size: var(--fs-xs);
  padding: 7px 10px;
  border: none;
  border-radius: var(--r-md);
  cursor: pointer;
  color: var(--btn-ink);
  background: linear-gradient(180deg, var(--accent-hi), var(--accent));
}
#hudSignin button.hidden {
  display: none;
}
/* Logout button: the HUD is pointer-events:none, so it must opt back in. Shown
   only for signed-in players (toggled in net.js). */
#logoutBtn {
  margin-top: 9px;
  pointer-events: auto;
  font: inherit;
  font-size: var(--fs-2xs);
  padding: 4px 10px;
  border-radius: var(--r-full);
  border: 1px solid var(--stroke-2);
  background: rgba(0, 0, 0, 0.35);
  color: var(--ink-dim);
  cursor: pointer;
  transition:
    color var(--t-fast) var(--ease-out),
    background var(--t-fast) var(--ease-out);
}
#logoutBtn:hover {
  background: rgba(0, 0, 0, 0.55);
  color: var(--ink);
}
#logoutBtn:active {
  background: rgba(0, 0, 0, 0.65);
  color: var(--ink);
}
#logoutBtn.hidden {
  display: none;
}
/* HUD Discord link/status pill (signed-in players; discord.js toggles label +
   visibility). Clickable "Link Discord" when unlinked; a read-only "Discord: @name"
   badge (disabled) once linked. Like its #logoutBtn sibling, the HUD is
   pointer-events:none so it must opt back in — without this it inherits none and
   swallows clicks (the cursor falls through to the canvas). Blurple via the --discord
   tokens so it reads as a Discord affordance. */
#hudDiscordBtn {
  margin-top: 9px;
  pointer-events: auto;
  font: inherit;
  font-weight: 700;
  font-size: var(--fs-2xs);
  padding: 4px 10px;
  border-radius: var(--r-full);
  border: 1px solid var(--discord-deep);
  background: linear-gradient(180deg, var(--discord-hi), var(--discord));
  color: var(--ink);
  cursor: pointer;
  transition:
    transform var(--t-fast) var(--ease-out),
    box-shadow var(--t-fast) var(--ease-out);
}
#hudDiscordBtn:hover {
  transform: translateY(-1px);
  box-shadow: 0 0 14px rgba(88, 101, 242, 0.45);
}
#hudDiscordBtn:not(:disabled):active {
  transform: scale(0.97);
  box-shadow: none;
}
/* Linked state: a static status badge, not a pressable pill — no pointer cursor and
   no hover lift/glow. */
#hudDiscordBtn:disabled {
  cursor: default;
}
#hudDiscordBtn:disabled:hover {
  transform: none;
  box-shadow: none;
}
#hudDiscordBtn.hidden {
  display: none;
}
.bar {
  height: 12px;
  border-radius: var(--r-full);
  background: rgba(0, 0, 0, 0.4);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);
  overflow: hidden;
  margin-top: 7px;
  position: relative;
}
.bar > i {
  border-radius: var(--r-full);
  position: relative;
  overflow: hidden;
}
/* Damage-lag trail: the ghost sits behind the fill with the same width but a
   slower, delayed transition — on damage the fill snaps down and the pale
   ghost lingers, then drains. On heal it hides under the fill entirely. */
#hpGhost {
  position: absolute;
  left: 0;
  top: 0;
  height: 100%;
  background: rgba(255, 138, 91, 0.45);
  /* No !important: the id selector already outranks every competing rule, and it
     must LOSE to the reduced-motion *{transition:none!important} kill-switch. */
  transition: width 0.55s var(--ease-out) 0.28s;
}
#hpFill {
  position: absolute;
  left: 0;
  top: 0;
  height: 100%;
}
/* Red rim flash on the stats panel while HP is dropping (hud.js toggles it). */
#stats.hurt {
  border-color: var(--danger);
  box-shadow: var(--danger-glow), var(--glass-hilite);
}
.bar > i {
  display: block;
  height: 100%;
  width: 0%;
  transition: width 0.12s linear;
}
.bar .label {
  position: absolute;
  inset: 0;
  font-size: 10px;
  line-height: 12px;
  text-align: center;
  font-weight: 700;
  color: var(--ink-hi);
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5);
}
#hpFill {
  background: linear-gradient(90deg, var(--danger-hi), var(--danger));
}
#xpFill {
  background: linear-gradient(90deg, var(--gold), var(--accent));
}
#manaFill {
  background: linear-gradient(90deg, var(--info-hi), var(--info));
}
/* The bar div carries `hidden` while manaMax is 0; display:flex elsewhere would
   override the attribute, so pin it here. */
#manaBar[hidden] {
  display: none;
}

/* Diamonds readout in the top HUD (#stats). A soft currency, not an item.
   Clickable: opens the Shop (wired in menus.js initMenus). The HUD is
   pointer-events:none, so it must opt back in — without this it inherits none, the
   click falls through to the canvas (reading as a move/attack) and neither the
   :hover brighten nor the title tooltip ever fires. */
.diamonds {
  display: flex;
  align-items: center;
  gap: 5px;
  margin-top: 7px;
  font-size: var(--fs-sm);
  font-weight: 800;
  color: var(--diamond-pale);
  cursor: pointer;
  pointer-events: auto;
}
.diamonds:hover {
  color: var(--ink-hi);
}
.diamonds .diaIcon {
  color: var(--diamond);
  text-shadow: 0 0 8px rgba(111, 211, 255, 0.6);
  font-size: 14px;
}
.diamonds:hover .diaIcon {
  color: var(--diamond-hi);
  text-shadow: 0 0 10px rgba(111, 211, 255, 0.9);
}

/* Quests panel rows (menus.js renderQuests). */
.quest {
  padding: 9px 11px;
  margin: 8px 0;
  border-radius: var(--r-md);
  background: rgba(0, 0, 0, 0.28);
  border: 1px solid var(--stroke-1);
}
.quest.ready {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent-16) inset;
}
.quest.claimed {
  opacity: 0.55;
}
/* The Joker Egg claim card (menus.js jokerEggCardHtml) — the one milestone that pays a
   mythic ITEM, so it borrows the egg's own quality colour (--qc) instead of the shared
   ready accent, and sits apart from the diamond rows below it. */
.quest.jokerClaim {
  border-color: var(--qc);
  box-shadow: 0 0 0 1px var(--qc) inset;
  background: rgba(0, 0, 0, 0.42);
  margin-bottom: 14px;
}
.quest.jokerClaim .questLabel {
  display: flex;
  align-items: center;
  gap: 7px;
  color: var(--qc);
}
.jokerEggArt {
  width: 28px;
  height: 28px;
  image-rendering: auto;
}
.questHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.questLabel {
  font-weight: 700;
  font-size: var(--fs-sm);
}
.questBar {
  height: 8px;
  margin-top: 8px;
  border-radius: var(--r-full);
  background: rgba(0, 0, 0, 0.4);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);
  overflow: hidden;
}
.questBar > i {
  display: block;
  height: 100%;
  width: 0%;
  border-radius: var(--r-full);
  background: linear-gradient(90deg, var(--diamond), var(--accent));
  transition: width 0.2s linear;
}
.questProg {
  margin-top: 4px;
  font-size: var(--fs-2xs);
  color: var(--ink-dim);
  text-align: right;
}
.questReward,
.questDone {
  font-size: var(--fs-xs);
  font-weight: 700;
  white-space: nowrap;
}
.questReward {
  color: var(--diamond);
}
/* Daily-login streak line atop the Quests panel (menus.renderQuests). */
.questStreak {
  font-size: var(--fs-sm);
  color: #ffd23f;
  margin: 2px 0 10px;
}
.questDone {
  color: var(--ink-dim);
}

/* Collection questlines — one tier chip per quality step (--qc = the tier's color).
   Locked: dim outline. Ready: a clickable pickup button glowing in its tier color.
   Claimed: filled with a check. */
.qlChips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}
.qlChip {
  min-width: 26px;
  height: 22px;
  padding: 0 5px;
  border-radius: var(--r-full);
  border: 1px solid var(--qc);
  background: transparent;
  opacity: 0.35;
  font-size: var(--fs-2xs);
  font-weight: 700;
  line-height: 20px;
  text-align: center;
  color: var(--qc);
}
.qlChip.ready {
  opacity: 1;
  cursor: pointer;
  background: rgba(0, 0, 0, 0.35);
  box-shadow: 0 0 6px var(--qc);
}
.qlChip.claimed {
  opacity: 0.8;
  background: var(--qc);
  color: var(--ink-on-quality);
}

/* Reward node calendar/track (menus.js dailyGridHtml/chestTrackHtml, models from
   public/rewardnodes.js) — the qlChip states scaled up to a day-sized cell.
   --qc tints gift days their item-quality color; plain days stay neutral. */
.rnGrid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 6px;
  margin-top: 10px;
}
.rnNode {
  --qc: rgba(255, 255, 255, 0.35);
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1px;
  min-height: 44px;
  padding: 3px 2px;
  border-radius: var(--r-md);
  border: 1px solid var(--qc);
  background: rgba(0, 0, 0, 0.28);
  /* Locked days are dimmed, but not so far that the gift art they now carry
     stops reading — the whole point of an upcoming node is to be looked at. */
  opacity: 0.5;
  font: inherit;
  font-size: var(--fs-2xs);
  color: var(--ink);
}
.rnDay {
  font-size: var(--fs-2xs);
  line-height: 1;
}
.rnAmt {
  font-size: 10px;
  line-height: 1.2;
  color: var(--diamond);
  white-space: nowrap;
}
.rnGift,
.rnMile {
  position: absolute;
  top: -5px;
  font-style: normal;
  font-size: 10px;
  line-height: 1;
}
.rnGift {
  right: -3px;
  filter: drop-shadow(0 0 3px var(--qc));
}
/* The gift as its actual item art (we know which item the day pays). It moves out
   of the 🎁 fallback's cramped corner into the cell's empty left half, big enough
   to recognise the item at a glance; the day/◆ text stays centred beside it. */
img.rnGift {
  top: 50%;
  right: auto;
  left: 4px;
  transform: translateY(-50%);
  width: 28px;
  height: 28px;
  object-fit: contain;
}
.rnMile {
  left: -3px;
}
.rnNode.ready {
  opacity: 1;
  border-color: var(--accent);
  background: rgba(0, 0, 0, 0.35);
  box-shadow: 0 0 8px var(--accent);
}
/* Only the armed (oldest) ready node is a button — a backlog's later ready
   days render as inert spans waiting their oldest-first turn. */
button.rnNode.ready {
  cursor: pointer;
}
.rnNode.claimed {
  opacity: 0.8;
  border-color: var(--accent-16);
  background: rgba(255, 255, 255, 0.07);
}
.rnNode.claimed .rnDay::after {
  content: ' ✓';
  color: var(--accent);
}
/* A day whose unclaimed reward expired with a broken streak — dim and struck
   with a muted red ✕, the honest twin of the claimed ✓. */
.rnNode.missed {
  opacity: 0.55;
  border-color: rgba(224, 90, 90, 0.45);
  background: rgba(224, 90, 90, 0.08);
}
.rnNode.missed .rnDay::after {
  content: ' ✕';
  color: rgba(224, 90, 90, 0.8);
}
/* Claim-order connectors: arrows bridging the 6px grid gap from the node's
   predecessor (the snake layout keeps every predecessor adjacent) — a bar
   (::before) plus an arrowhead (::after) pointing along the claim direction,
   so the left→right / drop / right→left snake reads at a glance. */
.rnNode.linkL::before,
.rnNode.linkR::before,
.rnNode.linkU::before {
  content: '';
  position: absolute;
  background: rgba(255, 255, 255, 0.55);
}
.rnNode.linkL::after,
.rnNode.linkR::after,
.rnNode.linkU::after {
  content: '';
  position: absolute;
  width: 0;
  height: 0;
  border: 4px solid transparent;
}
.rnNode.linkL::before,
.rnNode.linkR::before {
  top: 50%;
  width: 8px;
  height: 4px;
  margin-top: -2px;
}
.rnNode.linkL::before {
  left: -8px;
}
.rnNode.linkL::after {
  top: 50%;
  left: -5px;
  margin-top: -4px;
  border-left: 5px solid rgba(255, 255, 255, 0.55);
}
.rnNode.linkR::before {
  right: -8px;
}
.rnNode.linkR::after {
  top: 50%;
  right: -5px;
  margin-top: -4px;
  border-right: 5px solid rgba(255, 255, 255, 0.55);
}
.rnNode.linkU::before {
  left: 50%;
  top: -8px;
  width: 4px;
  height: 8px;
  margin-left: -2px;
}
.rnNode.linkU::after {
  left: 50%;
  top: -5px;
  margin-left: -4px;
  border-top: 5px solid rgba(255, 255, 255, 0.55);
}
/* The 4-chest play-time track: nodes joined by flexible bars. */
.rnChests {
  display: flex;
  align-items: center;
  gap: 0;
  margin-top: 10px;
}
.rnChests .rnNode {
  flex: 0 0 auto;
  min-width: 52px;
}
/* A chest node is a fraction of a calendar cell's width, so a gift one widens to
   seat the item art beside its minutes/◆ instead of sitting on top of them. */
.rnChests .rnNode.hasItem {
  min-width: 84px;
  padding-left: 32px;
}
.rnBar {
  flex: 1 1 8px;
  height: 2px;
  background: rgba(255, 255, 255, 0.18);
}

/* Hunts — one row per started quality ladder: tier label + or-better kill count
   + that ladder's rung chips (the chips wrap on narrow screens). */
.huntTier {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}
.huntTierLabel {
  font-size: var(--fs-2xs);
  font-weight: 700;
  min-width: 74px;
}
.huntTier .qlChips {
  margin-top: 4px;
  flex: 1;
}

/* Diamond Shop — a stock grid of gear rows; reuses the .chip.icon + .btn tokens. */
.shopBal {
  float: right;
  font-size: var(--fs-sm);
  font-weight: 800;
  color: var(--diamond);
  text-shadow: 0 0 8px rgba(111, 211, 255, 0.6);
}
.shopGrid {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  margin-top: 8px;
}
.shopItem {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 11px;
  border-radius: var(--r-md);
  background: rgba(0, 0, 0, 0.28);
  border: 1px solid var(--stroke-1);
}
.shopItem.poor {
  opacity: 0.6;
}
.shopMeta {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-width: 0;
}
.shopName {
  font-weight: 700;
  font-size: var(--fs-sm);
}
.shopSale {
  margin-left: 6px;
  padding: 1px 5px;
  border-radius: 6px;
  font-size: var(--fs-2xs);
  font-weight: 800;
  color: var(--ink-on-success);
  background: #4ade80;
  vertical-align: middle;
}
.shopWas {
  opacity: 0.6;
  font-weight: 500;
  margin-right: 3px;
}

/* --- Style (cosmetic appearance) panel: control rows beside a live preview canvas. */
.menuWin[data-panel='style'] .winBody {
  display: flex;
  gap: var(--sp-3);
  align-items: flex-start;
}
.styleControls {
  flex: 1 1 auto;
  min-width: 0;
}
.styleGrid {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}
.styleRow {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
}
.styleLabel {
  font-size: var(--fs-2xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  opacity: 0.7;
}
.styleOpts {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
}
.styleSwatch {
  width: 24px;
  height: 24px;
  border-radius: 7px;
  border: 2px solid var(--stroke-2);
  cursor: pointer;
  padding: 0;
}
.styleSwatch:hover {
  border-color: var(--accent);
}
.styleSwatch.active {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent);
}
.stylePreviewWrap {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}
.stylePreviewModes {
  display: flex;
  gap: var(--sp-1);
}

/* Top-center active-perks strip — one small icon per equipped trinket (grouped
   by type + quality), filled by hud.js renderActiveBuffs from state.buffs (the
   panel-independent mirror). The .buff tile + .buffRow container are shared with the
   Character menu panel. The strip itself ignores the mouse so it never blocks the
   canvas, but each tile re-enables pointer events so its hover tooltip works. */
#buffStrip {
  /* Chip geometry lives here so the mobile row cap below can be expressed as a
     COLUMN COUNT and can never drift from the tile size. .buff reads --buff-chip
     with a fallback, since the Character panel's .buffRow doesn't define it. */
  --buff-chip: 34px;
  --buff-gap: 6px;
  position: fixed;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--buff-gap);
  max-width: 56vw;
  pointer-events: none;
}
.buffRow {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 4px;
}
.buff {
  position: relative;
  width: var(--buff-chip, 34px);
  height: var(--buff-chip, 34px);
  border-radius: var(--r-sm);
  border: 2px solid var(--stroke-2);
  background: var(--glass-2);
  backdrop-filter: blur(var(--blur-1));
  box-shadow: var(--elev-1), var(--glass-hilite);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  pointer-events: auto;
  transition: transform var(--t-fast) var(--ease-out);
}
.buff:hover {
  transform: translateY(-2px);
}
.buff img {
  width: 26px;
  height: 26px;
  object-fit: contain;
}
/* A timed perk (only the Mushroom's rolled buff today): a cooldown shade over the icon
   that RETREATS as the roll runs out, the loadout recast sweep's twin. --tfill is the
   fraction of time LEFT, poked per frame by hud/buffs.js updateBuffTimers in BOTH
   containers; the 0 fallback means a tile that never gets poked shows no shade rather
   than a full one. */
.buff[data-until]::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  pointer-events: none;
  background: rgba(8, 14, 28, 0.55);
  clip-path: inset(0 0 calc((1 - var(--tfill, 0)) * 100%) 0);
  transition: clip-path 0.12s linear;
}
.buff .amt {
  position: absolute;
  z-index: 2; /* above the timed-perk shade, which is a later-painted pseudo-element */
  right: -4px;
  bottom: -5px;
  min-width: 15px;
  padding: 0 3px;
  font-size: var(--fs-2xs);
  font-weight: 700;
  line-height: 15px;
  text-align: center;
  color: var(--ink-hi);
  background: rgba(20, 20, 24, 0.92);
  border-radius: 8px;
}

/* The mobile fold tab — a slide-out handle on the outer edge of the player frame and the party
   panel (hud/fold.js). Declared here only to be HIDDEN: a desktop window has room for both
   panels in full, so the whole affordance lives inside the mobile block that already relocates
   them. Everything it needs to look like a tab is down there too. */
.hudFold {
  display: none;
}

/* Persistent party panel on the LEFT (below the #stats panel). Display-only,
   driven by you.party; shows one row per member with level + a healthbar. */
#partyHud {
  left: 14px;
  top: 175px;
  min-width: 168px;
  font-size: var(--fs-sm);
}
#partyHud h4 {
  margin: 0 0 8px;
  font-size: var(--fs-2xs);
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--ink-dim);
}
#partyHud .pm {
  padding: 5px 0;
}
#partyHud .pm + .pm {
  border-top: 1px solid rgba(255, 255, 255, 0.07);
}
#partyHud .pmTop {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 4px;
}
#partyHud .pmName {
  font-weight: 700;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 116px;
}
#partyHud .pmName.me {
  color: var(--gold);
}
#partyHud .pmLvl {
  color: var(--gold);
  font-weight: 700;
  flex: none;
}
#partyHud .pmBar {
  height: 9px;
  border-radius: 6px;
  background: rgba(0, 0, 0, 0.35);
  overflow: hidden;
  position: relative;
}
#partyHud .pmBar > i {
  display: block;
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--danger-hi), var(--danger));
  transition: width 0.12s linear;
}
#partyHud .pm.dead .pmName {
  color: var(--ink-dim);
}
#partyHud .pm.dead .pmBar > i {
  background: var(--ink-faint);
}

/* Bottom equipment-loadout bar: a compact DECK of the saved sets, the active one
   full-size in front and the inactive ones fanned/dimmed behind it — each peeking
   out a little further at the bottom. The rows are absolutely stacked (one place),
   positioned by their deck --depth (0=front) so a switch just re-drives --depth and
   the transform/opacity transition rotates the deck (see hud.applyLoadoutDepths). */
#loadoutBar {
  --maxd: 0; /* deepest depth (count-1), set by hud.applyLoadoutDepths */
  position: fixed;
  left: 50%;
  bottom: 12px;
  transform: translateX(-50%);
  z-index: calc(var(--z-hud) + 1); /* above the HUD (20), below the menu overlay (28) */
  height: 150px; /* front card sits raised by maxd·peek; deeper cards fill down to here */
  pointer-events: auto;
}
/* While the Inventory panel is open the bar floats above the menu overlay so
   bag items can be dragged down onto its cells to equip. */
#loadoutBar.lifted {
  z-index: calc(var(--z-menu) + 1);
}
.loadoutRow {
  --depth: 0;
  --peek: 26px; /* how far each deeper card sinks below the one in front */
  position: absolute;
  left: 50%;
  bottom: 0;
  transform-origin: bottom center;
  /* The deck grows UP from the baseline: the deepest card (depth = --maxd) rests at
     the container bottom, the front card is raised by maxd·peek, so every card's lower
     edge peeks out below the one in front of it — and none clip off the screen bottom. */
  transform: translateX(-50%) translateY(calc((var(--depth) - var(--maxd)) * var(--peek)))
    scale(calc(1 - var(--depth) * 0.06));
  opacity: calc(1 - var(--depth) * 0.28);
  display: flex;
  align-items: center;
  gap: 18px;
  background: var(--glass-2);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-lg);
  padding: 10px 22px;
  cursor: pointer;
  backdrop-filter: blur(var(--blur-1));
  transition:
    opacity var(--t-med) var(--ease-out),
    transform var(--t-med) var(--ease-out);
}
.loadoutRow.active {
  /* The border shows the set's average quality (inline style), so the
     active set is marked by a brighter glass + accent glow instead. */
  background: var(--glass-3);
  box-shadow:
    0 0 0 2px rgba(255, 255, 255, 0.18),
    var(--glow-accent),
    var(--elev-1);
}
.loadoutRow.inactive:hover {
  opacity: 0.9;
}
.loadoutNum {
  font-weight: 800;
  font-size: 26px;
  color: var(--gold);
  width: 33px;
  text-align: center;
}
.loadoutRow.inactive .loadoutNum {
  color: var(--ink-dim);
}
/* Keycap hint — present on every card but shown only on the next-to-equip (depth-1)
   one, since pressing R always promotes the card sitting right behind the active.
   Pulled OUT of the flex flow and floated just outside the card's left edge (the deck
   overflow is visible, so a child at right:100% renders outside the box) — sat at the
   peeking bottom rather than centered, so on the depth-1 card it shows beside the
   peeking deck instead of hiding behind the raised front card. It still rides the row's
   depth transform/opacity, keeping it visually tied to the card it promotes. */
.loadoutKey {
  position: absolute;
  right: calc(100% + 10px); /* just OUTSIDE the card's left edge */
  bottom: 6px; /* down by the peek, not vertically centered */
  visibility: hidden;
  font-weight: 800;
  font-size: 22px;
  color: #000;
  line-height: 1;
  padding: 5px 9px;
  border: 1px solid var(--gold);
  border-radius: var(--r-xs);
  background: var(--gold);
  box-shadow: 0 2px 0 rgba(0, 0, 0, 0.35);
}
.loadoutRow[data-depth='1'] .loadoutKey {
  visibility: visible;
}
.loadoutSlots {
  display: flex;
  gap: 10px;
}
.loadoutCell {
  position: relative;
  width: 60px;
  height: 60px;
  border: 2px solid var(--stroke-1);
  border-radius: 20px;
  background: rgba(0, 0, 0, 0.25);
  display: flex;
  align-items: center;
  justify-content: center;
}
.loadoutCell.empty {
  opacity: 0.4;
}
.loadoutCell[draggable='true'] {
  cursor: grab;
}
.loadoutCell.dragover {
  border-color: var(--accent);
  box-shadow:
    0 0 0 2px var(--accent) inset,
    var(--glow-accent);
  opacity: 1;
}
.loadoutCell img {
  width: 48px;
  height: 48px;
}
/* Weapon durability/respawn fill: a greyed base icon (cellBase) with a coloured copy
   (cellFill) laid over it, revealed bottom-up by --fill (0..1, set per-frame by
   updateLoadoutWear). While respawning the colour is slightly desaturated; at full it
   snaps to normal saturation. Both icon layers are centred so they overlap exactly; the
   centred 48px icon never reaches the rounded corners, so no clipping is needed. */
.loadoutCell .cellBase {
  filter: grayscale(1) brightness(0.6);
}
.loadoutCell .cellFill {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  clip-path: inset(calc((1 - var(--fill, 1)) * 100%) 0 0 0);
  transition: clip-path 0.12s linear;
  pointer-events: none;
}
.loadoutCell.respawning .cellFill img {
  filter: saturate(0.55);
}
/* Spell recast sweep: while a spell trinket is on cooldown its cell is shaded from the top down,
   the shade clearing bottom-up as --cdfill (0..1, set per-frame by updateLoadoutWear) reaches 1
   — the same direction the durability fill reveals, so "filling up" means ready in both. Drawn
   on ::before because ::after is the mobile tap-target expander. A ready spell keeps .spellSlot
   but drops .recasting, so no shade is painted at all. */
.loadoutCell.spellSlot.recasting::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  pointer-events: none;
  background: rgba(8, 14, 28, 0.62);
  clip-path: inset(0 0 calc(var(--cdfill, 1) * 100%) 0);
  transition: clip-path 0.12s linear;
}
/* Bow arrow badge: a small arrow in the cell's bottom-right corner whose colour fills as the
   loaded arrow reloads — same two-layer trick as the cell fill (greyed ammoBase + a coloured
   ammoFill clipped bottom-up by --afill), shrunk to a corner sub-icon. Hidden unless the
   per-frame driver marks it `.on` (a loaded bow) on the ACTIVE row. */
.loadoutAmmo {
  position: absolute;
  right: 1px;
  bottom: 1px;
  width: 27px;
  height: 27px;
  display: none;
  background: rgba(0, 0, 0, 0.55);
  border-radius: 4px;
  pointer-events: none;
}
.loadoutRow.active .loadoutAmmo.on {
  display: block;
}
.loadoutAmmo img {
  position: absolute;
  top: 1.5px;
  left: 1.5px;
  width: 24px;
  height: 24px;
}
.loadoutAmmo .ammoBase {
  filter: grayscale(1) brightness(0.6);
}
.loadoutAmmo .ammoFill {
  position: absolute;
  inset: 0;
  clip-path: inset(calc((1 - var(--afill, 1)) * 100%) 0 0 0);
  transition: clip-path 0.12s linear;
}
.loadoutAmmo.reloading .ammoFill img {
  filter: saturate(0.55);
}
/* gem marker on a loadout cell whose item has a card socketed */
.loadoutGem {
  position: absolute;
  top: 1px;
  right: 2px;
  font-size: 10px;
  line-height: 1;
  color: var(--gold);
  text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
  pointer-events: none;
}

/* Items panel: armor paperdoll beside the inventory bag. Wraps (armor stacks above
   the bag) on narrow/mobile widths; the bag column shrinks/scrolls, armor stays fixed. */
.itemsLayout {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--sp-3);
}
.armorCol {
  /* Fixed at the paperdoll's natural width (3×52px cells + 2×6px gaps) so the nested
     Sockets section can't widen the column and steal space from the bag — the armor/bag
     split stays identical whether or not a socketed item is shown; sockets wrap below. */
  flex: 0 0 168px;
  width: 168px;
}
.bagCol {
  flex: 1 1 220px;
  min-width: 0;
}
/* Craft mode: the Known-recipes list sits in its own column to the right of the
   bench + bag, filling the remaining width and wrapping below on narrow screens
   (.itemsLayout is flex-wrap: wrap). Its inner .recipeList caps height + scrolls. */
.recipeCol {
  flex: 1 1 220px;
  min-width: 200px;
}

/* Armor paperdoll: a centred 3-column body layout. */
.armorDoll {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  margin: 6px 0 10px;
}
.armorRow {
  display: flex;
  gap: 6px;
  justify-content: center;
}
.armorCell {
  position: relative;
  width: 52px;
  height: 52px;
  border: 2px solid var(--stroke-1);
  border-radius: 14px;
  background: rgba(0, 0, 0, 0.25);
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
}
.armorCell.gap {
  border-color: transparent;
  background: transparent;
}
.armorCell.empty {
  opacity: 0.55;
}
.armorCell[data-act='unequipArmor'] {
  cursor: pointer;
}
.armorCell.dragover {
  border-color: var(--accent);
  box-shadow:
    0 0 0 2px var(--accent) inset,
    var(--glow-accent);
  opacity: 1;
}
.armorCell img {
  width: 42px;
  height: 42px;
}
.armorLabel {
  font-size: 9px;
  line-height: 1.1;
  color: var(--ink-dim);
  pointer-events: none;
}

/* Day/night clock: a compact glass chip sitting just above the bottom-right minimap.
   Display-only (inherits #hud's pointer-events:none). Toggled via the shared .hidden. */
#clock {
  right: 14px;
  bottom: 182px;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 7px 12px;
  white-space: nowrap;
}
#clockGlyph {
  font-size: 20px;
  line-height: 1;
}
#clockText {
  display: flex;
  flex-direction: column;
  line-height: 1.25;
}
#clockPhase {
  font-weight: 800;
  font-size: var(--fs-sm);
}
#clockCountdown {
  font-size: var(--fs-2xs);
  opacity: 0.72;
}
/* Current-map weather, stacked as its own line under the countdown. Toggled via .hidden by
   hud.js renderClockWeather (hidden on indoor/cave maps). Slightly brighter than the countdown
   so it reads as a distinct line rather than more time text. */
#clockWeather {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  font-size: var(--fs-2xs);
  opacity: 0.9;
  margin-top: 1px;
}
#weatherLabel {
  font-weight: 700;
}
/* Happy-hour line inside the clock chip (hud.js renderHappyHour): dimmed "in ~2h" while
   upcoming; gold + pulsing while the daily XP window is live (.on). */
#happyHour {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  font-size: var(--fs-2xs);
  opacity: 0.72;
  margin-top: 1px;
}
#happyLabel {
  font-weight: 700;
}
#happyHour.on {
  opacity: 1;
  color: #ffd23f;
  animation: happyPulse 1.6s ease-in-out infinite;
}
@keyframes happyPulse {
  50% {
    opacity: 0.6;
  }
}
#mini {
  right: 14px;
  bottom: 14px;
  padding: 0;
  overflow: hidden;
  line-height: 0;
}
#mini canvas {
  position: static;
  border-radius: calc(var(--r-lg) - 1px);
}
/* Fullscreen minimap mode (M): centre the map as a dimmed pop-up overlay
   instead of the bottom-right corner. The huge spread box-shadow dims the
   whole screen behind it (robust under the element's own overflow:hidden,
   unlike a fixed-position ::before, which the .fullscreen transform would
   turn into a clipped containing block). The canvas size is driven by JS. */
#mini.fullscreen {
  right: auto;
  bottom: auto;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: var(--z-modal);
  box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.55);
}

/* No backdrop-filter: the gradient dim alone. A full-screen blur over the LIVE canvas
   re-composites every frame — see docs/decisions/no-fullscreen-blur-over-the-live-canvas.md. */
#dead {
  position: fixed;
  inset: 0;
  z-index: var(--z-dead);
  display: none;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: var(--sp-2);
  background: radial-gradient(
    120% 120% at 50% 45%,
    rgba(28, 12, 14, 0.55),
    rgba(16, 6, 8, 0.78) 75%,
    rgba(8, 3, 4, 0.9)
  );
}
#dead h2 {
  margin: 0;
  font-family: var(--font-display);
  font-size: 54px;
  font-weight: 700;
  letter-spacing: -1px;
  color: #ffdede;
  text-shadow:
    0 4px 12px rgba(0, 0, 0, 0.4),
    0 0 34px rgba(255, 91, 91, 0.35);
}
#dead p {
  margin: 0;
  color: var(--ink-dim);
}
#dead .card {
  margin-top: 6px;
}
#dead .btn[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}
/* Respawn destination field: one card at a time in a scroll-snapping track you swipe
   (native overflow — the browser owns the momentum + snap), flanked by ‹ › arrows for
   mouse/keyboard, over the single Spawn button that commits the shown card. */
#dead .respawnBtns {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-2);
}
.respawnField {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  max-width: 100%;
}
.respawnTrack {
  display: flex;
  width: clamp(120px, 40vw, 200px);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  /* Claim the horizontal axis so a swipe never scrolls the card behind it (the death
     card and #dead both become vertical scrollers in short landscape). */
  touch-action: pan-x;
  overscroll-behavior-x: contain;
  border-radius: var(--r-md);
  scrollbar-width: none;
}
.respawnTrack::-webkit-scrollbar {
  display: none;
}
.respawnTrack:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* Cards and arrows are scoped under #dead to outrank `.card button`'s accent gradient —
   only the Spawn button is meant to wear it. */
#dead .respawnCard {
  flex: 0 0 100%;
  scroll-snap-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  font: inherit;
  font-weight: 800;
  font-size: var(--fs-sm);
  color: var(--ink);
  padding: 12px 6px;
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-md);
  background: rgba(0, 0, 0, 0.28);
  box-shadow: none;
  cursor: pointer;
  transition:
    border-color var(--t-fast) var(--ease-out),
    background var(--t-fast) var(--ease-out);
}
#dead .respawnCard[aria-selected='true'] {
  border-color: var(--accent);
  background: rgba(255, 255, 255, 0.08);
}
#dead .respawnCard:hover {
  transform: none;
}
.respawnIcon {
  font-size: 30px;
  line-height: 1.1;
}
.respawnName {
  text-align: center;
}
#dead .respawnArrow {
  flex: none;
  width: 40px;
  height: 40px;
  padding: 0;
  font: inherit;
  font-size: 22px;
  line-height: 1;
  color: var(--ink);
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid var(--stroke-1);
  border-radius: 50%;
  box-shadow: none;
  cursor: pointer;
}
#dead .respawnArrow:disabled {
  opacity: 0.3;
  cursor: default;
}
#dead .respawnArrow:not(:disabled):hover {
  background: rgba(255, 255, 255, 0.2);
  transform: none;
}
.respawnDots {
  display: flex;
  gap: 6px;
}
.respawnDots i {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.25);
}
.respawnDots i.on {
  background: var(--accent);
}
.respawnGo {
  width: 100%;
}

/* death recap: a small label/value grid summarising the life that ended */
.deathStats {
  margin: 4px 0 10px;
  text-align: left;
}
.deathStats .row {
  display: flex;
  justify-content: space-between;
  gap: var(--sp-4);
  padding: 3px 0;
  font-size: 14px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.deathStats .row:last-child {
  border-bottom: none;
}
.deathStats .row .k {
  color: var(--ink-dim);
}
.deathStats .row .v {
  color: var(--ink);
  font-weight: 700;
}
/* recap image grids: one quality-bordered tile per looted item / slain mob */
.deathStats .recapSection {
  margin-top: 10px;
}
.deathStats .recapSection h4 {
  margin: 0 0 5px;
  font-size: var(--fs-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-dim);
}
.deathStats .recapGrid {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.deathStats .recapTile {
  position: relative;
  width: 40px;
  height: 40px;
  border: 2px solid var(--ink-faint);
  border-radius: 8px;
  background: rgba(0, 0, 0, 0.28);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.deathStats .recapTile img {
  width: 30px;
  height: 30px;
  object-fit: contain;
}
.deathStats .recapTile .amt {
  position: absolute;
  right: -4px;
  bottom: -5px;
  min-width: 15px;
  padding: 0 3px;
  font-size: var(--fs-2xs);
  font-weight: 700;
  line-height: 15px;
  text-align: center;
  color: var(--ink-hi);
  background: rgba(20, 20, 24, 0.92);
  border-radius: 8px;
}

/* Shared disclaimer box (start gate + death overlay). One glass box; the two stage variants
   differ only in tint — gold "warning" for the dev build, friendly green for early alpha. */
.warnBox {
  border-radius: 12px;
  padding: 11px 13px;
  font-size: var(--fs-sm);
  line-height: 1.5;
  color: var(--ink);
}
.devWarn {
  border: 1px solid var(--gold-50);
  background: rgba(120, 80, 0, 0.25);
}
.devWarn b {
  color: var(--gold);
}
.alphaWarn {
  border: 1px solid rgba(120, 220, 150, 0.45);
  background: rgba(30, 90, 55, 0.25);
}
.alphaWarn b {
  color: rgb(140, 230, 165);
}
/* Which warning (and the dev-server pointer) shows is decided purely by the stage the server
   stamps onto <html data-stage> before first paint — no JS, so prod never flashes the dev
   warning while /config is in flight. See server/stage.js + public/main.js. */
html[data-stage='prod'] .devWarn {
  display: none;
}
html[data-stage='dev'] .alphaWarn {
  display: none;
}
html[data-stage='dev'] #devServerNote {
  display: none;
}

/* Device-aware start-card control hints: exactly one shows. pointer:coarse picks
   the touch copy at FIRST PAINT (before any JS); body.mobile (main.js
   isTouchDevice) additionally forces the touch copy on devices the media query
   missed (touch controls mount exactly when body.mobile is set). */
.hintTouch {
  display: none;
}
@media (pointer: coarse) {
  .hintDesktop {
    display: none;
  }
  .hintTouch {
    display: block;
  }
}
body.mobile .hintDesktop {
  display: none;
}
body.mobile .hintTouch {
  display: block;
}

/* Clickable: opens the Skill tree (wired in menus.js initMenus). #hud is
   pointer-events:none, so like .diamonds it must opt back in or the click
   falls through to the canvas as a move/attack. */
#stats .sp {
  font-size: var(--fs-xs);
  font-weight: 800;
  color: var(--btn-ink);
  background: var(--gold);
  border-radius: var(--r-full);
  padding: 1px 8px;
  margin-right: 8px;
  box-shadow: 0 0 12px rgba(255, 210, 63, 0.45);
  cursor: pointer;
  pointer-events: auto;
}
#stats .sp:hover {
  box-shadow: 0 0 16px rgba(255, 210, 63, 0.8);
}

/* ---- menu (inventory / craft / upgrade / skills) ---- */
/* Bottom-right icon dock: one button per menu; each toggles its own window. The
   shortcut hint rides on data-tip and shows to the left on hover. */
#menuDock {
  position: fixed;
  right: 12px;
  bottom: 302px; /* above the mute button (262 + 32 button + 8 gap), which sits above the clock's growth band */
  z-index: var(--z-dock);
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  pointer-events: auto;
  /* Short window: the upward stack (~10 × 46px) would clip past the top edge —
     cap it and let overflow buttons wrap into a second column to the left. */
  max-height: calc(100dvh - 314px);
  flex-wrap: wrap;
}
/* Big minimap mode (M) is 320px tall vs the default 160, so lift the corner clock
   and menu dock to clear its taller footprint (toggled by render.js applyMinimapOverlay). */
body.minimap-big #clock {
  bottom: 342px; /* 14 (mini inset) + 320 (big map) + 8 gap */
}
body.minimap-big #sfxMute {
  bottom: 422px; /* clock bottom + ~75 clock band + 5 gap, mirroring the baseline stack */
}
body.minimap-big #menuDock {
  bottom: 462px; /* mute bottom + 32 button + 8 gap */
  max-height: calc(100dvh - 474px);
}
#menuDock button {
  position: relative;
  width: 46px;
  height: 46px;
  font-size: 22px;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ink);
  background: var(--glass-2);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-lg);
  box-shadow: var(--elev-1), var(--glass-hilite);
  backdrop-filter: blur(var(--blur-2));
  cursor: pointer;
  transition:
    transform var(--t-fast) var(--ease-out),
    background var(--t-fast) var(--ease-out),
    box-shadow var(--t-fast) var(--ease-out);
}
#menuDock button:hover {
  transform: translateY(-2px);
  background: rgba(255, 255, 255, 0.08);
}
#menuDock button:active {
  transform: scale(0.96);
}
#menuDock button.open {
  border-color: var(--accent);
  box-shadow: var(--glow-accent);
}
#menuDock button::after {
  content: attr(data-tip);
  position: absolute;
  right: calc(100% + 10px);
  top: 50%;
  transform: translateY(-50%);
  white-space: nowrap;
  font-size: var(--fs-xs);
  font-weight: 700;
  color: var(--ink);
  background: var(--glass-3);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-md);
  padding: 5px 9px;
  box-shadow: var(--elev-2);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-fast) var(--ease-out);
}
#menuDock button:hover::after {
  opacity: 1;
}
/* Ready-to-claim rewards count on the quest-dock button. A real child element —
   the button's ::after is already the hover tooltip. */
.dockBadge {
  position: absolute;
  top: -6px;
  right: -6px;
  min-width: 18px;
  height: 18px;
  padding: 0 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--fs-2xs);
  font-weight: 800;
  font-style: normal;
  line-height: 1;
  color: var(--ink-on-gold);
  background: var(--gold);
  border-radius: 9px;
  box-shadow: var(--elev-1);
  pointer-events: none;
}
.dockBadge[hidden] {
  display: none;
}

/* The window host: a full-screen passthrough layer (each .menuWin captures its own
   pointer events); menus.js appends one movable window per open panel. */
#menuLayer {
  position: fixed;
  inset: 0;
  z-index: var(--z-menu);
  pointer-events: none;
}
.menuWin {
  position: absolute;
  /* Stacking is by z-index, driven off state.openWindows (menus/rail.js restackWindows) —
     NOT DOM order, because re-appending a node to raise it eats the click that raised it.
     This is the floor a window sits at until its first restack. */
  z-index: 1;
  width: min(920px, 94vw);
  max-height: 88vh;
  display: flex;
  flex-direction: column;
  background: var(--glass-3);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-xl);
  box-shadow: var(--elev-3), var(--glass-hilite);
  backdrop-filter: blur(var(--blur-3)) saturate(1.3);
  pointer-events: auto;
  animation: fc-fade-up var(--t-enter) var(--ease-out);
}
/* Draggable title bar (the ONLY drag surface, so it can't fight item drag-and-drop
   inside the body). */
.winBar {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 9px 12px 9px 16px;
  cursor: grab;
  border-bottom: 1px solid var(--stroke-2);
  touch-action: none;
}
.menuWin.dragging {
  user-select: none;
}
.menuWin.dragging .winBar {
  cursor: grabbing;
}
.winTitle {
  flex: 1;
  font-weight: 700;
  font-size: 14px;
  color: var(--ink);
}
.winClose {
  font: inherit;
  color: var(--ink-dim);
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid transparent;
  border-radius: var(--r-full);
  width: 26px;
  height: 26px;
  cursor: pointer;
  transition:
    color var(--t-fast) var(--ease-out),
    background var(--t-fast) var(--ease-out);
}
.winClose:hover {
  color: var(--ink);
  background: rgba(255, 255, 255, 0.08);
}
.winBody {
  overflow: auto;
  padding: 4px 16px 18px;
}
.winBody h3 {
  margin: 14px 0 8px;
  font-size: var(--fs-md);
}
.winBody h3:first-child {
  margin-top: 2px;
}
.muted {
  color: var(--ink-dim);
  font-size: var(--fs-xs);
  font-weight: 600;
}
.slots {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
}
.slot {
  width: 48px;
  height: 48px;
  border-radius: var(--r-sm);
  border: 2px solid var(--stroke-1);
  background: rgba(0, 0, 0, 0.28);
  box-shadow: var(--glass-hilite);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 22px;
  cursor: pointer;
  transition: transform var(--t-fast) var(--ease-out);
}
.slot:not(.empty):hover {
  transform: translateY(-2px);
}
.slot.empty {
  color: var(--ink-dim);
  font-size: 20px;
  cursor: default;
  opacity: 0.6;
}
.slot img.g {
  width: 30px;
  height: 30px;
  object-fit: contain;
}
.slot.dragover {
  border-color: var(--gold);
  background: rgba(255, 255, 255, 0.12);
}
.grid {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
}
.chip {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 6px 10px;
  border-radius: var(--r-sm);
  border: 2px solid var(--stroke-1);
  background: rgba(0, 0, 0, 0.25);
  box-shadow: var(--glass-hilite);
  font-size: var(--fs-sm);
  font-weight: 700;
  transition:
    transform var(--t-fast) var(--ease-out),
    background var(--t-fast) var(--ease-out);
}
.chip.equipable {
  cursor: grab;
}
.chip.equipable:active {
  cursor: grabbing;
}
.chip.equipable:hover {
  background: rgba(255, 255, 255, 0.08);
  transform: translateY(-1px);
}
/* Craft bag: items not usable for the current recipes — visible but dimmed + inert
   (grayscale desaturates the inline quality-coloured border too). */
.chip.dim {
  opacity: 0.4;
  filter: grayscale(0.9);
  cursor: default;
}
.chip .g {
  font-size: 17px;
}
.chip img.g {
  width: 20px;
  height: 20px;
  object-fit: contain;
}
.chip .ct {
  color: var(--ink-dim);
}
/* Admin item pickers (give-item on Tools, and the Items stats tab): a scrolling grid of
   selectable catalog chips (icon + name), the admin twin of the bag. Reuses .chip.icon;
   adds a name label + a selected accent. */
.adminItemGrid {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  max-height: 34vh;
  overflow-y: auto;
  padding: 2px;
  margin: 4px 0 6px;
  scrollbar-width: thin;
  scrollbar-color: var(--stroke-1) transparent;
}
/* The Items stats tab shows a big per-tier stats table below the picker, so keep its grid
   shorter than the give-item one to leave the table in view. */
.itemStatGrid {
  max-height: 22vh;
}
.adminItemGrid::-webkit-scrollbar {
  width: 8px;
}
.adminItemGrid::-webkit-scrollbar-thumb {
  background: var(--stroke-1);
  border-radius: var(--r-sm);
}
/* .chip.icon (48px square, icon-only) is more specific than a bare .chip.adminPick and wins
   by source order, so qualify with .icon here to restore a row with a name label + a fixed
   width (don't flex-shrink to pack more per row, which squeezes the text to an ellipsis). */
.chip.icon.adminPick {
  cursor: pointer;
  flex: 0 0 auto;
  width: 138px;
  height: 40px;
  padding: 4px 8px;
  gap: 7px;
  justify-content: flex-start;
  overflow: hidden;
}
.chip.icon.adminPick:hover {
  background: rgba(255, 255, 255, 0.08);
  transform: translateY(-1px);
}
.chip.icon.adminPick img.g {
  width: 22px;
  height: 22px;
  flex: 0 0 auto;
}
.chip.icon.adminPick .pickName {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chip.adminPick.sel {
  border-color: var(--accent);
  box-shadow:
    0 0 0 2px var(--accent) inset,
    0 0 0 1px var(--accent-16);
}
.craftRow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 8px 0;
  border-top: 1px solid rgba(255, 255, 255, 0.07);
  flex-wrap: wrap;
}
.craftIO {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}
/* Search box + type-prefilter buttons above the bag and the Known-recipes list. Mirrors
   the admin search row: the text input flexes to fill, the category buttons wrap below it
   in the narrow recipe column. */
.filterBar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  margin: 6px 0 4px;
}
.filterBar input {
  flex: 1 1 140px;
  min-width: 110px;
  font: inherit;
  font-weight: 700;
  font-size: var(--fs-sm);
  color: var(--ink);
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--stroke-1);
  border-radius: 8px;
  padding: 6px 8px;
}
.filterCats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-1);
  flex: 1 1 100%;
}
.filterEmpty {
  margin: 6px 2px;
}

/* Give the internally-scrolling bag + recipe lists a visible slim scrollbar (they cap at
   ~46/60vh and scroll; the mobile rule above drops the caps for one continuous sheet). */
.invWrap,
.recipeList {
  scrollbar-width: thin;
  scrollbar-color: var(--stroke-1) transparent;
}
.invWrap::-webkit-scrollbar,
.recipeList::-webkit-scrollbar {
  width: 8px;
}
.invWrap::-webkit-scrollbar-thumb,
.recipeList::-webkit-scrollbar-thumb {
  background: var(--stroke-1);
  border-radius: var(--r-sm);
}
.invWrap::-webkit-scrollbar-thumb:hover,
.recipeList::-webkit-scrollbar-thumb:hover {
  background: var(--ink-dim);
}

/* The "Known recipes" list in the Craft panel. Craftable rows (.addable) are clickable
   to auto-fill the bench; un-craftable rows (.off) are greyed (reason on hover title).
   Each row is one compact line: ingredient icons (.ri, with ×N) → output name (.ro). */
.recipeList {
  display: flex;
  flex-direction: column;
  /* Extend downward as recipes are learned, then scroll internally so the list's
     own column takes the growth instead of stretching the whole modal card. */
  max-height: 60vh;
  overflow-y: auto;
}
.recipeRow {
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-wrap: nowrap;
  /* Keep intrinsic height so the list overflows into its own scrollbar (see
     .recipeList) once recipes exceed max-height, instead of compressing rows. */
  flex-shrink: 0;
  gap: 6px;
  padding: 7px 8px;
  border-top: 1px solid rgba(255, 255, 255, 0.07);
  border-radius: var(--r-sm);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.recipeRow .ri {
  display: inline-flex;
  align-items: center;
  gap: 2px;
}
.recipeRow .ri img {
  width: 18px;
  height: 18px;
  object-fit: contain;
}
.recipeRow .ri .ct {
  color: var(--ink-dim);
}
.recipeRow .arrow {
  color: var(--ink-dim);
  font-weight: 800;
}
.recipeRow .ro {
  font-weight: 800;
  color: var(--ink);
}
.recipeRow.addable {
  cursor: pointer;
}
.recipeRow.addable:hover {
  background: rgba(255, 255, 255, 0.08);
}
.recipeRow.addable:active {
  transform: scale(0.99);
}
.recipeRow.off {
  opacity: 0.5;
}
/* Undiscovered recipes: fully masked teaser rows (? per ingredient, ??? output). */
.recipeRow.mystery {
  opacity: 0.45;
  cursor: default;
}
.recipeRow .ri.mystery {
  width: 18px;
  height: 18px;
  justify-content: center;
  color: var(--ink-dim);
  font-weight: 800;
}
.partyRow {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 0;
  border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.partyRow .partyName {
  font-weight: 600;
}
.partyRow .partyDo {
  margin-left: auto;
  display: flex;
  gap: 6px;
}
.partyPrefs {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-bottom: 10px;
}
.partyPrefs label {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  cursor: pointer;
}
/* --- admin panel --- */
.adminRow {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin: 6px 0 4px;
}
.adminRow select,
.adminRow input {
  font: inherit;
  font-weight: 700;
  font-size: var(--fs-sm);
  color: var(--ink);
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--stroke-1);
  border-radius: 8px;
  padding: 6px 8px;
}
.adminRow input[type='number'] {
  width: 72px;
}
#adminSearch {
  flex: 1 1 160px;
  min-width: 120px;
}
/* Offline players in the admin Players roster read dimmer than online ones. */
.playerRow.offline .partyName {
  opacity: 0.72;
}
/* The per-player PC/mobile playtime ratio — never wrap mid-ratio. */
.playerRow .playSplit {
  white-space: nowrap;
}
/* Admin Inventory tab: a labelled cell (armor part / station) and a loadout-set row. */
.holdingCell {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  font-size: var(--fs-2xs);
}
.holdingSet {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  flex-wrap: wrap;
  margin: 4px 0;
}
/* --- admin skills sub-tab (read-only target skill tree) --- */
.skillTable {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin: 4px 0 8px;
}
.skillRow {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  font-size: var(--fs-xs);
}
.skillRow .skillName {
  flex: 0 0 8.5em;
  font-weight: 600;
}
.skillRow .skillPips {
  flex: 1 1 auto;
  letter-spacing: 2px;
  font-variant-numeric: tabular-nums;
}
.skillRow .skillCount {
  flex: 0 0 auto;
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}
/* --- dashboard panel --- */
.dashGrid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
  gap: var(--sp-2);
  margin: 6px 0 4px;
}
.dashStat {
  display: flex;
  flex-direction: column;
  gap: 2px;
  background: rgba(0, 0, 0, 0.22);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-sm);
  padding: 8px 10px;
  box-shadow: var(--glass-hilite);
}
.dashLabel {
  font-size: var(--fs-2xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-dim);
}
.dashValue {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 19px;
  font-variant-numeric: tabular-nums;
}
/* --- compendium sub-tab (mob XP / drop rates / level XP) --- */
.compTable {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-xs);
  margin: 4px 0 12px;
}
.compTable th,
.compTable td {
  text-align: left;
  vertical-align: top;
  padding: 4px 6px;
  border-bottom: 1px solid var(--stroke-1);
}
.compTable th {
  font-size: var(--fs-2xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-dim);
  position: sticky;
  top: 0;
  background: var(--glass-3);
}
.compTable td.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.compTable.levels {
  display: block;
  max-height: 240px;
  overflow: auto;
}
.compTable.matrix {
  display: block;
  overflow-x: auto;
  margin: 4px 0 10px;
}
.compTable.matrix td,
.compTable.matrix th {
  white-space: nowrap;
}
.compMobBody h4 {
  margin: 10px 0 2px;
  font-size: var(--fs-xs);
}
.compMobBody h4.cardDrop {
  color: var(--gold);
}
.compPicker {
  margin: 6px 0 8px;
  font-size: var(--fs-xs);
  color: var(--ink-dim);
}
.compSelect {
  margin-left: 4px;
  padding: 3px 6px;
  background: var(--glass-2);
  color: var(--ink);
  border: 1px solid var(--stroke-1);
  border-radius: 6px;
  font: inherit;
}
/* Clickable recipe item chip in the admin Items sub-tab (re-selects the item). */
.compPick {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  padding: 2px 6px;
  border: 1px solid var(--stroke-1);
  border-radius: 6px;
  cursor: pointer;
}
.compPick:hover {
  border-color: var(--ink-dim);
}
.compPick .g {
  width: 16px;
  height: 16px;
}
/* Admin Craft Tree sub-tab: the layered recipe DAG (one inline SVG from renderCraftTree;
   the wrap owns the scroll so the whole graph never stretches the menu window). */
.craftTreeCtl {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 6px;
}
.craftTreeCtl .muted {
  font-size: var(--fs-2xs);
  margin-left: 4px;
}
.craftTreeWrap {
  overflow: auto;
  max-height: 62vh;
  border: 1px solid var(--stroke-1);
  border-radius: 8px;
  background: var(--glass-3);
  cursor: grab;
  user-select: none; /* a drag must never select the svg node labels */
}
.craftTreeWrap.ctPanning,
.craftTreeWrap.ctPanning .ctNode {
  cursor: grabbing;
}
.craftTreeWrap svg {
  display: block;
}
.ctNode {
  cursor: pointer;
}
.ctNode rect {
  fill: var(--glass-2);
  stroke: var(--stroke-1);
}
.ctNode:hover rect {
  stroke: var(--ink-dim);
}
.ctNode text {
  fill: var(--ink);
  font-size: var(--fs-2xs);
  dominant-baseline: middle;
}
.ctSel rect {
  stroke: var(--accent);
  /* --glow-accent's box-shadow triple (x y blur color) is also valid drop-shadow syntax */
  filter: drop-shadow(var(--glow-accent));
}
.ctEdge {
  stroke: var(--ink-faint);
  fill: none;
  stroke-width: 1.5;
  opacity: 0.7;
}
.ctRecipe rect {
  fill: var(--accent-16);
  stroke: var(--stroke-accent);
}
.ctCount {
  fill: var(--ink-dim);
  font-size: 10px;
  text-anchor: middle;
}
.ctNode.ctDim,
.ctRecipe.ctDim,
.ctFlow.ctDim {
  opacity: 0.15;
}
.compLevelHead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-top: 10px;
}
.compLevelHead h3 {
  margin: 0;
}
.compLevelNav {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  font-size: var(--fs-xs);
}
.compLevelNav button[disabled] {
  opacity: 0.4;
  cursor: default;
}
/* --- census sub-tab (items in circulation) --- */
.compTable.census {
  display: block;
  max-height: 360px;
  overflow: auto;
}
/* Economy-audit history (admin Audit tab): a scrollable, newest-first event log. */
.compTable.audit {
  display: block;
  max-height: 360px;
  overflow: auto;
}
.compTable.audit td {
  white-space: nowrap;
}
/* Skill Use tab (admin): per-branch/per-rank account counts with a share bar. */
.compTable.skillUse {
  display: block;
  max-height: 420px;
  overflow: auto;
}
.compTable.skillUse td.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.compTable.skillUse .skillBranch td {
  font-weight: 700;
  border-top: 1px solid var(--stroke-1);
}
.compTable.skillUse .skillRank td:first-child {
  padding-left: 18px;
  color: var(--ink-dim);
}
.compTable.skillUse .guildBar {
  min-width: 90px;
  margin: 3px 0;
}
.skillDot {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 3px;
  margin-right: 6px;
  vertical-align: middle;
}
.compTable.census .censusRow {
  cursor: pointer;
}
.compTable.census th {
  cursor: pointer;
  user-select: none;
}
.compTable.census th:hover {
  color: var(--ink-hi);
}
.compTable.census th .sortCaret {
  margin-left: 4px;
  font-size: 10px;
  opacity: 0.8;
}
.compTable.census .censusRow:hover {
  background: rgba(255, 255, 255, 0.05);
}
.censusIcon {
  width: 16px;
  height: 16px;
  vertical-align: -3px;
}
.censusOwners td {
  background: rgba(255, 255, 255, 0.03);
}
.censusOwnerList {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 10px;
}
.censusOwner {
  font-size: var(--fs-2xs);
}
.censusOwner.off {
  color: var(--ink-dim);
}
.censusOwner em {
  font-style: normal;
  color: var(--ink-dim);
}
/* --- guild panel --- */
.guildBar {
  height: 8px;
  border-radius: 6px;
  background: rgba(255, 255, 255, 0.1);
  overflow: hidden;
  margin: 6px 0;
}
.guildBarFill {
  height: 100%;
  background: linear-gradient(90deg, var(--accent), var(--gold));
}
.guildPolicy {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  margin: 8px 0;
}
.guildCreate {
  display: flex;
  gap: var(--sp-2);
  margin-bottom: 12px;
}
.guildCreate input {
  flex: 1;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--stroke-1);
  background: rgba(0, 0, 0, 0.25);
  color: var(--ink);
}
.guildRole {
  font-size: var(--fs-2xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  opacity: 0.85;
}
.guildDanger {
  margin-top: 14px;
  padding-top: 10px;
  border-top: 1px solid rgba(255, 80, 80, 0.25);
}
.gtGrid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 10px;
  margin-top: 8px;
}
.gtCol {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.gtHead {
  font-weight: 700;
  font-size: var(--fs-sm);
  opacity: 0.9;
}
.gtNode {
  border: 1px solid var(--stroke-1);
  border-radius: 8px;
  padding: 7px 8px;
  background: rgba(255, 255, 255, 0.03);
}
.gtNode.owned {
  border-color: var(--accent);
  background: var(--accent-08);
  box-shadow: 0 0 10px var(--accent-16);
}
.gtNode.locked {
  opacity: 0.5;
}
.gtName {
  font-weight: 600;
  font-size: var(--fs-xs);
}
.gtDesc {
  font-size: var(--fs-2xs);
  margin: 2px 0 5px;
}
.craftIO .arrow {
  color: var(--ink-dim);
  font-weight: 800;
}
.craftIO .nm {
  font-weight: 800;
  font-size: var(--fs-sm);
}
.craftDo {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}
.qsel {
  font: inherit;
  font-size: var(--fs-sm);
  padding: 5px 8px;
  border-radius: 8px;
  background: rgba(0, 0, 0, 0.3);
  color: var(--ink);
  border: 1px solid var(--stroke-1);
}
.btn {
  font: inherit;
  font-weight: 800;
  font-size: var(--fs-sm);
  color: var(--btn-ink);
  background: linear-gradient(180deg, var(--accent-hi), var(--accent));
  border: none;
  border-radius: var(--r-sm);
  padding: 7px 14px;
  cursor: pointer;
  box-shadow: var(--glass-hilite);
  transition:
    transform var(--t-fast) var(--ease-out),
    box-shadow var(--t-fast) var(--ease-out),
    filter var(--t-fast) var(--ease-out);
}
.btn.off {
  background: rgba(255, 255, 255, 0.12);
  color: var(--ink-dim);
}
.btn:disabled,
.btn[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}
.btn:not(:disabled):hover {
  filter: brightness(1.12);
  transform: translateY(-1px);
  box-shadow: var(--glow-accent);
}
.btn.off:not(:disabled):hover {
  box-shadow: none;
}
.btn:not(:disabled):active {
  transform: scale(0.96);
  box-shadow: none;
}
.btn.off:not(:disabled):hover {
  background: rgba(255, 255, 255, 0.22);
  color: var(--ink);
}
.btn.sm {
  padding: 5px 10px;
  font-size: var(--fs-xs);
}

/* icon-only inventory cell: artwork centred, count badge in the corner */
.chip.icon {
  position: relative;
  width: 48px;
  height: 48px;
  padding: 0;
  justify-content: center;
}
.chip.icon img.g {
  width: 32px;
  height: 32px;
}
.ct.badge {
  position: absolute;
  right: 3px;
  bottom: 2px;
  font-size: var(--fs-2xs);
  font-weight: 800;
  line-height: 1;
  color: var(--ink-hi);
  padding: 1px 4px;
  border-radius: 6px;
  background: rgba(0, 0, 0, 0.6);
  pointer-events: none;
}
/* Admin inventory-viewer delete affordance: a corner × on each holdings chip. */
.chip .del {
  position: absolute;
  top: -6px;
  right: -6px;
  width: 18px;
  height: 18px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--fs-sm);
  font-weight: 800;
  line-height: 1;
  color: var(--ink-hi);
  border: 1px solid rgba(255, 255, 255, 0.35);
  border-radius: 50%;
  background: var(--danger);
  cursor: pointer;
  opacity: 0.85;
}
.chip .del:hover {
  opacity: 1;
  transform: scale(1.1);
}
/* mob-card sockets: a row per equipped item that has sockets, with a cell per
   socket (drag a matching card on, click a filled one off) */
.socketSection {
  margin: 8px 0 4px;
}
.socketGrid {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 6px;
}
/* One equipped item: its icon on top, its socket cells stacked directly below it. */
.socketTile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 6px 8px;
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 9px;
  background: rgba(255, 255, 255, 0.03);
}
.socketCells {
  display: flex;
  gap: 6px;
}
.socketCell {
  position: relative;
  width: 34px;
  height: 34px;
  border: 2px dashed rgba(255, 255, 255, 0.25);
  border-radius: 7px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.25);
}
.socketCell.filled {
  border-style: solid;
  cursor: grab;
}
.socketCell.filled:active {
  cursor: grabbing;
}
.socketCell img {
  width: 26px;
  height: 26px;
}
/* Quality tint: the JS builders set `--qc` (never an inline border-color), so the
   state cues below override it by normal cascade — no !important anywhere. */
.chip.icon,
.socketCell.filled,
.toolCell:not(.empty) {
  border-color: var(--qc, var(--stroke-2));
}
/* Shared drop-target states — socket cells and tool cells cue identically
   (one rule set; .toolCell base styles live in the tool-slot section below). */
.socketCell.dragover,
.toolCell.dragover {
  border-color: var(--accent);
  background: var(--success-16);
}
/* A socket/tool/armor slot the dragged card/tool/armor can go into — cued at dragstart. */
.socketCell.eligible,
.toolCell.eligible,
.armorCell.eligible {
  border-color: var(--accent);
  border-style: dashed;
  background: var(--success-10);
  animation: fc-socket-pulse 1.1s ease-in-out infinite;
}
@keyframes fc-socket-pulse {
  0%,
  100% {
    box-shadow: 0 0 0 0 rgba(95, 224, 106, 0);
  }
  50% {
    box-shadow: 0 0 0 3px var(--success-28);
  }
}
/* The cell under the cursor + the bag items that fit it (hover cue, both ways). */
.socketCell.sockethot,
.toolCell.toolhot,
.armorCell.armorhot {
  border-color: var(--accent);
  box-shadow:
    0 0 0 2px var(--accent) inset,
    var(--glow-accent);
}
.armorCell.armorhot {
  opacity: 1; /* empty cells are dimmed; the cue must read at full strength (like .dragover) */
}
.chip.cardmatch,
.chip.toolmatch,
.chip.armormatch {
  border-color: var(--accent);
  box-shadow: var(--glow-accent);
}
/* The choice already installed, in the slot picker popup (menus.js pickerEntryHtml): a solid
   accent ring so it reads as "this is what's in there now" against the plain other entries. */
.pickCell.picked,
.socketCell.picked,
.chip.picked {
  border-color: var(--accent);
  border-style: solid;
  box-shadow:
    0 0 0 2px var(--accent) inset,
    var(--glow-accent);
}
/* --- station tool slots (beside the craft bench) ------------------------------ */
.toolCol {
  flex: 0 0 auto;
}
/* The Tools fold toggle exists only on mobile (the media block below shows it and hides
   .toolSlots.folded). Hiding the button here is what makes the fold inert on desktop —
   the .folded class is still worn, but no base rule acts on it (hud/fold.js contract). */
.toolsFold {
  display: none;
}
.toolSlots {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  align-content: flex-start;
  max-width: 132px;
}
.toolCell {
  position: relative;
  width: 52px;
  min-height: 60px;
  border: 2px dashed rgba(255, 255, 255, 0.25);
  border-radius: 9px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  padding: 4px 2px;
  background: rgba(0, 0, 0, 0.25);
}
.toolCell:not(.empty) {
  border-style: solid;
  cursor: pointer;
}
.toolCell img {
  width: 34px;
  height: 34px;
}
/* An empty role slot: a dim ghost of the entry-tier tool showing what belongs here. */
.toolCell.empty .g.ghost {
  opacity: 0.28;
  filter: grayscale(1);
}
/* The role name under each slot (Blade / Hammer), so the slot's type is explicit. */
.toolCell .tlabel {
  font-size: 10px;
  line-height: 1;
  color: var(--ink-dim);
  text-align: center;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* .toolCell drop-target/hover/match states are grouped with the socketCell ones
   above (identical cue set — one shared rule block instead of two drifting copies). */
/* the rolled output-quality odds under a craft result */
.craftOdds {
  margin-top: 4px;
  font-size: var(--fs-xs);
  display: flex;
  flex-wrap: wrap;
  gap: 2px 4px;
}
/* quality section header in the grouped inventory */
.qHead {
  margin: 12px 0 6px;
  font-size: var(--fs-xs);
  font-weight: 800;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: var(--qc, var(--ink-dim));
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}
.qHead::after {
  content: '';
  flex: 1;
  height: 2px;
  border-radius: 2px;
  background: var(--qc, var(--stroke-1));
  opacity: 0.45;
}
.invWrap:first-child .qHead,
.invWrap > .qHead:first-child {
  margin-top: 2px;
}

/* dragging an equipped item out onto the bag unequips it */
.invWrap {
  border-radius: 11px;
  transition: background 0.08s;
  min-height: 40px;
  max-height: 46vh; /* scroll internally so the bag stays clear of the bottom loadout bar */
  overflow-y: auto;
}
.invWrap.dragover {
  background: var(--gold-12);
  outline: 2px dashed var(--gold);
  outline-offset: 2px;
}

/* ---- Items panel mode toggle (Craft / Upgrade) ---- */
.modeToggle {
  display: flex;
  flex-wrap: wrap; /* the Admin panel's 12 sub-tabs overflow a single row */
  gap: var(--sp-2);
  margin: 0 0 12px;
}
.modeToggle .btn {
  flex: 1;
  text-align: center;
}
/* Ready-to-claim count chip on a sub-tab button (the Quests panel tabs). */
.tabBadge {
  display: inline-block;
  margin-left: 6px;
  min-width: 16px;
  padding: 1px 4px;
  font-size: var(--fs-2xs);
  font-weight: 800;
  font-style: normal;
  line-height: 1.2;
  color: var(--ink-on-gold);
  background: var(--gold);
  border-radius: 8px;
}

/* ---- crafting bench (staging area above the inventory) ---- */
.benchRow {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}
.bench {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2);
  padding: 8px;
  border-radius: var(--r-md);
  border: 2px dashed var(--stroke-2);
  background: rgba(0, 0, 0, 0.2);
  transition:
    background var(--t-fast) var(--ease-out),
    border-color var(--t-fast) var(--ease-out),
    box-shadow var(--t-fast) var(--ease-out);
  min-height: 64px;
}
.bench.dragover {
  border-color: var(--gold);
  background: var(--gold-12);
  box-shadow: var(--glow-gold);
}
/* addable inventory/bench cells behave like equippable ones (grab + hover);
   dimmed cells are inert and visibly out of play (filter feedback). */
.chip.addable {
  cursor: grab;
}
.chip.addable:active {
  cursor: grabbing;
}
.chip.addable:hover {
  background: rgba(255, 255, 255, 0.08);
}
.craftOut {
  justify-content: flex-start;
  gap: 14px;
  border-top: none;
  padding-top: 4px;
}

/* floating item tooltip (name + stats), positioned at the cursor */
#itemTip {
  position: fixed;
  z-index: var(--z-tip);
  pointer-events: none;
  max-width: 220px;
  background: var(--glass-3);
  border: 1px solid var(--stroke-1);
  border-top: 2px solid var(--qc, var(--stroke-2));
  border-radius: var(--r-sm);
  padding: 8px 10px;
  backdrop-filter: blur(var(--blur-2));
  box-shadow: var(--elev-2), var(--glass-hilite);
}
/* --- the slot picker popup (menus.js) -----------------------------------------
   A small toolbar anchored at the click, holding every valid choice for a card socket or a
   bow's ammo slot: the owned cards/arrows that fit plus Empty (⌀) — or, opened from a bag
   card/arrow, the slots it can go into. One scrolling row, so a big collection scrolls
   instead of covering the panel behind it. Shares the tooltip's glass plate. */
#slotPicker {
  position: fixed;
  z-index: var(--z-pick);
  max-width: min(340px, 86vw);
  padding: 6px;
  background: var(--glass-3);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-sm);
  backdrop-filter: blur(var(--blur-2));
  box-shadow: var(--elev-2), var(--glass-hilite);
}
.pickRow {
  display: flex;
  gap: 6px;
  overflow-x: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--stroke-1) transparent;
}
.pickCell {
  position: relative;
  flex: 0 0 auto;
  width: 42px;
  height: 42px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  cursor: pointer;
  color: var(--ink-hi);
  font-size: 18px;
  font-weight: 800;
  border: 2px solid var(--qc, var(--stroke-2));
  border-radius: 8px;
  background: rgba(0, 0, 0, 0.25);
}
.pickCell.empty {
  border-style: dashed;
  color: var(--ink-dim);
}
.pickCell:hover {
  background: rgba(255, 255, 255, 0.08);
}
.pickCell img {
  width: 30px;
  height: 30px;
}
#itemTip .tipName {
  font-weight: 800;
  font-size: 14px;
}
#itemTip .tipSub {
  margin-top: 1px;
}
/* Flavour text (what the item IS). Italic + dimmed so it reads as prose and never competes with
   the stat rows below it, which are what a player actually scans a tooltip for. Unscoped: it
   renders both in the hover tooltip and in the admin Compendium's summon matrix. */
.tipFlavor {
  margin-top: 5px;
  font-size: var(--fs-xs);
  font-style: italic;
  line-height: 1.35;
  color: var(--ink-dim);
}
#itemTip .tipStats {
  margin-top: 6px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
#itemTip .tipRow {
  display: flex;
  justify-content: space-between;
  gap: 14px;
  font-size: var(--fs-xs);
  font-weight: 600;
}
#itemTip .tipRow span:first-child {
  color: var(--ink-dim);
}
/* "vs equipped" delta block (menus.tooltipCompareHtml): signed green/red rows
   against the piece the hovered item would replace. */
#itemTip .tipCompare {
  margin-top: 6px;
  padding-top: 5px;
  border-top: 1px solid var(--stroke-1);
}
#itemTip .tipCmpHead {
  font-size: var(--fs-2xs);
  font-weight: 700;
  margin-bottom: 2px;
}
#itemTip .tipCmp.up span:last-child {
  color: var(--success);
}
#itemTip .tipCmp.down span:last-child {
  color: var(--danger-hi);
}
.slot[draggable='true'] {
  cursor: grab;
}
.slot[draggable='true']:active {
  cursor: grabbing;
}

/* ---- skill tree (canvas) ---- */
/* A self-contained <canvas> (skillcanvas.js) renders the whole tree with its
   own pan/zoom; the stage is just a fixed-size, inset viewport for it. */
.skillStage {
  position: relative;
  width: 100%;
  height: min(520px, 68vh);
  margin: 6px 0 2px;
  border-radius: 12px;
  background:
    radial-gradient(circle at 50% 42%, rgba(255, 255, 255, 0.04), transparent 70%),
    rgba(0, 0, 0, 0.28);
  border: 1px solid var(--stroke-1);
  overflow: hidden;
  touch-action: none; /* pan/zoom/hold must not scroll the panel */
}
#skillCanvas {
  /* Pinned to the (position:relative, overflow:hidden) stage so the canvas
     can never push layout or render above the tabs, whatever size JS sets. */
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  cursor: grab;
}

#toast {
  position: fixed;
  left: 50%;
  bottom: 64px;
  transform: translateX(-50%);
  z-index: var(--z-toast);
  opacity: 0;
  visibility: hidden;
  transition:
    opacity var(--t-med) var(--ease-out),
    visibility 0s linear var(--t-med);
  padding: 10px 18px 10px 15px;
  border-radius: var(--r-full);
  border: 1px solid var(--stroke-1);
  border-left: 3px solid var(--stroke-2);
  font-weight: 700;
  font-size: 14px;
  color: var(--ink);
  background: var(--glass-3);
  backdrop-filter: blur(var(--blur-2));
  box-shadow: var(--elev-2), var(--glass-hilite);
}
#toast.ok {
  border-left-color: var(--success);
  box-shadow: var(--elev-2), var(--success-glow);
}
#toast.err {
  border-left-color: var(--danger);
  box-shadow: var(--elev-2), var(--danger-glow);
}
/* A clickable toast (jumps to its menu, e.g. Quests ▸ Daily): cursor + hover lift. */
#toast.click {
  cursor: pointer;
}
#toast.click:hover {
  filter: brightness(1.15);
  border-color: var(--stroke-2);
}

/* The loud, high-stakes result toast (craft/upgrade): larger, lifted to mid-screen,
   a full colour ring instead of just a left accent. Entrance motion is per-outcome
   (see the .show.big rules below). */
#toast.big {
  bottom: 42%;
  padding: 14px 26px;
  font-size: var(--fs-lg);
  border-width: 2px;
  border-left-width: 2px;
  overflow: hidden; /* clips the entrance shine sweep */
}
/* --toast-color is the crafted item's quality colour (set inline by hud.showToast,
   like the banner's --ach-color) — the win glow matches what you just made. */
#toast.big.ok {
  border-color: var(--toast-color, var(--success));
  box-shadow:
    var(--elev-3),
    var(--success-glow),
    0 0 22px 0 var(--toast-color, var(--success));
}
#toast.big.err {
  border-color: var(--danger);
  box-shadow: var(--elev-3), var(--danger-glow), var(--danger-glow);
}

/* ---- the blocking full-screen overlays (name / reconnect / kicked / merge) ----
   None carries a backdrop-filter: each dims with its own flat wash instead. The rAF loop
   never pauses behind them, so a full-screen blur would re-composite every frame — see
   docs/decisions/no-fullscreen-blur-over-the-live-canvas.md. */
#nameModal {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-modal) + 2);
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 9, 12, 0.6);
  /* On-screen keyboard: padding shrinks the flex content box, so the centered card
     shifts into the unoccluded band (keyboard.js publishes the vars — the frame's
     bottom normally, its right edge under the forced-landscape turn). */
  padding-bottom: var(--kb-inset-bottom, 0px);
  padding-right: var(--kb-inset-right, 0px);
}
/* The band the keyboard leaves can undercut the card (its width under the rotated
   right-edge inset, its height in native landscape) — clamp to the padded content
   box and scroll inside rather than sliding the input off-screen. */
#nameModal .card {
  max-width: 100%;
  max-height: 100%;
  overflow-y: auto;
}
#nameModal h2 {
  margin: 0 0 2px;
  font-size: 22px;
  color: var(--ink);
}
#nameError {
  font-size: var(--fs-sm);
  line-height: 1.5;
  color: var(--danger);
}

/* ---- reconnect overlay (in-game connection drop — net.js retries behind it) ---- */
#reconnectOverlay {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-modal) + 3);
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 9, 12, 0.72);
}
#reconnectOverlay h2 {
  margin: 0 0 2px;
  font-size: 22px;
  color: var(--ink);
}

/* ---- kicked overlay (signed in elsewhere — blocking, no reconnect) ---- */
#kickedModal {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-modal) + 3);
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 9, 12, 0.72);
}
#kickedModal h2 {
  margin: 0 0 2px;
  font-size: 22px;
  color: var(--ink);
}

/* ---- account-merge prompt (blocking) ---- */
#mergeModal {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-modal) + 3);
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 9, 12, 0.6);
}

/* ---- workbench place/upgrade prompt (shown near the home station spot) ----
   Shares its overlay styling with the Diamond Shop purchase-confirm popup and
   the generic confirm modal (confirm.js): all centered, dimmed, above the menu
   overlay (z-index 28). No backdrop-filter — see
   docs/decisions/no-fullscreen-blur-over-the-live-canvas.md. */
#placeModal,
#shopConfirmModal,
#confirmModal {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 9, 12, 0.6);
  /* On-screen keyboard: padding shrinks the flex content box, so the centered card
     shifts into the unoccluded band (keyboard.js publishes the vars — the frame's
     bottom normally, its right edge under the forced-landscape turn). #confirmModal
     is the one that asks you to TYPE (retype a guild name, a count, an admin token),
     but any centred modal can be raised while the keyboard is already up. */
  padding-bottom: var(--kb-inset-bottom, 0px);
  padding-right: var(--kb-inset-right, 0px);
}
/* The band the keyboard leaves can undercut the card — clamp to the padded content box
   and scroll inside it rather than sliding the input off-screen (as #nameModal does). */
#placeModal .card,
#shopConfirmModal .card,
#confirmModal .card {
  max-width: 100%;
  max-height: 100%;
  overflow-y: auto;
}
#placeModal h2,
#shopConfirmModal h2,
#confirmModal h2 {
  margin: 0 0 6px;
  font-size: 20px;
  color: var(--ink);
}
#placeModal .placeBtns,
#shopConfirmModal .placeBtns,
#confirmModal .placeBtns {
  display: flex;
  gap: 10px;
  margin-top: 12px;
}
/* Typed/count confirmations (retype the name / WIPE / how many). */
#confirmInput {
  width: 100%;
  margin-top: 10px;
  background: rgba(0, 0, 0, 0.3);
  color: var(--ink);
  border: 1px solid var(--stroke-1);
  border-radius: 8px;
  padding: 6px 9px;
  font-size: var(--fs-sm);
  outline: none;
}
#confirmInput:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-16);
}
/* Opt-in checkbox row ("Don't ask again"); .hidden still wins (!important). */
#confirmCheckRow {
  display: flex;
  align-items: center;
  gap: 7px;
  margin-top: 10px;
  font-size: var(--fs-sm);
  color: var(--ink);
  cursor: pointer;
}
/* A destructive OK reads as danger, not the default amber. */
#confirmOk.danger {
  background: var(--danger);
  border-color: var(--danger-hi);
  color: var(--ink-hi);
}
#confirmOk.danger:hover {
  background: var(--danger-hi);
}

.hidden {
  display: none !important;
}
/* Screen-reader-only: rendered (so aria-live fires) but visually collapsed.
   display:none would silence live regions — this is the standard clip pattern. */
.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
#conn {
  position: fixed;
  left: 50%;
  top: 14px;
  transform: translateX(-50%);
  z-index: var(--z-top);
  background: var(--glass-3);
  border: 1px solid var(--danger);
  color: var(--ink);
  padding: 8px 16px;
  border-radius: var(--r-full);
  font-size: var(--fs-sm);
  font-weight: 600;
  display: none;
  backdrop-filter: blur(var(--blur-1));
  box-shadow: var(--danger-glow);
}

/* Admin-only client performance overlay (F3). Fixed mid-left, pointer-events
   none so it never blocks input. Hidden until an admin toggles it. */
#perfOverlay {
  position: fixed;
  left: 14px;
  top: 130px;
  z-index: calc(var(--z-top) + 1);
  pointer-events: none;
  white-space: pre;
  background: rgba(10, 16, 12, 0.82);
  color: var(--success-soft);
  font:
    11px/1.35 ui-monospace,
    'SF Mono',
    Menlo,
    Consolas,
    monospace;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid rgba(120, 220, 150, 0.25);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.mobile #perfOverlay {
  font-size: 9px;
  top: 96px;
}

/* Small, faint in-game version tag in the bottom-right corner. Follows #perfOverlay's
   unobtrusive fixed-overlay pattern (fixed + pointer-events:none) but muted, and a low
   z-index so it sits UNDER the interactive HUD (dock/chat/loadout) rather than over it. */
#verTag {
  position: fixed;
  right: 8px;
  bottom: 6px;
  z-index: var(--z-under);
  pointer-events: none;
  color: var(--ink-dim);
  opacity: 0.7;
  font:
    10px/1.3 ui-monospace,
    'SF Mono',
    Menlo,
    Consolas,
    monospace;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}

/* Chat — a persistent bottom-LEFT panel (the loadout bar owns bottom-centre).
   Top-level element, z-index 22: above the HUD (20), below the menu overlay
   (28). Hidden until in-game (#hud reveal in net.js toggles it too). */
#chatPanel {
  position: fixed;
  left: 12px;
  bottom: 12px;
  z-index: calc(var(--z-hud) + 2);
  width: 320px;
  max-width: 42vw;
  display: flex;
  flex-direction: column;
  background: var(--glass-2);
  border: 1px solid var(--stroke-1);
  border-radius: var(--r-lg);
  backdrop-filter: blur(var(--blur-2)) saturate(1.3);
  box-shadow: var(--elev-1), var(--glass-hilite);
  overflow: hidden;
  pointer-events: auto;
}
#chatPanel.hidden {
  display: none;
}
#chatTabs {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 4px 4px 0;
}
/* Mobile-only closer (chat.js emits it); the tabs beside it are flex:1, so opt out or it
   stretches with them. Its 44px touch target comes from the .winClose coarse-pointer rule. */
#chatTabs .winClose {
  flex: none;
  margin-left: auto;
  align-self: center;
}
.chatTab {
  position: relative;
  flex: 1 1 auto;
  min-width: 42px;
  background: rgba(0, 0, 0, 0.25);
  color: var(--ink-dim);
  border: 1px solid transparent;
  border-radius: var(--r-sm) var(--r-sm) 0 0;
  padding: 3px 6px;
  font-size: var(--fs-2xs);
  font-weight: 600;
  cursor: pointer;
  transition:
    color var(--t-fast) var(--ease-out),
    background var(--t-fast) var(--ease-out);
}
.chatTab:hover {
  color: var(--ink);
  background: rgba(255, 255, 255, 0.07);
}
/* New messages on a non-active tab: light it up so it catches the eye. */
.chatTab.unread {
  background: rgba(0, 0, 0, 0.45);
  color: var(--ink);
  border-top-color: var(--gold);
  box-shadow: 0 0 8px rgba(255, 210, 63, 0.55);
}
.chatTab.active {
  background: rgba(0, 0, 0, 0.4);
  color: var(--ink);
  border-color: var(--stroke-1);
}
/* Unread-count badge on a tab (chatlib.unreadBadge: '1'–'9' or '9+'). */
.chatBadge {
  position: absolute;
  top: -5px;
  right: 2px;
  min-width: 14px;
  height: 14px;
  padding: 0 3px;
  border-radius: var(--r-full);
  background: var(--gold);
  color: var(--ink-on-gold);
  font-size: 9px;
  font-style: normal;
  font-weight: 800;
  line-height: 14px;
  text-align: center;
}
/* Inline dim timestamp — only on the first line after a quiet gap. */
.chatTs {
  color: var(--ink-faint);
  font-size: 10px;
}
/* Inline submit feedback (whisper with no target, …) — chat.js toggles .show. */
#chatError {
  display: none;
  padding: 3px 8px;
  color: var(--danger-hi);
  font-size: var(--fs-2xs);
}
#chatError.show {
  display: block;
}
#chatLog {
  height: 168px;
  overflow-y: auto;
  padding: 6px 8px;
  font-size: var(--fs-xs);
  line-height: 1.45;
  word-break: break-word;
}
.chatLine {
  margin: 1px 0;
  color: var(--ink);
}
.chatSys {
  color: var(--ink-dim);
  font-style: italic;
}
.chatName {
  font-weight: 700;
  cursor: pointer;
}
.chatName:hover {
  text-decoration: underline;
}
.chatTo {
  font-weight: 700;
  color: var(--chan-whisper);
}
.chatSep {
  color: var(--ink-dim);
}
/* Per-channel name tint (the line's channel class colours its name). */
.chPublic .chatName {
  color: var(--ink);
}
.chGlobal .chatName {
  color: var(--chan-system);
}
.chNearby .chatName {
  color: var(--chan-global);
}
.chParty .chatName {
  color: var(--chan-party);
}
.chGuild .chatName {
  color: var(--chan-guild);
}
.chWhisper .chatName,
.chWhisper.chatLine {
  color: var(--chan-whisper);
}
/* Tab tints (left border accent per channel). */
.chatTab.chGlobal.active {
  border-top-color: var(--chan-system);
}
.chatTab.chNearby.active {
  border-top-color: var(--chan-global);
}
.chatTab.chParty.active {
  border-top-color: var(--chan-party);
}
.chatTab.chGuild.active {
  border-top-color: var(--chan-guild);
}
.chatTab.chWhisper.active {
  border-top-color: var(--chan-whisper);
}
#chatInputRow {
  display: flex;
  padding: 5px;
  border-top: 1px solid var(--stroke-1);
}
#chatInput {
  flex: 1;
  background: rgba(0, 0, 0, 0.3);
  color: var(--ink);
  border: 1px solid var(--stroke-1);
  border-radius: 8px;
  padding: 5px 8px;
  font-size: var(--fs-xs);
  outline: none;
}
#chatInput:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-16);
}
/* A roster name that starts a whisper when clicked (party/guild panels). */
.chatLink {
  cursor: pointer;
}
.chatLink:hover {
  text-decoration: underline;
}

/* ============================================================
   Mobile / touch controls + responsive layout
   The overlay (#touchControls) is built by public/touch.js and only mounted
   on touch devices; the `.mobile` body class (set in main.js) drives the
   responsive tweaks. Everything below is inert on desktop.
   ============================================================ */
#touchControls {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-hud) + 4); /* above HUD/loadout/chat (20-22), below menu overlay (28) */
  pointer-events: none; /* children opt back in */
}
#touchControls > * {
  pointer-events: auto;
}

/* Left-thumb joystick */
.tcStick {
  position: absolute;
  left: max(18px, env(safe-area-inset-left));
  bottom: max(26px, env(safe-area-inset-bottom));
  width: 132px;
  height: 132px;
  border-radius: 50%;
  background: var(--glass-1);
  border: 2px solid var(--stroke-2);
  backdrop-filter: blur(var(--blur-1));
  box-shadow:
    inset 0 2px 10px rgba(0, 0, 0, 0.4),
    var(--glass-hilite);
  touch-action: none;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tcKnob.settling {
  /* Animate only the release recentre (touch.js reset); live tracking stays raw. */
  transition: transform 90ms ease-out;
}
.tcKnob {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  background: radial-gradient(circle at 40% 35%, var(--accent-hi), var(--accent));
  border: 2px solid var(--accent-deep);
  box-shadow:
    0 4px 12px rgba(0, 0, 0, 0.4),
    var(--glow-accent);
  will-change: transform;
}

/* Right-thumb action buttons (Attack / Defend) */
.tcActions {
  position: absolute;
  right: max(18px, env(safe-area-inset-right));
  bottom: max(26px, env(safe-area-inset-bottom));
  display: flex;
  align-items: flex-end;
  gap: 14px;
}
.tcBtn {
  font: inherit;
  color: var(--btn-ink);
  border: none;
  cursor: pointer;
  touch-action: none;
  /* Kill the browser's tap flash + long-press callout so a press reads as an
     instant hold, with no gesture-disambiguation lag. */
  -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none;
  user-select: none;
  -webkit-user-select: none;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tcActions .tcBtn {
  width: 76px;
  height: 76px;
  border-radius: 50%;
  font-size: 30px;
  box-shadow: var(--elev-1), var(--glass-hilite);
}
.tcAttack {
  background: linear-gradient(180deg, var(--danger-hi), var(--danger));
}
/* Attack is deliberately larger than the base action button; the compound selector
   outranks `.tcActions .tcBtn` above — no !important. */
.tcActions .tcAttack {
  width: 92px;
  height: 92px;
  font-size: 38px;
}
.tcDefend {
  background: linear-gradient(180deg, var(--info-hi), var(--info));
}
.tcBtn.held {
  transform: scale(0.92);
  filter: brightness(1.15);
}
.tcAttack.held {
  box-shadow: var(--elev-1), var(--danger-glow);
}
.tcDefend.held {
  box-shadow:
    var(--elev-1),
    0 0 16px rgba(91, 156, 255, 0.5);
}

/* Count badges: ready-to-claim rewards on ☰ and the rail's 📜 (hud.js), unread messages on
   the rail's 💬 (chat.js). Hidden while empty. */
.railBtn,
.tcBurger {
  position: relative;
}
.tcBadge {
  position: absolute;
  top: -3px;
  right: -3px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: var(--r-full);
  background: var(--gold);
  color: var(--ink-on-gold);
  font-size: 10px;
  font-weight: 800;
  line-height: 16px;
  text-align: center;
}
.tcBadge:empty {
  display: none;
}

/* THE menu button — the only menu chrome on the playing field. It flows as the last flex
   child of #hudTopLeft, right of the player frame. Everything it used to lead (a fan-out
   strip of panel icons, 💬, End Run) now lives inside the menu's own rail, so nothing here
   has a width that changes and nothing can slide it sideways. flex:none keeps it from
   stretching in the row; pointer-events opts back in (the HUD is none). */
.tcBurger {
  flex: none;
  pointer-events: auto;
  width: 44px;
  height: 44px;
  border-radius: var(--r-full);
  font-size: var(--fs-lg);
  /* Restated, not inherited: this is the ONE .tcBtn with a dark fill, so .tcBtn's
     --btn-ink (dark ink, sized for the bright amber/red/blue action buttons) put a
     near-black ☰ on dark glass at ~1.1:1. The heavier plate + hairline keep the glyph
     and the button's own outline legible over a bright patch of world too. */
  color: var(--ink);
  background: var(--glass-3);
  border: 1px solid var(--stroke-2);
  backdrop-filter: blur(var(--blur-1));
  box-shadow: var(--elev-1), var(--glass-hilite);
}
.tcBurger:active {
  background: rgba(255, 255, 255, 0.15);
}
/* While the menu is up the same button closes it, so it wears the accent the rail's open
   panel wears — one visual language for "tap this again to go back". */
.tcBurger.open {
  background: var(--accent);
  border-color: var(--accent-deep);
  box-shadow: var(--glow-accent);
  color: var(--ink-on-gold);
}

/* --- the mobile menu rail -----------------------------------------------------
   The switcher, down the LEFT edge of the full-screen overlay: landscape is short and
   wide, so the rail spends the axis there's plenty of (a top tab row would eat the one
   there isn't). It sits inside #menuLayer beside the sheet, and outranks it by z-index —
   windows carry an openWindows-derived z-index, so the rail is pinned clear above that
   band. Hidden on desktop and whenever no panel is open (menus.js toggles .hidden). */
#menuRail {
  display: none;
}
#menuRail .railScroll {
  /* TWO icon columns, not one. Ten 44px entries in a single file need ~480px, and a
     landscape phone frame is 390–430px tall — a single column would ALWAYS scroll, and
     an icon you have to go looking for is the thing this redesign set out to remove.
     Paired up they're five rows, so every panel is visible at once on any frame. It still
     scrolls if a future panel pushes past the frame (scrollbar hidden — the clipped row
     is the cue). */
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: none;
  display: grid;
  grid-template-columns: repeat(2, var(--rail-cell));
  justify-content: center;
  align-content: start;
  gap: 4px;
  /* Start below the floating ☰, which lands in this column while the menu is open — so it
     reads as the rail's own header rather than covering its first icon. */
  padding-top: var(--burger-band, 0px);
}
#menuRail .railScroll::-webkit-scrollbar {
  display: none;
}
#menuRail .railFoot {
  /* End Run, below a divider and OUTSIDE the scroller so a run-ending button is never
     what your thumb lands on while flicking through panels. */
  flex: none;
  padding-top: 6px;
  margin-top: 4px;
  border-top: 1px solid var(--stroke-2);
  display: flex;
  justify-content: center;
}
.railBtn {
  width: var(--rail-cell);
  height: var(--rail-cell);
  border-radius: var(--r-md);
  border: 1px solid transparent;
  background: rgba(0, 0, 0, 0.25);
  font-size: var(--fs-lg);
  line-height: 1;
  cursor: pointer;
  transition:
    background var(--t-fast) var(--ease-out),
    border-color var(--t-fast) var(--ease-out);
}
.railBtn:active {
  background: rgba(255, 255, 255, 0.15);
}
/* The open panel's icon (menus.js setDockActive). Re-tapping it closes the menu, so it
   has to read unmistakably as "you are here". */
.railBtn.active {
  background: var(--accent);
  border-color: var(--accent-deep);
  box-shadow: var(--glow-accent);
}
/* --- Responsive tweaks for small / touch screens --- */
@media (max-width: 760px), (pointer: coarse) {
  /* Backdrop blur is the priciest effect on mobile GPUs — drop the tiers so the
     same glass rules cost a fraction (the translucency still reads as glass). */
  :root {
    --blur-1: 3px;
    --blur-2: 5px;
    --blur-3: 8px;
  }
  /* Shrink the corner HUD panels so they don't dominate a phone screen. The player
     frame (#stats) is pulled down noticeably smaller — narrower, smaller type, and
     shorter HP/XP bars — so it stays a glanceable badge, not a wall. */
  #stats {
    /* Its own grid row, under the ☰ + perks row, and hard against the frame's left edge —
       nothing shares the row, so nothing can indent or squeeze it. */
    grid-column: 1 / -1;
    justify-self: start;
    min-width: 0;
    max-width: 30vw;
    font-size: var(--fs-2xs);
    padding: 3px 5px;
    /* The base sheet has it position:static; the fold tab needs it as a containing block. */
    position: relative;
    transition: max-width var(--t-med) var(--ease-out);
  }
  #stats .bar {
    height: 11px;
  }
  #stats .bar .label {
    font-size: 8px;
    /* Match the bar height (base pattern: bar height == label line-height) so the
       HP/XP numbers aren't clipped top/bottom by the bar's overflow:hidden. */
    line-height: 11px;
  }
  #stats .name {
    max-width: 22vw;
    font-size: var(--fs-2xs);
  }
  #diamonds {
    font-size: 9px;
  }
  /* The guest sign-in prompt would nearly double the badge height — hide it on
     phones (the Log out / sign-in still live in the menus). */
  #hudSignin .hint {
    display: none;
  }
  #partyHud {
    /* Compact panel on the RIGHT, stacked directly under the minimap. The map
       is at top:10px and its "small" mode renders ~96px tall on mobile (JS-driven
       inline size, and mobile can't cycle map modes), so 112px clears it. */
    left: auto;
    right: max(10px, env(safe-area-inset-right));
    top: 112px;
    min-width: 0;
    max-width: 40vw;
    font-size: 10px;
    transition: max-width var(--t-med) var(--ease-out);
  }

  /* ---- fold tabs (hud/fold.js) ------------------------------------------------
     A phone shows these two panels over the playfield the whole run, so each gets a tab on
     its OUTER edge that folds it to a strip: the frame's on its right (it is anchored left),
     the party panel's on its left (it is anchored right). The tab sits fully outside the
     panel so it never covers a readout, and pointer-events opts back in — #hud is none, the
     same reason .tcBurger restates it. The chevron points the way that edge will travel on
     the next tap, which is the whole affordance; it comes from ::before so one HTML builder
     can serve two tabs facing opposite ways. */
  .hudFold {
    display: flex;
    align-items: center;
    justify-content: center;
    position: absolute;
    z-index: 1;
    pointer-events: auto;
    width: 18px;
    height: 34px;
    padding: 0;
    font-size: var(--fs-sm);
    line-height: 1;
    color: var(--ink);
    background: var(--glass-3);
    border: 1px solid var(--stroke-2);
    backdrop-filter: blur(var(--blur-1));
    box-shadow: var(--elev-1), var(--glass-hilite);
  }
  .hudFold:active {
    background: rgba(255, 255, 255, 0.15);
  }
  #stats > .hudFold {
    right: -20px;
    top: 50%;
    transform: translateY(-50%);
    border-left: none;
    border-radius: 0 var(--r-md) var(--r-md) 0;
  }
  /* Hit slop, grown OUTWARD rather than the symmetric ::after the other isolated controls use
     (.winClose / #sfxMute / .diamonds, bottom of this sheet): an 18px tab needs ~44px of target,
     and spending that inward would park it over #diamonds — the one thing inside the frame that
     opts back into pointer events, and a tap there is meant to open the Shop. */
  #stats > .hudFold::after {
    content: '';
    position: absolute;
    inset: -6px -20px -6px -6px;
  }
  #partyHud > .hudFold::after {
    content: '';
    position: absolute;
    inset: -6px -6px -6px -20px;
  }
  #stats > .hudFold::before {
    content: '‹';
  }
  #stats.folded > .hudFold::before {
    content: '›';
  }
  /* Pinned near the panel's top, not centred: the party grows downward as members join, and a
     centred tab would walk down the screen every time someone did. */
  #partyHud > .hudFold {
    left: -20px;
    top: 8px;
    border-right: none;
    border-radius: var(--r-md) 0 0 var(--r-md);
  }
  #partyHud > .hudFold::before {
    content: '›';
  }
  #partyHud.folded > .hudFold::before {
    content: '‹';
  }
  /* Folded frame: the level pill and both bars, nothing else. The bar LABELS go too — the
     panel is too narrow to hold "90/100" without the numbers colliding with the fill.
     #spBadge deliberately stays: it is the game's only "you have unspent skill points" nudge
     and is already a tiny pill riding beside the level. */
  #stats.folded {
    min-width: 88px;
    max-width: 128px;
  }
  #stats.folded .name,
  #stats.folded #diamonds,
  #stats.folded #hudSignin,
  #stats.folded #hudDiscordBtn,
  #stats.folded #logoutBtn,
  #stats.folded .bar .label {
    display: none;
  }
  #stats.folded .bar {
    height: 8px;
  }
  /* Folded party: a stack of thin healthbars and nothing else. Names and levels are the wide
     part; the bar is the part you actually watch mid-fight, so it is the part that survives. */
  #partyHud.folded {
    min-width: 62px;
    max-width: 76px;
    padding: 4px 6px;
  }
  #partyHud.folded h4,
  #partyHud.folded .pmTop {
    display: none;
  }
  #partyHud.folded .pm {
    padding: 2px 0;
  }
  #partyHud.folded .pm + .pm {
    border-top: none;
  }
  #partyHud.folded .pmBar {
    height: 6px;
  }
  /* The perks strip rides the top row as the ☰'s right-hand neighbour (touch.js moves the
     node into #hudTopLeft), so it drops its own anchor and lays out in the grid cell. Chips
     shrink to read as a secondary strip beside the 44px ☰, and centre against it.
     The row needs no width cap of its own: the cell is bounded by #hudTopLeft's own
     left…right band, which already reserves the top-right clock + minimap column and is
     measured in the REAL frame — a vw cap here tracked the viewport, not #rot (width:100dvh
     when rotated), so it measured the wrong axis. Wrapping grows this row and pushes #stats
     down, which is why it can wrap at all. */
  #buffStrip {
    --buff-chip: 26px;
    --buff-gap: 4px;
    position: static;
    transform: none;
    max-width: none;
    align-self: center;
    justify-content: flex-start;
    flex-wrap: wrap;
  }
  /* Both derived from --buff-chip (inherited from the strip) so the artwork and the ×count
     badge can never drift from the tile they sit in — the desktop rules are fixed pixels
     sized for a 34px chip and would overflow this one. */
  #buffStrip .buff img {
    width: calc(var(--buff-chip) - 8px);
    height: calc(var(--buff-chip) - 8px);
  }
  #buffStrip .buff .amt {
    right: -3px;
    bottom: -3px;
    min-width: 12px;
    padding: 0 2px;
    font-size: 8px;
    line-height: 12px;
  }
  /* A narrower row means a taller stack, which can reach down toward the bottom-left
     joystick. The tile only re-enables pointer events for its hover tooltip — dead
     weight on touch — so drop them here and a chip can never swallow a thumb press. */
  #buffStrip .buff {
    pointer-events: none;
  }
  /* On phones the corner is a two-row grid: [☰ | perks] on top, the shrunk player frame
     spanning below it (every other control moved inside the menu). A grid, not a flex row,
     so the frame's y is whatever the perk row's height turns out to be — perks may wrap
     onto a second row and the frame simply moves down instead of disappearing under them.
     `right` reserves the top-right clock (right:114 + ~100px chip) + minimap column, and so
     bounds the perk row too.
     --hud-top is shared with --burger-band below so the rail's icons can start clear of
     the floating ☰; the portrait block re-expresses it for the rotated frame. */
  #hudTopLeft {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    gap: var(--sp-2);
    left: max(14px, env(safe-area-inset-left));
    top: var(--hud-top);
    right: 224px;
  }
  /* End Run lives in the menu rail's footer on mobile: full rail width, and visibly a
     text button rather than one more icon — it ends the run. */
  #endRun {
    padding: 6px 9px;
    font-size: var(--fs-2xs);
  }
  #menuRail #endRun {
    width: 100%;
    min-height: 40px;
    border-radius: var(--r-md);
  }
  /* Minimap canvas size is driven by JS (the M-toggle modes) via inline styles,
     so there's no fixed canvas width/height override here.
     Native-landscape phones (pointer:coarse, no #rot rotation): top-right corner,
     clear of the bottom-corner joystick/attack thumbs. Portrait (rotated) overrides
     this below — the rotation would otherwise drop it onto the attack button. */
  #mini {
    /* Safe-area maxes keep the corner cluster off a notch/rounded corner in
       NATIVE landscape; env() insets are physical, so the rotated-portrait block
       below re-expresses them against the rotated frame's edges. */
    right: max(10px, env(safe-area-inset-right));
    bottom: auto;
    top: max(10px, env(safe-area-inset-top));
  }
  /* #hud is pointer-events:none, so the map must opt back in for a tap to reach its
     own listener (touch.js cycles the modes — the phone's twin of the M hotkey).
     Gated on .mobile, not the media query: a narrow DESKTOP window matches this block
     too, and there the map stays click-through so a click over it still attacks. */
  .mobile #mini {
    pointer-events: auto;
  }
  /* Day/night clock: on phones sit it at the TOP, immediately left of the corner
     minimap, as one compact top-right cluster (was floating bottom-right, over the
     touch thumbs). right = map inset (10) + map ~96px + ~8 gap; smaller type/padding
     to match. #clockText stays a stacked column so the chip stays narrow beside the map. */
  #clock {
    top: max(10px, env(safe-area-inset-top));
    bottom: auto;
    /* 114 = map inset (10) + map ~96 + gap; keep the +104 offset off the map's
       own safe-area-adjusted inset. */
    right: calc(max(10px, env(safe-area-inset-right)) + 104px);
    gap: 6px;
    padding: 4px 8px;
  }
  /* The `big` map lifts the desktop bottom-right stack off the taller map
     (body.minimap-big #clock). Here the clock is TOP-anchored, so that bottom
     would pin both edges and deform the chip — and it outranks the plain `#clock`
     above, so re-state the reset at matching specificity. What the mobile cluster
     does need is the sideways twin of that lift: the big map is 192px wide against
     the small map's 96, so slide the clock past its wider edge (192 + 8 gap). */
  body.minimap-big #clock {
    bottom: auto;
    right: calc(max(10px, env(safe-area-inset-right)) + 200px);
  }
  #clock #clockGlyph {
    font-size: 14px;
  }
  #clock #clockPhase {
    font-size: 10px;
  }
  #clock #clockCountdown {
    font-size: 9px;
  }
  #clock #clockWeather {
    font-size: 9px;
    gap: 3px;
  }
  #clock #happyHour {
    font-size: 9px;
    gap: 3px;
  }
  /* Menus go full-screen sheets with bigger tap targets. The dock is hidden on touch —
     one ☰ (touch.js) opens the menu, and the rail inside it switches panels. */
  #menuDock {
    display: none;
  }
  /* --- the full-screen sheet ---------------------------------------------------
     Edge to edge, minus the rail: #menuLayer is already inset:0 inside #rot, so this
     fills the whole play area in BOTH orientations (the rot frame IS the landscape box —
     no dvh/dvw remap needed) and nothing of the game shows through. JS skips inline
     left/top on mobile (menus.js placeWindow), so these win. */
  #rot {
    --rail-cell: 44px; /* platform-guideline touch target */
    --rail-w: calc(2 * var(--rail-cell) + 12px); /* two columns + gap + padding */
    --sheet-head: 36px;
    --hud-top: max(14px, env(safe-area-inset-top)); /* where #hudTopLeft — and so ☰ — starts */
    --burger-band: calc(var(--hud-top) + 44px + 6px); /* ☰ height + breathing room */
  }
  .menuWin {
    inset: 0 0 0 var(--rail-w);
    width: auto;
    height: auto;
    max-height: none;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    /* OPAQUE, unlike the desktop window's glass: "fully full-screen" means none of the
       live world showing through — and dropping the backdrop-filter here removes the
       priciest effect on a mobile GPU from the largest surface on screen. */
    background: var(--bg-1);
    backdrop-filter: none;
    animation: fc-sheet-up var(--t-enter) var(--ease-out);
  }
  /* The window is a flex column with a definite height now, so the body — not the
     window — takes the overflow. min-height:0 lets it shrink below its content. */
  .menuWin > .winBody {
    flex: 1 1 auto;
    min-height: 0;
    overflow-x: hidden;
  }
  /* A slim header: the panel's name, and one ✕. The rail is the switcher, so this bar
     carries no icons — it's the cheapest thing that can tell you where you are, on the
     axis landscape can least afford. */
  .sheetBar {
    flex: none;
    min-height: var(--sheet-head);
    padding: 4px 8px 4px 12px;
    cursor: default;
  }
  .sheetBar .winTitle {
    font-size: var(--fs-sm);
    letter-spacing: 0.02em;
    text-transform: uppercase;
    color: var(--ink-dim);
  }
  /* The Style sheet is the one exception to edge-to-edge: its content IS your character
     out in the world, so it left-binds and lets the world show to its right (render.js
     pans the camera there). Everything else still hides behind it. */
  .menuWin[data-panel='style'] {
    inset: 0 auto 0 var(--rail-w);
    width: 58%;
    max-width: 440px;
  }
  /* Preview canvas is gone — lay the Style rows out as a single column. */
  .mobile .menuWin[data-panel='style'] .winBody {
    flex-direction: column;
    align-items: stretch;
  }
  /* --- the rail ---------------------------------------------------------------- */
  #menuRail {
    display: flex;
    flex-direction: column;
    position: absolute;
    inset: 0 auto 0 0;
    width: var(--rail-w);
    /* Sibling of the windows inside #menuLayer, and it must stay above every one of them:
       clear the window band, which restackWindows fills from 1 upward (one per open panel). */
    z-index: 100;
    padding: 6px 4px calc(6px + env(safe-area-inset-bottom));
    background: var(--bg-0);
    border-right: 1px solid var(--stroke-2);
    pointer-events: auto;
    animation: fc-sheet-up var(--t-enter) var(--ease-out);
  }
  #menuRail.hidden {
    display: none;
  }
  /* --- what the open menu hides -------------------------------------------------
     Everything except the ☰. The overlay is edge to edge, so the play surfaces behind
     it are not just covered but taken out of the layout: no thumb can reach the joystick
     or the attack pad through the sheet, and the browser stops compositing them.
     menus.js also releases any captured touch input when the class goes on, so hiding
     the controls can't strand the character walking or swinging.
     The CHILD combinator is load-bearing: the deck and the chat panel are MOVED into a
     sheet while their panel is open (menus.js dockNode), and a descendant selector would
     hide them there too — they are only play surfaces while they are still #rot's own
     children. */
  #rot.sheet-open > #loadoutBar,
  #rot.sheet-open > #touchControls,
  #rot.sheet-open > #chatPanel {
    display: none;
  }
  /* The ☰ is the exception: it floats ABOVE the sheet and becomes the closer, so the one
     control a thumb always knows how to find never goes away. #hud carries z-index 20 and
     is therefore a stacking context — a child can't escape it — so the whole HUD layer is
     promoted over the menu and then emptied of everything but the button. #hud stays
     pointer-events:none, so only .tcBurger (which opts back in) is tappable; and it sits
     BEFORE #menuLayer in the document, so at equal z-index the slot picker (--z-pick, 29)
     still paints over it. */
  #rot.sheet-open > #hud {
    z-index: calc(var(--z-menu) + 1);
  }
  #rot.sheet-open #hud > *:not(#hudTopLeft) {
    display: none;
  }
  /* Clears the perks strip too, now that it lives in this corner rather than out in #hud.
     visibility, NOT display: the grid's tracks are sized from the boxes in them, so removing
     the ☰'s neighbours would resize row 1 / column 1 and slide the button the instant the
     menu opened — the exact drift this redesign set out to make impossible. Keeping the
     boxes keeps the ☰ pixel-identical open or closed. */
  #rot.sheet-open #hudTopLeft > *:not(.tcBurger) {
    visibility: hidden;
  }
  /* Items sheet: the loadout deck is DOCKED into the window as a sticky footer row
     (menus.js liftLoadoutBar) rather than floating above it, so bag items can still be
     dragged onto its cells to equip. It's a sibling of .winBody, which innerHTML
     rebuilds on every render — inside it, the deck would be destroyed. */
  .menuWin[data-panel='inventory'] > #loadoutBar {
    /* `relative`, not `static`: the deck's rows are absolutely stacked (see #loadoutBar
       above), so they need this to stay their containing block — static would anchor them
       to the sheet and drop the deck off its bottom edge. And an explicit height, for the
       same reason: with only absolute children the box would collapse to nothing. */
    position: relative;
    flex: none;
    left: auto;
    bottom: auto;
    transform: none;
    width: auto;
    height: 84px;
    margin-bottom: env(safe-area-inset-bottom);
    border-top: 1px solid var(--stroke-2);
  }
  /* Chat sheet: the panel IS the body (menus.js docks the live #chatPanel node in), so
     the empty .winBody steps aside and the panel fills the sheet. The keyboard inset
     comes from keyboard.js's visualViewport listener — the frame's bottom normally, its
     right edge under the forced-landscape turn (config.sheetKeyboardInset). */
  .menuWin[data-panel='chat'] > .winBody {
    display: none;
  }
  .menuWin[data-panel='chat'] > #chatPanel {
    position: static;
    flex: 1 1 auto;
    min-height: 0;
    width: auto;
    max-width: none;
    max-height: none;
    border: 0;
    border-radius: 0;
    background: none;
    backdrop-filter: none;
    box-shadow: none;
    padding-bottom: var(--kb-inset-bottom, 0px);
    padding-right: var(--kb-inset-right, 0px);
  }
  /* Items panel on mobile: one column, one scrollbar. Stack the layout in source
     order (tools → bench+bag → recipes) at full width so the bench sits directly above
     the inventory instead of the tool column squishing them side-by-side, and let the
     sheet's .winBody own the single scroll (drop the inner bag/recipe height caps so they
     no longer nest their own scrollbars). Tool slots run as a left-aligned horizontal row.
     Craft mode overrides the scroll ownership below — .mobileInv and the Upgrade Bench
     (which has no layout wrapper at all) are the ones the sheet body still scrolls. */
  .menuWin[data-panel='inventory'] .itemsLayout {
    flex-direction: column;
    flex-wrap: nowrap;
    gap: 10px;
  }
  .menuWin[data-panel='inventory'] .itemsLayout > * {
    width: 100%;
    min-width: 0;
  }
  .menuWin[data-panel='inventory'] .invWrap,
  .menuWin[data-panel='inventory'] .recipeList {
    max-height: none;
    overflow-y: visible;
  }
  .menuWin[data-panel='inventory'] .toolSlots {
    max-width: none;
    justify-content: flex-start;
  }
  .menuWin[data-panel='inventory'] .toolCell {
    width: 44px;
    min-height: 50px;
  }
  .menuWin[data-panel='inventory'] .toolCell img {
    width: 28px;
    height: 28px;
  }
  /* The Tools column folds behind a chevron in its header (collapsed by default) to free
     the cramped sheet for the bench + Craft buttons. Chevron is a ::before like .hudFold;
     ::after grows the tap target outward without covering the first tool cell. */
  .menuWin[data-panel='inventory'] .toolsFold {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    position: relative;
    width: 32px;
    height: 24px;
    padding: 0;
    background: rgba(0, 0, 0, 0.25);
    border: 1px solid rgba(255, 255, 255, 0.2);
    border-radius: 6px;
    color: var(--ink-dim);
  }
  .menuWin[data-panel='inventory'] .toolsFold::before {
    content: '▾';
  }
  .menuWin[data-panel='inventory'] .toolsFold[aria-expanded='false']::before {
    content: '▸';
  }
  .menuWin[data-panel='inventory'] .toolsFold::after {
    content: '';
    position: absolute;
    inset: -8px -10px -8px -6px;
  }
  .menuWin[data-panel='inventory'] .toolSlots.folded {
    display: none;
  }
  /* Default Items view on mobile (renderItems 'none' → .mobileInv): armor paperdoll, bag
     and card sockets as THREE columns across the sheet's width, overriding the
     single-column stack above. Landscape gives ~790px of pane here, which fits all three
     without scrolling; the flex BASES (not fixed widths) let the socket column wrap
     underneath on a small phone rather than crushing the bag. */
  .menuWin[data-panel='inventory'] .itemsLayout.mobileInv {
    flex-direction: row;
    flex-wrap: wrap;
    gap: var(--sp-2);
    align-items: flex-start;
  }
  .menuWin[data-panel='inventory'] .itemsLayout.mobileInv > * {
    width: auto;
  }
  /* A basis, not `auto`: the paperdoll is only ~150px wide but its heading runs long, and
     an auto-sized column takes the HEADING's width — which ate the room the socket column
     needs to sit beside the bag. Let the heading wrap instead. */
  .itemsLayout.mobileInv .armorCol {
    flex: 0 1 200px;
    min-width: 0;
  }
  .itemsLayout.mobileInv .bagCol {
    flex: 1 1 260px;
    min-width: 0;
  }
  .itemsLayout.mobileInv .socketCol {
    flex: 1 1 220px;
    min-width: 0;
  }
  /* Shrink the paperdoll a touch so the bag keeps room in the row on a phone. */
  .itemsLayout.mobileInv .armorCell {
    width: 44px;
    height: 44px;
  }
  .itemsLayout.mobileInv .armorCell img {
    width: 34px;
    height: 34px;
  }
  /* Craft mode on mobile (renderItems 'craft' → .craftMobile): TWO panes that scroll
     independently — swipe the ~1/3 left pane and only the tool + staging slots and the Craft
     button move; swipe the right ~2/3 and only the bag + known-recipes list move. The left
     pane used to be merely `position: sticky` over a sheet that was the single scroller, so a
     swipe anywhere dragged both. Sizing the layout to exactly fill .winBody (height:100%,
     which resolves because .winBody's height comes out of the sheet's flex column) means the
     BODY can no longer overflow, and each column then owns its own. The rule above still
     uncaps .invWrap/.recipeList, which is what keeps the right pane one continuous scroll
     rather than two nested ones. Overrides the single-column stack, mirroring .mobileInv. */
  .menuWin[data-panel='inventory'] .itemsLayout.craftMobile {
    flex-direction: row;
    flex-wrap: nowrap;
    gap: var(--sp-2);
    align-items: stretch; /* not flex-start: each column needs the pane's full height to scroll inside */
    height: 100%;
    min-height: 0;
  }
  .menuWin[data-panel='inventory'] .itemsLayout.craftMobile > * {
    width: auto;
  }
  /* touch-action on both panes is required, not hygiene: html/body set `touch-action: none` so
     nothing competes with the world's touch controls, and that intersects down the ancestor
     chain — a pane that scrolls has to grant the pan back. `contain` stops the pane's scroll
     from chaining out to the sheet when it hits an end. */
  .itemsLayout.craftMobile .benchCol {
    flex: 0 0 34%;
    min-width: 0;
    min-height: 0;
    overflow-y: auto;
    touch-action: pan-y;
    overscroll-behavior: contain;
  }
  .itemsLayout.craftMobile .bagCol {
    flex: 1 1 0;
    min-width: 0;
    min-height: 0;
    overflow-y: auto;
    touch-action: pan-y;
    overscroll-behavior: contain;
  }
  /* Shop stock is a column of wide rows; on a landscape sheet that wastes most of the
     width and pushes the stock off the bottom. Flow the rows into as many columns as fit
     (>=320px each), so a full stock list is visible without scrolling. */
  .menuWin[data-panel='shop'] .shopGrid {
    flex-direction: row;
    flex-wrap: wrap;
  }
  .menuWin[data-panel='shop'] .shopItem {
    flex: 1 1 320px;
    min-width: 0;
  }
  /* The fixed modal cards (start/death/dev/name/kick/place/shop-confirm) have no height
     budget by default — on a short landscape screen a tall stack clips and can hide the
     buttons. Cap them to the viewport height and let the card scroll instead. */
  .card {
    max-height: 86dvh;
    overflow-y: auto;
  }
  /* The entry/flow overlays centre a big title beside the card; when the two together
     exceed the short landscape height, top-align and let the overlay scroll so nothing
     clips. (On TOUCH the `.mobile #dead` block overrides this for #dead: it never scrolls
     there, so only its recap column does.) */
  #start,
  #dead,
  #devModal {
    justify-content: flex-start;
    overflow-y: auto;
    padding: 16px 0;
  }
  /* #dead's title is fixed 54px — far too tall here. This covers a NARROW DESKTOP window
     (the `.mobile` block clamps it again, tighter, for touch). */
  #dead h2 {
    font-size: clamp(28px, 7vw, 54px);
  }
  /* --- chat's playing-field peek -----------------------------------------------
     On phones chat has two presentations of ONE node (menus.js dockChat moves it): the
     full panel, docked in the menu's Chat sheet, and this read-only peek out on the
     field — the last few lines of the active channel, no tabs, no input, and
     click-through so it can never swallow a thumb aimed at the world behind it. It sits
     centred between the joystick and the ⚔/🛡 cluster, above the kill-streak badge band —
     the compact badge (killstreak.js) owns the slot just above the loadout deck. Its
     lines expire on their own — see the .chatPeek fade near the keyframes. Unread
     messages still tick the rail's 💬 badge, so nothing is missed while only the peek
     is showing. */
  #chatPanel.chatPeek {
    /* Split into four vars so the two follow-on blocks can each override ONE concern
       without fighting: the short-frame blocks shrink the thumb clusters (--peek-stick /
       --peek-actions), the forced-landscape block remaps which safe-area edge is which
       (--peek-edge-*). Same-specificity rules setting the same property would just fall
       to source order, and the generic block comes last. */
    --peek-stick: 144px; /* joystick (132) + gap */
    --peek-actions: 196px; /* ⚔ (92) + gap + 🛡 (76) + gap */
    --peek-edge-left: max(18px, env(safe-area-inset-left));
    --peek-edge-right: max(18px, env(safe-area-inset-right));
    left: calc(var(--peek-edge-left) + var(--peek-stick));
    right: calc(var(--peek-edge-right) + var(--peek-actions));
    width: auto;
    max-width: none;
    /* Above the kill-streak badge band: the compact mobile badge tops out ≤ ~178px
       from the bottom at max rank, pop included (killstreak.js MOBILE_NUM_MAX). */
    bottom: 180px;
    pointer-events: none;
    max-height: 72px;
    padding: 4px 6px;
    background: none;
    border-color: transparent;
    backdrop-filter: none;
    box-shadow: none;
  }
  #chatPanel.chatPeek #chatTabs,
  #chatPanel.chatPeek #chatInputRow,
  #chatPanel.chatPeek #chatError {
    display: none;
  }
  /* Unbacked over the world, so the lines need their own legibility. */
  #chatPanel.chatPeek #chatLog {
    overflow: hidden;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
  }
  /* A basis rather than a fixed height, so the log — not the panel — absorbs any height
     cap on short frames. min-height:0 lets it shrink past its content. */
  #chatLog {
    flex: 1 1 130px;
    min-height: 0;
  }
  /* The deck sits at the very bottom, in the gap between the corner thumbs — during play
     it's a glanceable, tappable hotbar, so it steals as little play area as possible.
     While Items is open it's docked INSIDE the sheet instead (see the sheet block above),
     where the roomier `.lifted` sizing below gives drags a comfortable target. */
  #loadoutBar {
    bottom: 8px;
    height: 48px;
  }
  .loadoutRow {
    --peek: 10px; /* tight fan on the slim deck */
    gap: 6px;
    padding: 3px 7px;
  }
  .loadoutNum {
    font-size: var(--fs-2xs);
    width: 12px;
  }
  /* No R key on touch — hide the letter and show a rotation glyph in its place,
     keeping the gold keycap box as the "tap to cycle the deck" hint. */
  .loadoutKey {
    font-size: 0;
  }
  .loadoutKey::before {
    content: '\27F3'; /* ⟳ clockwise rotation arrow */
    font-size: 16px;
    color: #000;
    line-height: 1;
  }
  .loadoutSlots {
    gap: var(--sp-1);
  }
  .loadoutCell {
    /* Resting deck stays compact but tappable (was 28px — too small a target). */
    width: 36px;
    height: 36px;
    border-radius: 8px;
  }
  .loadoutCell img {
    width: 28px;
    height: 28px;
  }
  /* Docked in the Items sheet ⇒ grow the deck back to the roomy size, so dragging a bag
     item onto a cell has a comfortable target. Being a CHILD of the window is the "full
     size" signal here — the mobile deck is only ever docked while Items is open. */
  .menuWin > #loadoutBar .loadoutRow {
    --peek: 16px;
    gap: var(--sp-2);
    padding: 4px 9px;
  }
  .menuWin > #loadoutBar .loadoutNum {
    font-size: var(--fs-sm);
    width: 14px;
  }
  .menuWin > #loadoutBar .loadoutKey::before {
    font-size: 22px;
  }
  .menuWin > #loadoutBar .loadoutSlots {
    gap: 5px;
  }
  .menuWin > #loadoutBar .loadoutCell {
    width: 40px;
    height: 40px;
    border-radius: 10px;
  }
  .menuWin > #loadoutBar .loadoutCell img {
    width: 32px;
    height: 32px;
  }
  .slot {
    width: 54px;
    height: 54px;
  }
}

/* ---- short frame: slim the bottom thumb band --------------------------------
   Joystick (left), Attack/Defend (right) and the loadout deck (centre) all share
   the bottom band; on a short frame the full-size clusters crowd it. Shrink them
   (everything stays >44px). "Short frame" is a different physical axis per
   orientation: native landscape ⇒ small viewport HEIGHT; forced landscape
   (portrait + #rot rotation) ⇒ the frame's height is 100dvw, so a small viewport
   WIDTH. Two gates, same values. */
@media (pointer: coarse) and (orientation: landscape) and (max-height: 400px) {
  .tcStick {
    width: 108px;
    height: 108px;
  }
  .tcKnob {
    width: 48px;
    height: 48px;
  }
  .tcActions .tcBtn {
    width: 64px;
    height: 64px;
    font-size: 26px;
  }
  .tcActions .tcAttack {
    width: 78px;
    height: 78px;
    font-size: 32px;
  }
  /* Both thumb clusters shrink here (above), so the peek's band between them widens by
     the same amounts. The rail narrows too: ten entries have to fit a frame this short,
     and every icon stays a 40px target. */
  #chatPanel.chatPeek {
    --peek-stick: 120px; /* 108 + gap */
    --peek-actions: 168px; /* 78 + gap + 64 + gap */
    /* At bottom:180px on a ≤400px frame the peek's top must stay clear of the
       top HUD rows, so it gets fewer lines here. */
    max-height: 48px;
  }
  #chatLog {
    flex-basis: 110px;
  }
  #rot {
    --rail-cell: 40px;
  }
  #menuRail #endRun {
    min-height: 34px;
  }
}
@media (orientation: portrait) and (max-width: 400px) {
  .mobile .tcStick {
    width: 108px;
    height: 108px;
  }
  .mobile .tcKnob {
    width: 48px;
    height: 48px;
  }
  .mobile .tcActions .tcBtn {
    width: 64px;
    height: 64px;
    font-size: 26px;
  }
  .mobile .tcActions .tcAttack {
    width: 78px;
    height: 78px;
    font-size: 32px;
  }
  .mobile #chatPanel.chatPeek {
    --peek-stick: 120px;
    --peek-actions: 168px;
    max-height: 48px;
  }
  .mobile #chatLog {
    flex-basis: 110px;
  }
  .mobile #rot {
    --rail-cell: 40px;
  }
  .mobile #menuRail #endRun {
    min-height: 34px;
  }
}

/* ---- short landscape: two-column Heads-up gate -----------------------------
   In landscape the play area is wide-but-short (~380–430px tall), so the tall
   single-column card would push its "I understand" CTA below a scroll. Flow the
   #devModal card into columns and widen it. This block is native landscape (phone
   held sideways); the forced-landscape frame (portrait + #rot rotation) gets the
   same treatment sized against dvh/dvw in the block below. (#dead used to be
   styled here too, in lockstep; it now has one orientation-agnostic block instead
   — see "mobile death screen" below.) */
@media (pointer: coarse) and (orientation: landscape) and (max-height: 540px) {
  #devModal .card {
    width: min(680px, 92vw);
    max-height: 92dvh;
    gap: 10px;
    padding: 14px 16px;
  }
  #devModal .cardCol {
    display: flex;
    flex-direction: column;
    gap: var(--sp-2);
    /* Hug content (not a rigid 50%) and centre it, so buttons/text sit under
       the card centre rather than in the right half. */
    flex: 0 1 auto;
    align-items: center;
    text-align: center;
    min-width: 0;
    min-height: 0;
  }
  /* The Heads-up gate stacks its warning ABOVE the buttons (not beside them), and
     lays the buttons out in a row. */
  #devModal .cardCols {
    display: flex;
    flex-direction: column;
    gap: 14px;
    min-height: 0;
    justify-content: center;
    align-items: center;
  }
  #devModal .cardCol.devBtns {
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: center;
  }
  /* When the roadmap expands, span the full width below the button row instead of
     squeezing between the buttons. */
  #devModal .cardCol.devBtns #devRoadmap {
    flex-basis: 100%;
  }
  #devRoadmap {
    max-height: 30dvh;
    overflow-y: auto;
  }
}

/* ---- forced landscape (mobile portrait only) -------------------------------
   On a touch device held in PORTRAIT, rotate the whole app 90° clockwise so the
   game plays landscape — the widest usable play area. #rot wraps every element
   (index.html), so its transform makes it the containing block for all the
   position:fixed overlays inside, and they rotate together with the canvas.
   The element is sized to the LANDSCAPE box (viewport height × width) and rotated
   back over the portrait viewport; render.js swaps the canvas dimensions to match
   and rotates pointer/joystick vectors by the same quarter-turn (config.rotateVec).
   When the phone is turned to landscape this media query goes false → no rotation
   → native landscape (no double-rotation). Desktop never has `.mobile`, so it's
   inert there. dvh/dvw track the real viewport as the URL bar shows/hides. */
@media (orientation: portrait) {
  .mobile #rot {
    position: fixed;
    top: 0;
    left: 0;
    width: 100dvh;
    height: 100dvw;
    transform-origin: top left;
    transform: rotate(90deg) translateY(-100%);
  }
  /* The on-screen controls now live INSIDE #rot too (touch.js), so the joystick sits
     at the frame's bottom-left and Attack/Defend at its bottom-right — the same corners
     the minimap used to borrow. Tuck the map under the players panel at the frame's
     top-right, clear of both thumb clusters, the top-center menu row, and the bottom
     loadout deck. Frame-relative ⇒ it lands top-right of the landscape view the player
     sees, regardless of viewport size. */
  .mobile #mini {
    left: auto;
    bottom: auto;
    right: max(10px, env(safe-area-inset-bottom));
    top: 92px;
  }
  /* …but the `full` mode is a CENTRED pop-up, not a corner map. `#mini.fullscreen`
     ties this rule on specificity and loses on source order, so its centring would
     keep the corner anchors above and land the overlay half off the top-right edge.
     Re-state the centring here (the transform still comes from .fullscreen). */
  .mobile #mini.fullscreen {
    right: auto;
    bottom: auto;
    left: 50%;
    top: 50%;
  }
  /* Rotated frame: the map sits lower (top:92px) — keep the clock beside it.
     The smaller type inherits from the base mobile block above; right is
     re-expressed because env() insets are PHYSICAL and don't rotate with #rot —
     under the 90° CW turn the frame's edges map to
     left←inset-top (the notch), top←inset-right, right←inset-bottom (home bar),
     bottom←inset-left. Same remap for the frame-edge rules below. */
  .mobile #clock {
    top: 92px;
    right: calc(max(10px, env(safe-area-inset-bottom)) + 104px);
  }
  /* The wider `big` map again — re-expressed against the rotated frame's edge, and
     one class heavier so it outranks the `.mobile #clock` inset just above. */
  body.minimap-big.mobile #clock {
    right: calc(max(10px, env(safe-area-inset-bottom)) + 200px);
  }
  /* --hud-top, not a bare `top`: the rail's icons pad down by --burger-band so they start
     clear of the floating ☰, and that band has to track the SAME remapped inset. */
  .mobile #rot {
    --hud-top: max(14px, env(safe-area-inset-right));
  }
  /* One remap for the whole corner: the perks strip lays out INSIDE this box now, so it no
     longer needs (or has) an inset of its own to keep in sync. */
  .mobile #hudTopLeft {
    left: max(14px, env(safe-area-inset-top));
  }
  .mobile .tcStick {
    left: max(18px, env(safe-area-inset-top));
    bottom: max(26px, env(safe-area-inset-left));
  }
  .mobile .tcActions {
    right: max(18px, env(safe-area-inset-bottom));
    bottom: max(26px, env(safe-area-inset-right));
  }
  /* Party list stays under the minimap; the map sits lower here (top:92px), so
     drop it to 194px (92 + ~96px map + gap). Inherits the smaller font/width. */
  .mobile #partyHud {
    left: auto;
    right: max(10px, env(safe-area-inset-bottom));
    top: 194px;
  }
  /* Rotated frame: #rot is width:100dvh × height:100dvw. The full-screen menu sheet is
     `inset:0` inside #menuLayer (itself inset:0 in #rot), so it already fills the rotated
     landscape frame exactly — no per-orientation size override needed (the old vw/vh menu
     caps that this block used to re-express are gone). The .card modals still size against
     vw/vh, so keep re-expressing their height cap against the frame's real height (dvw). */
  .mobile .card {
    max-height: 86dvw;
  }
  /* The peek is anchored off the same two thumb clusters, so its edges take the SAME
     remap as .tcStick / .tcActions above — left off inset-top, right off inset-bottom.
     (The chat SHEET needs nothing here: it fills #menuLayer, which is already the rotated
     frame; the keyboard inset it takes is computed in the same frame by
     config.sheetKeyboardInset.) */
  .mobile #chatPanel.chatPeek {
    --peek-edge-left: max(18px, env(safe-area-inset-top));
    --peek-edge-right: max(18px, env(safe-area-inset-bottom));
  }
}

/* Forced-landscape counterpart of the short-landscape Heads-up block above. Here the
   #rot frame is width:100dvh × height:100dvw, so the scarce axis is dvw (device
   width); gate on a small device width and size the card against dvh/dvw instead
   of vw/vh. Same structure, so the buttons stay on-screen. */
@media (orientation: portrait) and (max-width: 540px) {
  .mobile #devModal .card {
    width: min(680px, 92dvh);
    max-height: 88dvw;
    gap: 10px;
    padding: 14px 16px;
  }
  .mobile #devModal .cardCol {
    display: flex;
    flex-direction: column;
    gap: var(--sp-2);
    flex: 0 1 auto;
    align-items: center;
    text-align: center;
    min-width: 0;
    min-height: 0;
  }
  /* Heads-up gate: warning stacked above a row of buttons (see landscape block). */
  .mobile #devModal .cardCols {
    display: flex;
    flex-direction: column;
    gap: 14px;
    min-height: 0;
    justify-content: center;
    align-items: center;
  }
  .mobile #devModal .cardCol.devBtns {
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: center;
  }
  .mobile #devModal .cardCol.devBtns #devRoadmap {
    flex-basis: 100%;
  }
  .mobile #devRoadmap {
    max-height: 34dvw;
    overflow-y: auto;
  }
}

/* ---- mobile death screen: a scrolling recap beside a pinned spawn rail ------
   ONE block for both mobile orientations, where the modal blocks above need two.
   #dead is `position:fixed; inset:0` INSIDE #rot, and #rot carries the transform in
   forced landscape — so it is the containing block and `inset:0` already IS the
   landscape frame either way. Sizing by flex-fill + `min-height:0` therefore needs
   no dvh/dvw and no orientation gate: the overlay fills the frame, the card fills
   what the title leaves, and the columns fill the card.

   The frame is wide-but-short, so the two regions are side-by-side columns rather
   than stacked bands: the rail costs width (which there is) instead of height
   (which there isn't).
     .recap — the run recap + the stage notice. The ONLY scroller on the screen.
     .spawn — the destination field + Spawn. Outside every scroller by construction,
              so no recap length can push it off-screen.
   Rules are scoped `#dead …` so `.card button`'s accent gradient can't win the
   cascade over the cards/arrows (see the base block's note). */
.mobile #dead {
  justify-content: flex-start;
  /* The overlay itself never scrolls — that is what keeps Spawn on-screen. Content
     can't overflow it either: the card below shrinks instead (flex + min-height:0). */
  overflow: hidden;
  gap: 6px;
  padding: 8px 0;
}
.mobile #dead h2 {
  flex: none;
  /* vmin is the device's SHORT side, which is the frame's height axis in native
     landscape and in the rotated portrait frame alike — the one viewport-relative
     value here, and the reason this block needs no orientation twin. */
  font-size: clamp(18px, 6vmin, 30px);
}
.mobile #dead .card {
  /* Hug the two columns rather than sit at a fixed width, so the first-spawn overlay
     (empty recap) closes up around the rail instead of framing a void. */
  width: fit-content;
  max-width: min(680px, 94%);
  /* Fill the height the title leaves, and drop the generic mobile `.card` scroller
     (86dvh/86dvw + overflow-y:auto) — the recap column scrolls instead. */
  flex: 1 1 auto;
  min-height: 0;
  max-height: none;
  overflow: hidden;
  margin-top: 0;
  gap: 8px;
  padding: 10px 12px;
}
.mobile #dead .cardCols {
  display: flex;
  flex: 1 1 auto;
  min-height: 0;
  gap: 12px;
  /* Stretch so each column owns the full card height (the recap needs it to scroll,
     the rail to centre); cluster them so a short recap doesn't strand the rail right. */
  align-items: stretch;
  justify-content: center;
}
.mobile #dead .cardCol {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  align-items: center;
  text-align: center;
  min-width: 0;
  min-height: 0;
}
.mobile #dead .cardCol.recap {
  /* Hug content rather than grow, so the first-spawn overlay (empty #deathStats)
     keeps the pair centred instead of shoving the rail to the edge. */
  flex: 0 1 auto;
  /* A flat cap, not a percentage: the card above is fit-content, and a %-of-parent
     max-width inside a shrink-to-fit parent is a circular constraint. */
  max-width: 360px;
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* Span the capped column so the label↔value rows keep their spread. */
.mobile #dead .cardCol.recap .deathStats {
  width: 100%;
}
.mobile #dead .cardCol.spawn {
  flex: 0 0 auto;
  justify-content: center;
  /* Never a scroller — the whole point of the split. */
  overflow: visible;
  border-left: 1px solid var(--stroke-1);
  padding-left: 12px;
}
/* Keep the rail short enough to sit beside even the shortest landscape frame. Only
   non-tappable bulk is trimmed — the arrows and Spawn keep their full size, since the
   axis they cost is width, which the frame has. The destination card turns side-on
   (icon beside name, not above it), which is where most of the height goes. */
.mobile #dead .respawnBtns {
  gap: 6px;
}
/* A flat width, not the base's 40vw clamp: the frame is always landscape here, so
   width is the abundant axis and the destination name wants room to sit beside its
   icon on one or two lines. */
.mobile #dead .respawnTrack {
  width: 170px;
}
.mobile #dead .respawnCard {
  flex-direction: row;
  justify-content: center;
  gap: 6px;
  padding: 7px 8px;
}
.mobile #dead .respawnIcon {
  font-size: 22px;
}
.mobile #dead #respawnHint {
  font-size: var(--fs-xs);
}

/* ---- compact mobile Character panel ----------------------------------------
   Mobile always renders landscape (native, or portrait rotated via #rot), so the
   Character sheet is wide-but-short. The default single stacked column of large stat
   tiles overflows and forces heavy scrolling. Scope to `.mobile` (set for all touch
   devices in main.js) + the character panel: flow the .charSections into two balanced
   columns to use the width, and shrink headings/tiles so the whole sheet fits with
   little/no scrolling. Desktop (never `.mobile`) is untouched. The count is a MINIMUM
   width, not a fixed number: the sheet went edge-to-edge, so on a roomy landscape frame
   it now flows three columns and on a small phone still two. */
.mobile .menuWin[data-panel='character'] .charPanel {
  columns: 210px 3;
  column-gap: 14px;
}
.mobile .menuWin[data-panel='character'] .charSection {
  /* Keep each heading glued to its grid across the column break. */
  break-inside: avoid;
}
.mobile .menuWin[data-panel='character'] .winBody h3 {
  margin: 6px 0 3px;
  font-size: var(--fs-xs);
}
.mobile .menuWin[data-panel='character'] .charSection:first-child h3 {
  margin-top: 0;
}
.mobile .menuWin[data-panel='character'] .dashGrid {
  grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
  gap: 6px;
  margin: 4px 0 2px;
}
.mobile .menuWin[data-panel='character'] .dashStat {
  padding: 4px 8px;
}
.mobile .menuWin[data-panel='character'] .dashValue {
  font-size: 14px;
}
.mobile .menuWin[data-panel='character'] .dashLabel {
  font-size: 10px;
}
.mobile .menuWin[data-panel='character'] .buff {
  width: 28px;
  height: 28px;
}
.mobile .menuWin[data-panel='character'] .buff img {
  width: 22px;
  height: 22px;
}

/* ---- motion system ---------------------------------------------------------
   Entrance/feedback keyframes. Elements hidden via display:none restart their
   animation when revealed, so the entrances below need no JS. Everything is
   disabled under prefers-reduced-motion. */
@keyframes fc-fade-up {
  from {
    opacity: 0;
    transform: translateY(12px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
@keyframes fc-fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
/* Mobile menu sheet entrance: slide up from the bottom edge (native sheet feel). */
@keyframes fc-sheet-up {
  from {
    opacity: 0;
    transform: translateY(6%);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
@keyframes fc-scale-in {
  from {
    opacity: 0;
    transform: scale(0.96);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
@keyframes fc-toast-in {
  from {
    opacity: 0;
    transform: translate(-50%, 16px);
  }
  to {
    opacity: 1;
    transform: translate(-50%, 0);
  }
}
@keyframes fc-slide-left {
  from {
    opacity: 0;
    transform: translateX(-12px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
/* The mobile chat peek's line expiry (see the .chatPeek block). */
@keyframes fc-chat-peek-fade {
  to {
    opacity: 0;
  }
}
@keyframes fc-pulse {
  0%,
  100% {
    transform: scale(1);
    opacity: 1;
  }
  50% {
    transform: scale(1.06);
    opacity: 0.85;
  }
}
@keyframes fc-glow-pulse {
  0%,
  100% {
    box-shadow: var(--glow-accent);
  }
  50% {
    box-shadow: 0 0 26px rgba(255, 138, 31, 0.55);
  }
}
@keyframes fc-glow-pulse-danger {
  0%,
  100% {
    box-shadow: var(--danger-glow);
  }
  50% {
    box-shadow: 0 0 26px rgba(255, 91, 91, 0.6);
  }
}
@keyframes fc-bar-sheen {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(350%);
  }
}
@keyframes fc-shake {
  0%,
  100% {
    transform: none;
  }
  20%,
  60% {
    transform: translateX(-2px);
  }
  40%,
  80% {
    transform: translateX(2px);
  }
}

/* entrances */
#start .card,
#dead .card {
  animation: fc-fade-up var(--t-enter) var(--ease-out);
}
#nameModal .card,
#kickedModal .card,
#mergeModal .card,
#placeModal .card,
#shopConfirmModal .card,
#confirmModal .card {
  animation: fc-scale-in var(--t-enter) var(--ease-spring);
}
#chatPanel {
  animation: fc-slide-left var(--t-enter) var(--ease-out);
}
/* Peek lines clear the field by themselves: hold, then fade. The delay is per-line and
   comes from the markup (chat.js writes --peek-delay from the line's AGE), so no timer is
   involved and a line already part-way through resumes there — a negative animation-delay
   starts mid-ramp — when a new message rebuilds the log's innerHTML. chatlib.js owns the
   two numbers (CHAT_PEEK_HOLD_MS / CHAT_PEEK_FADE_MS) and drops fully-faded lines. */
#chatPanel.chatPeek .chatLine {
  animation: fc-chat-peek-fade 800ms var(--peek-delay, 0ms) linear forwards;
}
#partyHud {
  animation: fc-fade-in var(--t-enter) var(--ease-out);
}
#itemTip,
#slotPicker {
  animation: fc-scale-in var(--t-fast) var(--ease-out);
}
/* Death recap rows stagger in (fill backwards so late rows start invisible). */
.deathStats .row {
  animation: fc-fade-up var(--t-slow) var(--ease-out) backwards;
}
.deathStats .row:nth-child(2) {
  animation-delay: 60ms;
}
.deathStats .row:nth-child(3) {
  animation-delay: 120ms;
}
.deathStats .row:nth-child(4) {
  animation-delay: 180ms;
}
.deathStats .row:nth-child(5) {
  animation-delay: 240ms;
}
.deathStats .recapSection {
  animation: fc-fade-up var(--t-slow) var(--ease-out) 300ms backwards;
}

/* toast in/out */
#toast.show {
  opacity: 1;
  visibility: visible;
  transition: none;
  animation: fc-toast-in var(--t-enter) var(--ease-spring);
}
/* The big result toast lands with an outcome-matched motion (both preserve the
   translateX(-50%) centering): success pops, failure shakes. */
#toast.show.big.ok {
  animation:
    fc-toast-pop 420ms var(--ease-spring),
    fc-toast-bloom 520ms var(--ease-out);
}
#toast.show.big.err {
  animation: fc-toast-shake 360ms var(--ease-out);
}
/* Shine sweep across the win toast face, once per entrance (same gleam as the
   achievement banner — reuses its keyframes). */
#toast.show.big.ok::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    115deg,
    transparent 30%,
    rgba(255, 255, 255, 0.18) 48%,
    transparent 62%
  );
  transform: translateX(-100%);
  animation: fc-ach-shine 900ms var(--ease-out) 180ms forwards;
}
@keyframes fc-toast-pop {
  0% {
    opacity: 0;
    transform: translate(-50%, 0) scale(0.7);
  }
  60% {
    opacity: 1;
    transform: translate(-50%, 0) scale(1.16);
  }
  100% {
    transform: translate(-50%, 0) scale(1);
  }
}
/* Glow swell-and-settle layered on top of the pop. */
@keyframes fc-toast-bloom {
  0% {
    filter: brightness(1);
  }
  35% {
    filter: brightness(1.35);
  }
  100% {
    filter: brightness(1);
  }
}
@keyframes fc-toast-shake {
  0%,
  100% {
    transform: translate(-50%, 0);
  }
  15%,
  45%,
  75% {
    transform: translate(calc(-50% - 10px), 0);
  }
  30%,
  60%,
  90% {
    transform: translate(calc(-50% + 10px), 0);
  }
}

/* Full-screen success/failure burst: a colour vignette (edges tinted, centre clear
   so menus stay readable) that flashes in and fades out. Non-interactive. */
#screenFlash {
  position: fixed;
  inset: 0;
  z-index: var(--z-fx);
  pointer-events: none;
  opacity: 0;
}
#screenFlash.flash-ok {
  background: radial-gradient(ellipse at center, transparent 45%, var(--success-28) 100%);
  animation: fc-flash 500ms var(--ease-out);
}
#screenFlash.flash-err {
  background: radial-gradient(ellipse at center, transparent 40%, rgba(255, 91, 91, 0.38) 100%);
  animation: fc-flash 500ms var(--ease-out);
}
@keyframes fc-flash {
  0% {
    opacity: 0;
  }
  20% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
/* Own-damage hit vignette (hud.flashDamage, fired by net.js off `hurt` events):
   a tighter, darker red EDGE burst than flash-err so "I'm being hit" reads
   distinctly from "that action failed". Own class + timer so a craft flash and a
   hit can't cancel each other. JS-gated on prefers-reduced-motion (hud.js). */
#screenFlash.flash-hurt {
  background: radial-gradient(ellipse at center, transparent 58%, rgba(214, 40, 40, 0.45) 100%);
  animation: fc-flash 400ms var(--ease-out);
}
/* Epic-achievement gold burst (public/celebrate.js goldFlash) — its own class so it
   can't clash with hud.js's ok/err pair on the same #screenFlash div. */
#screenFlash.flash-gold {
  background: radial-gradient(ellipse at center, transparent 42%, rgba(255, 210, 63, 0.3) 100%);
  animation: fc-flash 700ms var(--ease-out);
}

/* Achievement celebration banner (public/celebrate.js): queued one-at-a-time,
   top-center, tinted by --ach-color (quality color / gold). */
#achBanner {
  position: fixed;
  left: 50%;
  top: 13%;
  transform: translateX(-50%);
  z-index: var(--z-toast);
  display: flex;
  align-items: center;
  gap: 10px;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  padding: 10px 20px 10px 14px;
  border-radius: var(--r-full);
  border: 1px solid var(--stroke-1);
  border-left: 3px solid var(--ach-color, var(--accent));
  color: var(--ink);
  background: var(--glass-3);
  backdrop-filter: blur(var(--blur-2));
  box-shadow:
    var(--elev-2),
    var(--glass-hilite),
    0 0 24px 0 rgba(255, 220, 120, 0.22);
  overflow: hidden;
}
#achBanner.show {
  opacity: 1;
  visibility: visible;
  animation: fc-ach-in 480ms var(--ease-spring);
}
/* A rushed banner (more celebrations queued behind it) holds only ~450 ms — compress the
   entrance and shine so they still land instead of reading as flicker. */
#achBanner.show.rush {
  animation-duration: 200ms;
}
/* Only a SHOWING clickable banner takes pointer events — hidden/fading or
   non-clickable banners stay tap-transparent over the top-center of the screen. */
#achBanner.show.click {
  pointer-events: auto;
  cursor: pointer;
}
#achBanner.show.click:hover {
  filter: brightness(1.12);
}
/* Shine sweep across the banner face, once per entrance. */
#achBanner.show::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    115deg,
    transparent 30%,
    rgba(255, 255, 255, 0.18) 48%,
    transparent 62%
  );
  transform: translateX(-100%);
  animation: fc-ach-shine 900ms var(--ease-out) 180ms forwards;
}
#achBanner.show.rush::after {
  animation-duration: 380ms;
  animation-delay: 60ms;
}
#achBanner .achGlyph {
  font-size: 26px;
  line-height: 1;
}
#achBanner .achIcon {
  width: 32px;
  height: 32px;
}
#achBanner .achText {
  display: flex;
  flex-direction: column;
  line-height: 1.25;
}
#achBanner .achTitle {
  font-weight: 800;
  font-size: 16px;
  color: var(--ach-color, var(--ink));
}
#achBanner .achSub {
  font-size: var(--fs-sm);
  color: var(--ink-dim);
}
#achBanner.minor .achTitle {
  font-size: 14px;
}
#achBanner.minor .achGlyph {
  font-size: 20px;
}
#achBanner.epic {
  padding: 14px 26px 14px 18px;
}
#achBanner.epic .achTitle {
  font-size: 20px;
}
#achBanner.epic .achGlyph {
  font-size: 32px;
}
@keyframes fc-ach-in {
  0% {
    opacity: 0;
    transform: translate(-50%, -14px) scale(0.72);
  }
  60% {
    opacity: 1;
    transform: translate(-50%, 2px) scale(1.08);
  }
  100% {
    opacity: 1;
    transform: translate(-50%, 0) scale(1);
  }
}
@keyframes fc-ach-shine {
  to {
    transform: translateX(100%);
  }
}

/* Sound toggle for the synthesized achievement chimes (public/sfx.js) — a small
   glass button above the corner clock (desktop) / under the minimap (mobile). */
#sfxMute {
  position: fixed;
  right: 14px;
  bottom: 262px; /* clear the clock chip's growth band (182 + ~75 max height + 5 gap) */
  z-index: var(--z-hud);
  pointer-events: auto; /* #hud is pointer-events: none; opt back in like every interactive HUD child */
  width: 32px;
  height: 32px;
  padding: 0;
  font-size: var(--fs-md);
  line-height: 1;
  border-radius: var(--r-full);
  border: 1px solid var(--stroke-1);
  background: var(--glass-2);
  color: var(--ink);
  cursor: pointer;
}
.mobile #sfxMute {
  right: 10px;
  bottom: auto;
  top: 230px;
  width: 28px;
  height: 28px;
}

/* attention pulses */
#stats .sp {
  animation: fc-pulse 1.6s var(--ease-inout) infinite;
}
#dead .btn:not([disabled]) {
  animation: fc-pulse 1.6s var(--ease-inout) infinite;
}
.loadoutKey {
  animation: fc-glow-pulse 2.2s var(--ease-inout) infinite;
}
#menuDock button.open {
  animation: fc-glow-pulse 2.2s var(--ease-inout) infinite;
}
#conn {
  animation: fc-glow-pulse-danger 2.2s var(--ease-inout) infinite;
}
#nameError:not(.hidden) {
  animation: fc-shake 240ms var(--ease-out);
}

/* bar sheen: a soft highlight sweeps the HP/XP fills */
.bar > i::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 40%;
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.18), transparent);
  animation: fc-bar-sheen 3s var(--ease-inout) infinite;
}
#hpGhost::after {
  content: none;
}

/* One switch turns it all off. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --t-fast: 0ms;
    --t-med: 0ms;
    --t-slow: 0ms;
    --t-enter: 0ms;
  }
  *,
  *::before,
  *::after {
    animation: none !important;
    transition: none !important;
  }
  #toast {
    transition: none;
  }
  #toast.show {
    opacity: 1;
    visibility: visible;
  }
  /* Reduced motion means no RAMP, not "chat lines stay on the field forever" — restate the
     peek's expiry as an instant cut. It needs !important + the heavier selector to outrank
     the blanket `* { animation: none !important }` above. */
  #chatPanel.chatPeek .chatLine {
    animation: fc-chat-peek-fade 1ms var(--peek-delay, 0ms) linear forwards !important;
  }
  /* Banner still appears (plain, no motion); celebrate.js also skips particles + flash. */
  #achBanner.show {
    opacity: 1;
    visibility: visible;
  }
}

/* Stronger hairlines + dimmed-text lift for users who ask for more contrast. */
@media (prefers-contrast: more) {
  :root {
    --stroke-1: rgba(255, 255, 255, 0.22);
    --stroke-2: rgba(255, 255, 255, 0.38);
    --ink-dim: #c7d0dd;
    --ink-faint: #97a1b1;
  }
}

/* Windows High Contrast / forced-colors: backgrounds flatten and box-shadow-only
   cues vanish, so interactive elements need REAL borders from the system palette.
   Quality tints and bar fills opt out (forced-color-adjust: none) — their color IS
   the information (item rarity, health) — over an explicit dark plate so the
   preserved colors stay legible in both light and dark HC schemes. */
@media (forced-colors: active) {
  button,
  input,
  select,
  textarea,
  [role='button'] {
    border: 1px solid ButtonText;
  }
  .chip,
  .socketCell,
  .pickCell,
  .toolCell,
  .recapTile,
  .bar,
  .pmBar,
  .guildBar {
    forced-color-adjust: none;
    background: #101218;
  }
}

/* Anti-AFK-farming / anti-bot human-challenge overlay (public/challenge.js, #challenge).
   Hidden until the server issues a challenge; a centred glass card holds the puzzle.
   No backdrop-filter: the gradient dim alone, and it is up for as long as the puzzle takes —
   see docs/decisions/no-fullscreen-blur-over-the-live-canvas.md. */
#challenge {
  position: fixed;
  inset: 0;
  z-index: var(--z-top); /* above the death overlay (25) — a challenge always takes precedence */
  display: none;
  align-items: center;
  justify-content: center;
  background: radial-gradient(
    120% 120% at 50% 45%,
    rgba(10, 14, 24, 0.6),
    rgba(6, 9, 16, 0.82) 75%,
    rgba(3, 5, 9, 0.92)
  );
  /* On-screen keyboard (the text challenge): padding shrinks the flex content box, so
     the centered card shifts into the unoccluded band (keyboard.js publishes the vars —
     the frame's bottom normally, its right edge under the forced-landscape turn). */
  padding-bottom: var(--kb-inset-bottom, 0px);
  padding-right: var(--kb-inset-right, 0px);
}
.challengeCard {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-4);
  padding: 24px 28px;
  border-radius: 16px;
  background: rgba(20, 26, 40, 0.92);
  border: 1px solid var(--stroke-2);
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
  /* Clamp to the keyboard-padded content box (100%) and scroll inside rather than
     sliding the code image or input off-screen; 92vw stays the desktop cap. */
  max-width: min(92vw, 100%);
  max-height: 100%;
  overflow-y: auto;
}
.challengeTitle {
  font-family: var(--font-display);
  font-size: 22px;
  font-weight: 700;
  color: #eaf0ff;
  text-align: center;
}
.challengeBody {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-3);
}
.challengeGrid {
  /* One option per row: each tile is a full word drawn by the server, not a small icon. */
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  align-items: center;
}
.challengeIcon {
  /* Width/height are set inline from the image the server sent, so the tile matches its row
     exactly — every other size here would crop or letterbox the word. */
  padding: 0;
  border-radius: 10px;
  border: 1px solid var(--stroke-2);
  /* The picture is dark ink on white paper (see server/raster.js), so the tile reads as a
     paper plate on the dark card; the background colour only covers rounding artefacts. */
  background-color: #fff;
  background-repeat: no-repeat;
  cursor: pointer;
  transition:
    transform 0.08s ease,
    filter 0.12s ease,
    box-shadow 0.12s ease;
}
.challengeIcon:hover {
  /* The image is opaque, so a tint behind it would never show — dim the tile itself instead. */
  filter: brightness(0.92);
  box-shadow: 0 0 0 2px rgba(120, 170, 255, 0.55);
  transform: translateY(-2px);
}
.challengeMaze {
  /* Override the global full-screen `canvas` rule (position: fixed; inset: 0) so the maze
     sits inside the card instead of being pinned to the viewport corner. */
  position: static;
  inset: auto;
  border-radius: 10px;
  /* Paper, like the code image: the WALLS are a server-drawn picture that challenge.js hangs on
     this element as an inline background-image, and the canvas itself paints only the dot and the
     exit on top of it. */
  background-color: #fff;
  /* The dot is dragged with the mouse or a finger: the browser must never claim the gesture
     as a pan/zoom, nor start a text selection. (body already sets touch-action: none — this
     is the explicit local guarantee, since the drag is the only way to solve this on a phone.) */
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  cursor: grab;
}
.challengeMaze.grabbing {
  cursor: grabbing;
}
.challengeHint {
  font-size: var(--fs-sm);
  color: var(--ink-dim);
}
.challengeCode {
  /* The server-drawn code image. Shown at its natural size so the glyphs stay crisp, and
     unselectable so a long-press on a phone offers "copy image" rather than a text handle. */
  display: block;
  max-width: 100%;
  border-radius: 10px;
  background-color: #fff;
  user-select: none;
  -webkit-user-select: none;
  pointer-events: none;
}
.challengeInput {
  font-size: 22px;
  text-align: center;
  letter-spacing: 4px;
  padding: 8px 12px;
  width: 180px;
  border-radius: 8px;
  border: 1px solid var(--stroke-2);
  background: rgba(0, 0, 0, 0.35);
  color: var(--ink-hi);
}
.challengeSubmit {
  font-size: 16px;
  font-weight: 600;
  padding: 8px 22px;
  border-radius: 8px;
  border: 1px solid rgba(120, 170, 255, 0.5);
  background: rgba(90, 140, 255, 0.28);
  color: #eaf0ff;
  cursor: pointer;
}
.challengeSubmit:hover {
  background: rgba(120, 170, 255, 0.42);
}
.challengeFoot {
  display: flex;
  gap: 18px;
  font-size: var(--fs-sm);
  color: var(--ink-faint);
}
.challengeTimer {
  font-weight: 700;
  color: #ffd98a;
}

/* ---- coarse-pointer hit targets (≥44px platform guideline) ----------------
   Isolated controls get an invisible centred hit-slop pseudo (visual size
   unchanged); packed grids enlarge the control itself instead — slop there
   would overlap neighbours and bias taps toward the later DOM sibling. */
@media (pointer: coarse) {
  .winClose,
  .diamonds {
    position: relative;
  }
  .winClose::after,
  #sfxMute::after,
  .diamonds::after {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: max(100%, 44px);
    height: max(100%, 44px);
    transform: translate(-50%, -50%);
  }
  .styleSwatch {
    width: 34px;
    height: 34px;
  }
  .chatTab {
    padding: 8px 6px;
  }
  /* Half the 4px cell gap on each side — bigger tap area, no neighbour overlap. */
  .loadoutCell::after {
    content: '';
    position: absolute;
    inset: -2px;
  }
}
