/* PokeAFK play client — self-contained stylesheet (no build step).
   Pixel-art design system shared with the website (see website app.css):
   night navy · Rotom orange · ice blue · laurel gold. Square Game-Boy
   dialog-boxes, hard offset shadows, Press Start 2P display face. */

@font-face {
  font-family: "Press Start 2P";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/press-start-2p-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Press Start 2P";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/press-start-2p-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  --bg: #081120;           /* night navy (logo backdrop) */
  --bg-2: #0d1a2f;
  --panel: #101f36;
  --panel-2: #16294a;
  --border: #23406e;       /* steel blue */
  --edge: #050b16;         /* hard pixel outline */
  --text: #eaf2ff;
  --muted: #8fa3c4;
  --primary: #f7941e;      /* Rotom orange */
  --primary-2: #ffb339;    /* ember */
  --primary-deep: #b35f0a;
  --accent: #ffc93c;       /* laurel gold */
  --link: #4fc1ff;         /* ice blue */
  --blue-deep: #1d6fd8;
  --radius: 0;             /* pixels have corners */
  --shadow: 4px 4px 0 rgba(3, 7, 14, 0.55);
  --maxw: 1120px;
  --pixel: "Press Start 2P", "Courier New", monospace;

  --medal-bronze: #b0722d;
  --medal-silver: #c9d1d9;

  --tier-legendary: #ff9d2b;   /* orange */
  --tier-mythical: #c084fc;    /* purple */
  --tier-ultra-beast: #ff6ec7; /* pink */
  --green: #4ade80;
  --danger: #ff6b6b;       /* failure red — was only ever a hard-coded fallback before */

  /* EXPERIENCE — one colour, everywhere it is drawn. Gold reads as progression against
     this palette and is what the team HUD has always used; the offline report's bar was
     briefly blue/purple, inherited from a stale rule, which made "how much XP" look like
     two different meters. Every XP fill pulls from here — do not hand-pick a gradient. */
  --xp-a: #d9a520;
  --xp-b: #ffd447;
  --xp-glow: rgba(255, 212, 71, 0.45);

  /* in-game HUD layout. The side rails (.hud-rail) stack their panels with these,
     so a new panel never needs another hand-picked top/right magic number. */
  --hud-edge: 12px;         /* gap from the viewport edge */
  --hud-gap: 10px;          /* gap between stacked panels */
  --hud-rail-top: 96px;     /* clears the .team-hud strip */
  --hud-rail-bottom: 76px;  /* clears the hotbar band */
  /* The square HUD button (.hud-menu-btn) and the gap inside .hud-exit. Tokens rather than
     literals because `.team-hud` has to reserve the width the exit cluster covers, and the
     button shrinks on small screens — a retyped 46/38 there decouples the reservation from
     the thing it is reserving for, silently, at exactly one breakpoint. */
  --hud-btn: 46px;
  --hud-exit-gap: 8px;
  /* ---- the notch, the home indicator, and the rounded corners --------------------------
     `root.html.heex` sets `viewport-fit=cover` so the page paints edge to edge — which is what
     the mobile-fullscreen work needs, and is also the only "fullscreen" an iPhone can have
     (Safari there implements the Fullscreen API on no element at all). The cost is that the
     page now paints UNDER the notch and the home indicator, so anything fixed to a screen edge
     has to add the inset back.

     ⚠️ TOKENS, not `env()` at each call site. There are a dozen fixed layers (the rails, the
     corner rail, the hotbars, the dock, the toast stack, the full-bleed modals' ✕) and they all
     want the same answer; a per-site `env()` is a dozen places to forget one. It also composes
     with the `body.touchctl` reflow, which redefines --hud-rail-bottom and friends.

     ⚠️ These resolve to **0** on every browser that does not know `env()` and on every device
     without insets — which is the desktop case and the correct no-op. `env()`'s own second
     argument is the fallback, so no `@supports` guard is needed here (the one at `.vstick`
     predates these tokens and is left alone).

     ⚠️ LEFT and RIGHT are the ones people forget. They are 0 in portrait and non-zero in
     LANDSCAPE, which is exactly the orientation a phone plays this game in.

     ⚠️ **The tokens are raw SCREEN px, so a consumer inside zoomed chrome must multiply by
     `var(--ui-inv)`** — the same rule this file already states for `vh`/`vw` a few lines above.
     `zoom` scales a declared length, and the inset is a fixed number of screen pixels, so a bare
     `env()` inside `.game-root > *` over-pads at --ui 1.25 and under-pads at 0.75. The one
     consumer that must NOT multiply is `.vstick`, which is deliberately left out of the zoom
     allow-list (thumb targets keep their 44px floor). */
  --hud-safe-t: env(safe-area-inset-top, 0px);
  --hud-safe-r: env(safe-area-inset-right, 0px);
  --hud-safe-b: env(safe-area-inset-bottom, 0px);
  --hud-safe-l: env(safe-area-inset-left, 0px);

  /* UI SCALE — the player's interface-size setting (☰ → Settings → Display).
     Set on <html> by the inline script in root.html.heex before first paint, and by the
     UIScale hook when it changes. 1 = 100%. See .ui-scale below for how it is applied. */
  --ui: 1;

  /* ⚠️ Viewport units do NOT compensate for `zoom`. Inside a zoomed box `100vw` still means
     the real viewport, and the result is then pre-multiplied by the zoom like every other
     length. Measured in Chrome on a 390px viewport, not assumed:

         calc(100vw - 24px)   at --ui 1     → 366px   (correct)
                              at --ui 0.75  → 275px   (needlessly narrow)
                              at --ui 1.25  → 476px   (WIDER THAN THE SCREEN — clipped)

     So every viewport-relative length inside zoomed chrome multiplies by --ui-inv. At the
     default scale that is exactly 1, i.e. every such rule is a byte-for-byte no-op until
     the player moves the setting. Rules OUTSIDE the zoomed subtree — .create-shell,
     .wizard, and the body-level portals left at scale 1 — keep plain viewport units. */
  --ui-inv: calc(1 / var(--ui));
}

* { box-sizing: border-box; }

/* ---- Pixel art is scaled with NEAREST NEIGHBOUR, by default --------------------
   PokeAFK is a 32x32-tile pixel-art game (the repo-root art rule), so every image under
   `/assets/game/` is pixel art and every one of them is drawn at some size other than its
   natural one — a 32px item icon at 42px in the bag, a 96px still at 44px in a tooltip. The
   browser's default is a smooth bilinear resample, which turns a hard 1px outline into a
   three-pixel gradient and undoes the thing the art was authored for.

   ⚠️ **`:where()`, so this has ZERO specificity.** ~135 rules across this file and the
   website's already declare `image-rendering` per element, and a handful of them deliberately
   declare `auto` — `.cur-icon` here, `.page-banner`, `.feature-banner` and `.prof-key-art` on
   the site, all AI-generated art whose 1024px source lands under 1:1 and genuinely wants the
   smooth resample. A plain `img[src^=…]` selector is (0,1,1) and would have silently BEATEN
   `.cur-icon` (0,1,0), quietly re-breaking the one icon somebody had already fixed by hand.
   Inside `:where()` any class rule wins, so this is a default and never an override.

   ⚠️ Scoped to `/assets/game/`, which is exactly the pixel-art tree. The website's marketing
   art lives under `/images/` and is untouched — it is photographic-scale AI output, not tiles.
   `^=` matches the prefix, so the `?v=` cache-buster (`PokeCore.ItemSprite`) does not defeat it.

   `crisp-edges` first, then `pixelated`: both name nearest-neighbour, the later declaration
   wins wherever it is understood, and the pair covers the browsers that only ever shipped one
   of the two spellings. Firefox has supported `pixelated` since 93, so today this is belt and
   braces rather than a live fallback.

   This does NOT replace the per-element rules below. Those cover what this cannot see:
   `background-image` (a selector on `src` matches no background), `<canvas>`, and elements
   whose art arrives from a tree this pattern does not name. It is a floor, so that art added
   tomorrow is crisp without anybody remembering to say so. */
:where(img[src^="/assets/game/"]) {
  image-rendering: crisp-edges;
  image-rendering: pixelated;
}

/* ---- Scrollbars: one thin square bar, everywhere ------------------------------- */
/* Global on purpose. Firefox inherits `scrollbar-*` from `html`, and an unqualified
   `::-webkit-scrollbar` already matches every element — so a new modal or list is styled
   the moment it exists. The alternative, an opt-in `.scroll` class, would have needed
   every existing container edited and would still miss the next one; six scroll areas in
   this app are inline-styled with no class at all and could never have been reached.
   Track stays TRANSPARENT: .chat-panel / .hunt-analyzer / .bag-panel are translucent over the
   canvas, and an opaque track cuts a hard stripe through what shows behind them.
   Square, via --radius — a rounded thumb is off-brand in a design that has no round corners.
   Identical block lives in the website's app.css; the two apps share these tokens exactly. */
html { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius); }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
::-webkit-scrollbar-corner { background: transparent; }

html, body { margin: 0; padding: 0; }

body {
  background:
    radial-gradient(1100px 520px at 50% -140px, rgba(29, 111, 216, 0.16), transparent 62%),
    radial-gradient(800px 420px at 88% 12%, rgba(247, 148, 30, 0.07), transparent 55%),
    conic-gradient(from 0deg, rgba(255, 255, 255, 0.014) 0 25%, transparent 0 50%,
      rgba(255, 255, 255, 0.014) 0 75%, transparent 0) 0 0 / 24px 24px,
    var(--bg);
  color: var(--text);
  font: 16px/1.6 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  /* `dvh`, not `vh` — mobile Safari resolves `vh` against the address-bar-COLLAPSED viewport, so
     a `100vh` page hides its own last row behind browser chrome. The `vh` line above is the
     fallback for engines that do not know the unit; same pair, same reason, as the full-bleed
     modal block at the end of this file. */
  min-height: 100vh;
  min-height: 100dvh;
}

a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
:focus-visible { outline: 3px solid var(--primary); outline-offset: 2px; }

.container { max-width: var(--maxw); margin: 0 auto; padding: 0 20px; }

/* ---------------- Nav ---------------- */
.nav {
  position: sticky; top: 0; z-index: 20;
  background: rgba(8, 17, 32, 0.88);
  backdrop-filter: blur(8px);
  border-bottom: 3px solid var(--edge);
  box-shadow: 0 3px 0 rgba(35, 64, 110, 0.55);
}
.nav-inner { display: flex; align-items: center; gap: 18px; height: 62px; }
.brand { display: flex; align-items: center; gap: 10px; color: var(--text); }
.brand:hover { text-decoration: none; }
.brand-mark { width: 28px; height: 28px; display: block; }
.brand-word { font-family: var(--pixel); font-size: 13px; color: var(--primary-2); text-shadow: 2px 2px 0 var(--edge); }
.brand-word b { font-weight: 400; color: var(--link); }
.brand .dot {
  width: 22px; height: 22px;
  background: linear-gradient(180deg, var(--primary) 0 50%, #f6f8fa 50% 100%);
  border: 2px solid var(--edge); box-shadow: inset 0 0 0 2px var(--edge), 0 0 0 1px var(--border);
  position: relative;
}
.brand .dot::after { content: ""; position: absolute; inset: 8px; background: var(--edge); box-shadow: 0 0 0 2px #f6f8fa; }
.brand b { color: var(--primary-2); }
.nav-links { display: flex; gap: 4px; margin-left: auto; align-items: center; flex-wrap: wrap; }
.nav-links a {
  color: var(--muted); font-family: var(--pixel); font-size: 9px;
  padding: 9px 10px; border: 2px solid transparent; white-space: nowrap;
}
.nav-links a:hover { color: var(--text); background: var(--panel); border-color: var(--border); text-decoration: none; }
.nav-links a.cta {
  color: var(--edge); background: var(--primary);
  border: 2px solid var(--edge); box-shadow: 2px 2px 0 var(--edge);
}
.nav-links a.cta:hover { background: var(--primary-2); border-color: var(--edge); color: var(--edge); }
.nav-links a.cta:active { transform: translate(2px, 2px); box-shadow: none; }

.locale-switch { display: flex; gap: 0; margin-left: 12px; border: 2px solid var(--border); padding: 0; }
.locale-switch a { font-family: var(--pixel); font-size: 8px; color: var(--muted); padding: 7px 8px; }
.locale-switch a:hover { color: var(--text); text-decoration: none; }
.locale-switch a.on { color: var(--edge); background: var(--link); }

.nav-account { display: flex; align-items: center; gap: 8px; margin-left: 12px; }
.nav-account .email { font-size: 13px; color: var(--muted); max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-account a { color: var(--muted); font-family: var(--pixel); font-size: 9px; padding: 9px 10px; border: 2px solid transparent; white-space: nowrap; }
.nav-account a:hover { color: var(--text); background: var(--panel); border-color: var(--border); text-decoration: none; }
.linkish { background: none; border: 2px solid transparent; color: var(--muted); font-family: var(--pixel); font-size: 9px; cursor: pointer; padding: 9px 10px; }
.linkish:hover { color: var(--text); background: var(--panel); border-color: var(--border); }

/* ---------------- Dialog-box panels (signature) ----------------
   Every panel is a Game-Boy textbox: dark outer frame, light inner
   keyline, hard offset shadow, square corners. */
.panel {
  background: var(--panel);
  border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  padding: 20px;
}

/* auth forms */
.auth-wrap { max-width: 440px; margin: 0 auto; }
.auth-form { display: grid; gap: 4px; }
.auth-form label { display: block; font-size: 14px; color: var(--muted); margin-bottom: 12px; }
.auth-form input { display: block; width: 100%; margin-top: 6px; padding: 11px 12px; border: 2px solid var(--border); background: var(--bg-2); color: var(--text); font-size: 15px; border-radius: 0; }
.auth-form input:focus { outline: none; border-color: var(--primary); box-shadow: 2px 2px 0 var(--edge); }
.auth-form .btn { width: 100%; justify-content: center; margin-top: 6px; }
.auth-alt { text-align: center; color: var(--muted); margin-top: 16px; font-size: 14px; }

/* starter picker */
.starter-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.starter-card { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 16px; border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border); background: var(--panel); cursor: pointer; text-align: center; }
.starter-card:hover { box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); }
.starter-card input { accent-color: var(--primary); }
.starter-card img { image-rendering: pixelated; }
.starter-card:has(input:checked) { box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); background: var(--panel-2); }
.starter-name { font-family: var(--pixel); font-size: 10px; text-transform: capitalize; }
@media (max-width: 600px) { .starter-grid { grid-template-columns: 1fr; } }

/* farm HUD rows */
.muted { color: var(--muted); }
.hud-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 2px solid var(--bg-2); font-size: 14.5px; }
.hud-row:last-child { border-bottom: none; }
.hud-row span { color: var(--muted); }
.hud-row b { font-variant-numeric: tabular-nums; font-size: 16px; }
.cap-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 10px; }
.cap { display: flex; flex-direction: column; align-items: center; gap: 2px; font-size: 11px; text-transform: capitalize; text-align: center; }
.cap img { width: 56px; height: 56px; image-rendering: pixelated; }

/* ---------------- Layout ---------------- */
main { padding: 40px 0 72px; }
.page-head { margin-bottom: 26px; }
.page-head h1 { font-family: var(--pixel); font-size: 20px; line-height: 1.5; margin: 0 0 10px; color: var(--primary-2); text-shadow: 2px 2px 0 var(--edge); }
.page-head p { color: var(--muted); margin: 0; max-width: 70ch; }
.crumbs { color: var(--muted); font-size: 13px; margin-bottom: 14px; }
.crumbs a { color: var(--muted); }
.section-title { font-family: var(--pixel); font-size: 14px; line-height: 1.6; margin: 34px 0 14px; color: var(--link); text-shadow: 2px 2px 0 var(--edge); }
.section-title::before { content: "▶ "; color: var(--primary); }

/* ---------------- Hero ---------------- */
.hero { padding: 8px 0 10px; }
.hero h1 {
  font-family: var(--pixel);
  font-size: clamp(15px, 2.6vw, 24px); line-height: 1.8; margin: 0 0 16px;
  text-shadow: 3px 3px 0 var(--edge);
}
.hero h1 .grad { color: var(--link); }
.hero p.lead { font-size: 19px; color: var(--muted); max-width: 60ch; margin: 0 0 24px; }
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; }

/* ---------------- Buttons ---------------- */
.btn {
  display: inline-flex; align-items: center; gap: 8px;
  font-family: var(--pixel); font-size: 10px; line-height: 1.4;
  padding: 13px 18px; border: 3px solid var(--edge);
  background: var(--panel); color: var(--text);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  cursor: pointer;
}
.btn:hover { text-decoration: none; background: var(--panel-2); }
.btn:active { transform: translate(3px, 3px); box-shadow: inset 0 0 0 2px var(--border); }
.btn.primary {
  color: var(--edge); background: var(--primary);
  box-shadow: inset 0 0 0 2px var(--primary-2), var(--shadow);
}
.btn.primary:hover { background: var(--primary-2); }
.btn.primary:active { transform: translate(3px, 3px); box-shadow: inset 0 0 0 2px var(--primary-2); }
.btn[disabled] { opacity: 0.45; cursor: not-allowed; box-shadow: inset 0 0 0 2px var(--border); transform: none; }
.btn[disabled]:hover { background: var(--panel); }

/* ---------------- Cards / grids ---------------- */
.grid { display: grid; gap: 18px; }
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
.feature h3 { margin: 6px 0 6px; font-family: var(--pixel); font-size: 11px; line-height: 1.6; color: var(--text); }
.feature p { color: var(--muted); margin: 0; font-size: 14.5px; }
.feature .ico { font-size: 22px; }

/* ---------------- Pokédex grid ---------------- */
.dex-toolbar { color: var(--muted); font-size: 14px; margin-bottom: 14px; }
.dex-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
/* `--q` is opt-in, exactly as on the depot's `.dcell`: a host that sets it gets a quality-
   coloured border, and every host that doesn't (pokédex species, auction listings, the delve
   team picker) falls back to `--edge` and renders as before. `q_color/1` is the one source
   for the value, so a tier word and a border can never disagree. */
.mon {
  display: block; background: var(--panel);
  border: 3px solid var(--q, var(--edge)); box-shadow: inset 0 0 0 2px var(--border);
  padding: 12px; text-align: center; position: relative;
}
.mon:hover { text-decoration: none; box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); transform: translate(-2px, -2px); }
.mon .no { position: absolute; top: 10px; left: 12px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.mon .sprite { width: 96px; height: 96px; margin: 4px auto 2px; image-rendering: pixelated; display: block; }
.mon .name { font-family: var(--pixel); font-size: 9px; line-height: 1.5; text-transform: capitalize; color: var(--text); }
.mon .types { display: flex; gap: 5px; justify-content: center; margin-top: 8px; flex-wrap: wrap; }
.mon .rar { position: absolute; top: 10px; right: 12px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 800; }

/* ---------------- Type & rarity badges ---------------- */
.type { display: inline-block; padding: 3px 8px; border: 2px solid var(--edge); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.03em; color: #081120; }
/* type shown as its pixel-art sprite (title= gives the name on hover) — bare icon, no chip */
.type-ico { display: inline-block; vertical-align: middle; }
.type-normal { background: #9099a1; } .type-fire { background: #ff9d55; } .type-water { background: #4d90d5; color:#fff; }
.type-electric { background: #f4d23c; } .type-grass { background: #63bc5a; } .type-ice { background: #73cec0; }
.type-fighting { background: #ce4069; color:#fff; } .type-poison { background: #ab6ac8; color:#fff; } .type-ground { background: #d97746; color:#fff; }
.type-flying { background: #8fa8dd; } .type-psychic { background: #fa7179; color:#fff; } .type-bug { background: #90c12c; }
.type-rock { background: #c7b78b; } .type-ghost { background: #5269ad; color:#fff; } .type-dragon { background: #0b6dc3; color:#fff; }
/* Gen 2+ types — Dark, Steel and Fairy became MOVE types with the 2026-10-08 move import. Same
   hues as the farm map's `.tring-*` rings, so a type reads as one colour wherever it is drawn. */
.type-dark { background: #5a5366; color:#fff; } .type-steel { background: #5a8ea6; color:#fff; } .type-fairy { background: #ec8fe6; }

.tier-legendary { color: var(--tier-legendary); }
.tier-mythical { color: var(--tier-mythical); }
.tier-ultra-beast { color: var(--tier-ultra-beast); }
.pill { display: inline-block; padding: 3px 10px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; border: 2px solid currentColor; }

/* ---------------- Detail page ---------------- */
.detail { display: grid; grid-template-columns: 260px 1fr; gap: 26px; align-items: start; }
.detail .art { background: var(--panel); border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 18px; text-align: center; }
.detail .art img { width: 100%; max-width: 220px; image-rendering: pixelated; }
.stat-row { display: grid; grid-template-columns: 92px 42px 1fr; align-items: center; gap: 10px; margin: 7px 0; }
.stat-row .lbl { color: var(--muted); font-size: 13px; }
.stat-row .val { font-variant-numeric: tabular-nums; font-weight: 700; text-align: right; }
.bar { height: 12px; border: 2px solid var(--edge); background: var(--bg-2); overflow: hidden; }
.bar > span { display: block; height: 100%; background: repeating-linear-gradient(90deg, var(--primary) 0 8px, var(--primary-2) 8px 16px); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; font-size: 14.5px; }
.kv dt { color: var(--muted); } .kv dd { margin: 0; }
.evo { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.evo .arrow { color: var(--primary); }

/* ---------------- Tables ---------------- */
.table-wrap { overflow-x: auto; border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow); background: var(--panel); }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th, td { padding: 9px 12px; text-align: left; border-bottom: 2px solid var(--bg-2); white-space: nowrap; }
thead th { color: var(--link); font-family: var(--pixel); font-size: 8px; letter-spacing: 0; text-transform: uppercase; position: sticky; top: 0; background: var(--panel-2); }
tbody tr:hover { background: var(--panel-2); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }

/* Type chart matrix */
.matrix td, .matrix th { text-align: center; padding: 6px 8px; border: 1px solid var(--border); }
.matrix .head { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 11px; }
.m2 { background: rgba(74, 222, 128, 0.18); color: #86efac; font-weight: 800; }
.mh { background: rgba(248, 113, 113, 0.16); color: #fca5a5; }
.m0 { background: rgba(148, 163, 184, 0.14); color: var(--muted); }
.m1 { color: #47536420; }

/* ---------------- Grand Exchange — the market list ----------------
   Step 1 of ExchangeLive. It was an icon grid with nothing on it but a sprite and a clipped 9px
   label, and the reported problem was that a player could not tell what was available to trade.

   It is a real <table> inside `.table-wrap`, so it inherits the sticky <thead>, the hover row,
   `td.num`'s right-aligned tabular numerals and the panel border from the Tables block above —
   which is why there is so little here. Everything below is either something those rules cannot
   express, or a trap. */
.x-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.x-hint { font-size: 12px; }
.x-layout { display: grid; grid-template-columns: minmax(140px, max-content) 1fr; gap: 14px; align-items: start; }
.x-cats { display: flex; flex-direction: column; gap: 4px; max-height: 460px; overflow-y: auto; }
.x-cats .tab { justify-content: flex-start; text-align: left; white-space: nowrap; }
/* The rail is a category TREE (PokeCore.ItemCategories). A row is a caret + the tab; `--d` is the
   node's depth. The caret is a CSS triangle, not a glyph: no emoji or font-dependent arrows in UI. */
.x-node { display: flex; align-items: center; gap: 2px; padding-left: calc(var(--d, 0) * 14px); }
.x-node .tab { flex: 1; }
.x-count { margin-left: auto; padding-left: 8px; opacity: .6; font-variant-numeric: tabular-nums; }
.x-caret, .x-caret-sp { flex: 0 0 16px; width: 16px; height: 22px; }
.x-caret { background: none; border: 0; padding: 0; cursor: pointer; position: relative; }
.x-caret::before { content: ""; position: absolute; left: 5px; top: 7px; border: 4px solid transparent;
  border-left: 6px solid var(--text); border-right: 0; transition: transform .12s; }
.x-caret.open::before { transform: rotate(90deg); }
.x-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
.x-toolbar input[type="text"] { flex: 1; min-width: 160px; max-width: 260px;
  background: var(--bg-2); border: 2px solid var(--border); border-radius: 0; padding: 7px 10px; color: var(--text); }
.x-toolbar .tab { white-space: nowrap; }
/* The scroll container the sticky <thead> sticks inside. */
.x-wrap { max-height: 460px; overflow-y: auto; }

.xsort { cursor: pointer; user-select: none; }
.xsort:hover { color: var(--accent); }
.xsort.on { color: var(--accent); }
.x-mark { margin-left: 4px; font-size: 9px; }

.xrow { cursor: pointer; }
.x-name { display: flex; align-items: center; gap: 7px; }
.x-name img { width: 24px; height: 24px; image-rendering: pixelated; flex: none; }
/* `th, td` is `white-space: nowrap`, so without a ceiling one long TM name widens the Item column
   and pushes every price off the right edge of the wrap. */
.x-label { min-width: 0; max-width: 22ch; overflow: hidden; text-overflow: ellipsis; }
/* Carried count. Rendered only when non-zero (`owned_qty/2` answers nil, not 0), so this never
   has to say "0" — see its comment in exchange_live.ex. */
.x-own { flex: none; font-size: 10px; line-height: 1; padding: 2px 4px;
  background: var(--bg-2); border: 1px solid var(--edge); color: var(--accent); }
/* ⚠️ `—` is the ONLY thing that may render in a cell with no number. A zero in a price column
   reads as "free"; a zero in a depth column reads as a market that exists and is empty. Both are
   claims the data does not support. Muted so a board of dead items recedes behind the live ones. */
.x-none { color: var(--muted); opacity: 0.55; }
/* The ask is what you PAY and the bid is what you GET, so they take the same two colours the
   order book below uses for sell and buy. Kept in step with exchange_live.ex's book columns. */
.x-ask { color: #ce4069; }
.x-bid { color: #4caf7d; }
.x-up { color: #4caf7d; }
.x-down { color: #ce4069; }
.x-sub { color: var(--muted); font-style: normal; font-size: 11px; margin-left: 4px; }

/* Narrow: drop history first, then the order counts. Price and depth answer "can I trade this
   right now", which is the question the whole list exists for; `Last`/`24h` are context. Without
   this the seven columns push the table's own horizontal scrollbar inside the nested modal, where
   the panel is far narrower than the standalone /exchange route. */
@media (max-width: 900px) {
  .x-layout { grid-template-columns: 1fr; }
  /* Stays a column: a tree cannot wrap into a row. Capped so it does not push the table away. */
  .x-cats { max-height: 240px; }
}
@media (max-width: 720px) {
  .x-dim { display: none; }
  .x-sub { display: none; }
  .x-table th, .x-table td { padding: 8px 7px; }
}

#global-tip.tip-x {
  width: max-content; max-width: 250px; padding: 10px 12px;
  background: var(--panel-2); border-width: 3px;
  text-align: left; white-space: normal; text-transform: none; font-weight: 500;
}
#global-tip.tip-x .tip-x-own,
#global-tip.tip-x .tip-x-sell,
#global-tip.tip-x .tip-x-buy,
#global-tip.tip-x .tip-x-quiet { display: block; margin-top: 4px; font-size: 11px; }
#global-tip.tip-x .tip-x-own { margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border); color: var(--accent); }
#global-tip.tip-x .tip-x-sell { color: #ce4069; }
#global-tip.tip-x .tip-x-buy { color: #4caf7d; }

/* ---------------- Subscription / plans ---------------- */
.plans { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; align-items: stretch; }
.plan { background: var(--panel); border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 24px; display: flex; flex-direction: column; }
.plan.highlight { box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); position: relative; }
.plan .tag { position: absolute; top: -13px; left: 20px; background: var(--primary); color: var(--edge); font-family: var(--pixel); font-size: 8px; padding: 5px 10px; border: 2px solid var(--edge); text-transform: uppercase; }
.plan h3 { margin: 0 0 8px; font-family: var(--pixel); font-size: 13px; line-height: 1.5; }
.plan .tagline { color: var(--muted); font-size: 14px; margin: 0 0 16px; min-height: 40px; }
.plan .price { font-size: 34px; font-weight: 850; letter-spacing: -0.02em; }
.plan .price small { font-size: 15px; color: var(--muted); font-weight: 600; }
.plan ul { list-style: none; padding: 0; margin: 18px 0 22px; display: grid; gap: 10px; }
.plan li { display: flex; gap: 9px; font-size: 14.5px; align-items: flex-start; }
.plan li::before { content: "▸"; color: var(--green); font-weight: 900; }
.plan .btn { margin-top: auto; justify-content: center; }
.fair { margin-top: 26px; border: 3px solid var(--edge); border-left: 6px solid var(--accent); background: var(--panel); box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 16px 20px; }
.fair b { color: var(--accent); }

/* Star Shard store */
.ss-amount { margin: 6px 0 14px; }
.ss-total { font-size: 30px; font-weight: 850; letter-spacing: -0.02em; color: var(--accent); display: block; }
.ss-total small { font-size: 15px; color: var(--muted); font-weight: 700; }
.ss-bonus { font-size: 13px; color: var(--muted); }
.vip-hint { display: inline-block; font-size: 12px; font-weight: 700; color: var(--accent); background: rgba(255, 201, 60, 0.10); border: 2px solid rgba(255, 201, 60, 0.35); padding: 3px 10px; margin-bottom: 12px; }
.price-ss { font-size: 30px; font-weight: 850; letter-spacing: -0.02em; }
.price-ss .unit { font-size: 15px; color: var(--accent); font-weight: 700; }

/* ---------------- Guides ---------------- */
.guide-body p { margin: 0 0 14px; }
.tag-list { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 2px solid var(--border); padding: 3px 10px; }

/* ---------------- Friends panel ---------------- */
/* Square, not round: everything else in this UI is on the pixel grid, and a 4px circle
   renders as a lopsided blob at this size. Colour alone carries the state, so the button
   `title` is what a screen reader gets — the dot itself is aria-hidden. */
.friend-dot { width: 8px; height: 8px; flex: 0 0 8px; background: var(--muted); opacity: .45; }
.friend-dot.on { background: #6ee06e; opacity: 1; }

/* ---------------- News ---------------- */
.news-item { padding: 16px 0; border-bottom: 2px solid var(--panel-2); }
.news-item h4 { margin: 0 0 4px; font-size: 16px; }
.news-item .date { color: var(--link); font-family: var(--pixel); font-size: 8px; }
.news-item p { margin: 6px 0 0; color: var(--muted); font-size: 14.5px; }

/* ---------------- Banners (persistent inline state) ----------------
   NOT messages. These mark a state that is still true while you look at it —
   "the game service is unavailable", a GM sim's explanatory note, a blocked wizard
   step. They stay put. Transient messages are `.pk-toast` below; do not merge the two. */
.banner { border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border); background: var(--panel); padding: 14px 18px; margin-bottom: 18px; }
.banner.warn { box-shadow: inset 0 0 0 2px #7c5b16; background: rgba(255, 201, 60, 0.08); color: var(--accent); }

/* ---------------- Flash toasts ----------------
   One stack for the whole page. Every LiveView (shell, farm, bag panel, open modal) is a
   separate process with its own flash map and portals its toasts into #pk-toasts, so they
   queue in one column instead of stacking position:fixed boxes on top of each other.
   z-index 700 clears everything server-rendered — the ceiling below it is #global-tip at
   500 and #farm-card at 480 — while staying under the client-built .skillmodal-backdrop
   (100000) and the level-up overlay, which is pointer-events:none anyway.

   ⚠️ `--toast-lift` is what keeps the stack OFF the bottom-right menu rail, which shares this
   exact corner (`.hud-corner`, right: 14px, bottom: 12px). Every toast used to draw straight
   over the menu buttons — z 700 against a rail inside `.hud` at 10, so the toast always won
   and the icons underneath were simply unreadable while it was up.

   It is measured in JS (`TOASTS` in app.js) rather than reserved here because the rail's
   height is not a constant a stylesheet can know: it is `flex-wrap: wrap-reverse` under a
   max-width derived from the hotbars, so it is one 46px row on a wide desktop and two or
   three on a laptop — and `body.touchctl` moves its whole `bottom` to 118px. A hardcoded
   one-row reserve would be wrong on exactly the narrow screens that suffer most.

   The default is 0px, so the standalone routes (/farm, /pokedex, /auction) — which render
   `header.nav` + `main` and no HUD at all — keep the plain 16px corner with no JS involved. */
.toast-stack { position: fixed;
  right: calc(16px + var(--hud-safe-r) * var(--ui-inv));
  bottom: calc(16px + max(var(--toast-lift, 0px), var(--hud-safe-b) * var(--ui-inv)));
  z-index: 700; display: flex; flex-direction: column; gap: 10px;
  pointer-events: none; max-width: min(380px, calc((100vw - 32px) * var(--ui-inv))); }
/* <.portal> generates this wrapper around each LiveView's toasts. display:contents lifts
   the toasts themselves into #pk-toasts' flex flow, so the stack's gap applies to toasts
   rather than to wrappers — and a LiveView with no flash costs no phantom spacing. */
.toast-group { display: contents; }

.pk-toast { pointer-events: auto; cursor: pointer; position: relative; display: flex; align-items: flex-start; gap: 10px;
  padding: 12px 14px 12px 16px; background: var(--panel); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); font-size: 14px; line-height: 1.45; color: var(--text);
  animation: toast-in 0.22s cubic-bezier(0.2, 1.25, 0.4, 1) both; }
.pk-toast::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--toast-accent); }
.pk-toast:hover { box-shadow: inset 0 0 0 2px var(--toast-accent), var(--shadow); }

/* the exit, driven by the toast's own phx-remove (JS.hide transition/from/to + 220ms) */
.pk-toast-leaving { transition: opacity 0.22s ease, transform 0.22s ease; animation: none; }
.pk-toast-shown { opacity: 1; transform: none; }
.pk-toast-hidden { opacity: 0; transform: translateX(24px); }

.pk-toast-glyph { color: var(--toast-accent); font-size: 15px; line-height: 1.35; flex: 0 0 auto; }
.pk-toast-msg { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.pk-toast-x { flex: 0 0 auto; color: var(--muted); font-size: 12px; line-height: 1.6; }
.pk-toast:hover .pk-toast-x { color: var(--text); }

/* The pickup toast — "added to your bag". Built in JS (BAGTOAST in app.js), not by a
   put_flash, because it carries a sprite per line and because a flash map holds only one
   message per kind. It is a `.pk-toast` first so it inherits the frame, the entry animation
   and the exit classes: a pickup and a flash must read as the same object in the same stack.

   Column rather than the base row: `.pk-toast` is `display: flex` with `align-items:
   flex-start` for a glyph beside one line of text, and this is a heading over N rows. */
.pk-toast--pickup { --toast-accent: var(--green); flex-direction: column; align-items: stretch; gap: 6px; }
.pk-pickup-head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.pk-pickup-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
/* ⚠️ `pixelated`, like every other sprite on the page — an item icon is 32px art and the
   browser's default smoothing turns it to mush at this size. */
.pk-pickup-row img { width: 20px; height: 20px; image-rendering: pixelated; flex: 0 0 auto; }
.pk-pickup-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.pk-pickup-qty { flex: 0 0 auto; color: var(--accent); font-variant-numeric: tabular-nums; }
.pk-pickup-more { font-size: 12px; color: var(--muted); }

.pk-toast--success { --toast-accent: var(--green); }
.pk-toast--info    { --toast-accent: var(--link); }
.pk-toast--warning { --toast-accent: var(--accent); }
.pk-toast--error   { --toast-accent: var(--danger); }

@keyframes toast-in { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }

/* ⚠️ This block must keep `var(--toast-lift)`. It used to read a flat `bottom: 12px`, which
   overrode the base rule's `calc(16px + var(--toast-lift, 0px))` and silently threw the lift
   away on exactly the screens that need it most — `TOASTS` in app.js goes on measuring and
   stamping the property, and nothing below 820px consumed it.

   The cost was not theoretical. Under `body.touchctl` at this width the reflow moves
   `.hud-hotbars` to `right: 12px; bottom: 12px` (see the touch block above), i.e. into the toast
   stack's own corner — so every toast drew over the hotbar row, at z 700 against a rail inside
   `.hud` at 10. That is the same bug `--toast-lift` was added to fix on desktop, one breakpoint
   down, and the pickup toast made it constant during a hunt.

   ⚠️ **`max()`, never `+`.** `TOASTS.measure()` stamps `--toast-lift` from `.hud-corner`'s
   COMPUTED `bottom` plus its height — and that `bottom` now carries the safe-area inset itself,
   because the rail had to be lifted off the home indicator too. Adding the inset again here
   would lift the stack by it twice. `max()` is also the right relation on the merits: when a HUD
   is present the lift already clears rail-plus-inset, and on a HUD-less route the lift is 0 and
   the bare inset is what remains. Both operands are local px — `offsetHeight` and a computed
   `bottom` are pre-zoom, and `--hud-safe-*` is multiplied into that same space — so they are
   comparable, which `+` would have hidden.

   ⚠️ `TOASTS` measures `.hud-corner` and nothing else, and that is still correct here: on a
   phone the corner rail sits at `bottom: 118px` and the hotbars at `12px`, so the rail is the
   HIGHER obstacle and a lift taken from it clears both. Do not "generalise" the observer to
   also watch `.hud-hotbars` — it would compute the same number. (The observer's own comment
   explains the hotbars are exempt because they are bottom-CENTRE; that premise is a desktop
   one, and the touch reflow is what makes it false here.) */
@media (max-width: 820px) {
  .toast-stack {
    left: calc(12px + var(--hud-safe-l) * var(--ui-inv));
    right: calc(12px + var(--hud-safe-r) * var(--ui-inv));
    bottom: calc(12px + max(var(--toast-lift, 0px), var(--hud-safe-b) * var(--ui-inv)));
    max-width: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .pk-toast { animation: none; }
  .pk-toast-leaving { transition: none; }
}

/* ---------------- Footer ---------------- */
.footer { border-top: 3px solid var(--edge); box-shadow: 0 -3px 0 rgba(35, 64, 110, 0.55) inset; color: var(--muted); font-size: 13.5px; padding: 28px 0; }
.footer .cols { display: flex; gap: 40px; flex-wrap: wrap; justify-content: space-between; }
.footer a { color: var(--muted); }
.footer a:hover { color: var(--text); }

/* ---------------- Responsive ---------------- */
@media (max-width: 820px) {
  .grid.cols-3, .plans { grid-template-columns: 1fr; }
  .grid.cols-2 { grid-template-columns: 1fr; }
  .detail { grid-template-columns: 1fr; }
  .nav-inner { height: auto; padding: 10px 0; flex-wrap: wrap; }
  .nav-links { width: 100%; }
  .hero h1 { line-height: 2; }
}

/* ============================================================
   Game-only chrome — same pixel-art recipe, square + framed.
   Only shape/border/shadow/font differ from the old rounded look;
   positioning, z-index, backdrop-filter and animations preserved.
   ============================================================ */

/* ---- farm battle view ---- */
.battle-panel { display: flex; align-items: flex-start; justify-content: space-around; gap: 16px; text-align: center; }
.fighter { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; }
.fighter .sprite { image-rendering: pixelated; }
.fighter-name { font-weight: 800; text-transform: capitalize; font-size: 14px; }
.vs { font-size: 22px; color: var(--muted); font-weight: 900; flex: 0 0 auto; align-self: center; }
.bar .hp-enemy { background: linear-gradient(90deg, #ce4069, #ff9d55); }

.moves-col { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; width: 100%; max-width: 200px; }
.move-badge {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  padding: 7px 12px; border: 2px solid var(--edge); font-weight: 800; font-size: 13px;
  text-transform: capitalize; opacity: 0.5; filter: grayscale(0.4); transition: all 0.18s ease;
}
.move-badge .mb-cd { font-size: 11px; font-weight: 700; opacity: 0.8; font-variant-numeric: tabular-nums; }
.move-badge.used {
  opacity: 1; filter: none; transform: scale(1.05);
  box-shadow: 0 0 0 2px #fff, 0 0 18px 4px rgba(255, 255, 255, 0.5);
  animation: move-pop 0.5s ease;
}
.move-badge.used .mb-cd { color: #0d1117; background: rgba(255,255,255,0.85); padding: 1px 7px; }
@keyframes move-pop {
  0% { transform: scale(1.18); box-shadow: 0 0 0 3px #fff, 0 0 26px 7px rgba(255,255,255,0.85); }
  100% { transform: scale(1.05); }
}
@media (max-width: 620px) { .battle-panel { flex-direction: column; align-items: stretch; } .moves-col { max-width: none; } }

/* ---- pokemon detail modal ---- */
.modal-backdrop {
  position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.66);
  display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px; overflow-y: auto;
  animation: fade-in 0.15s ease;
}
.modal {
  position: relative; width: 100%; max-width: 520px; max-height: calc(90vh * var(--ui-inv)); overflow-y: auto;
  background: var(--panel); border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  padding: 24px; animation: modal-in 0.18s ease;
}
.modal-close {
  position: absolute; top: 14px; right: 14px; width: 32px; height: 32px;
  border: 2px solid var(--edge); background: var(--panel-2);
  color: var(--muted); font-size: 15px; cursor: pointer; line-height: 1;
}
.modal-close:hover { color: var(--text); }
/* the close [X] lives OUTSIDE the modal box — a direct child of the backdrop, floated to the
   modal's top-right. `order:1` renders it after the modal (to its right) though it comes first
   in the DOM; being a sibling of the scroll box, it stays put while the modal scrolls. */
.modal-backdrop > .modal-close,
.game-modal-backdrop > .modal-close {
  position: static; order: 1; align-self: flex-start; flex: 0 0 auto; margin-left: 10px;
}
.modal-head { display: flex; gap: 18px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
.modal-sprite { width: 118px; height: 118px; image-rendering: pixelated; flex: 0 0 auto; }
.modal-section { border-top: 2px solid var(--border); margin-top: 16px; padding-top: 14px; }
.modal-section h4 { margin: 0 0 10px; font-family: var(--pixel); font-size: 10px; text-transform: uppercase; color: var(--link); }
.modal-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
.modal-cols .modal-section { border-top: 2px solid var(--border); }

/* ---- unified Pokémon summary: tabs + rich skills + FX preview ---- */
.psum-tabs { display: flex; gap: 6px; margin-top: 14px; border-bottom: 2px solid var(--border); }
.psum-tab {
  appearance: none; background: transparent; border: 0; cursor: pointer;
  padding: 8px 14px; font-family: var(--pixel); font-size: 10px; text-transform: uppercase;
  color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -2px;
}
.psum-tab:hover { color: var(--text); }
.psum-tab.active { color: var(--link); border-bottom-color: var(--link); }
/* How many free moves are waiting to be accepted in the Skills tab — "SKILLS (2)".
   Red on every tab state, including `.active`, so it is never inherited away: the count is
   the one part of the label that is not a name. `pointer-events: none` so it can never be
   the click target inside the button that IS the control. This replaced a `.notif-dot`,
   which has no `.psum-tab` rule and therefore painted nothing at all. */
.psum-tab-n { margin-left: 5px; color: #ef4444; pointer-events: none; }
.psum-panel[hidden] { display: none; }
/* the first section inside a fresh tab shouldn't draw a second top border */
.psum-panel > .modal-section:first-child,
.psum-panel > .modal-cols:first-child .modal-section { border-top: 0; margin-top: 6px; }

/* ⚠️ …but only for the sections that START a column. Skills stack under the profile inside the
   right-hand column, and the rule above is a DESCENDANT selector, so without this it swallows
   their separator too and the two read as one run-on block. */
.psum-panel > .modal-cols:first-child .modal-section + .modal-section {
  border-top: 2px solid var(--border);
  margin-top: 16px;
}

/* 35 / 65, not an even split: the left column is the IV hexagon and six stat rows, which are
   fixed and narrow, while the right one now carries the profile AND every skill — and a skill
   row wraps its tags and effect text, so it is the side that actually wants the width.
   ⚠️ Scoped to a min-width query on purpose. A bare `.psum-panel > .modal-cols` would out-rank
   the `max-width: 480px` collapse below (media queries add no specificity), and the card would
   keep two cramped columns on a phone. */
@media (min-width: 481px) {
  .psum-panel > .modal-cols { grid-template-columns: 35fr 65fr; }
}

.skills-list { display: flex; flex-direction: column; gap: 8px; }
.skill-row { /* square/retro to match the game's panels */
  border: 2px solid var(--edge); padding: 8px 10px;
  background: var(--panel-2); transition: border-color .12s, box-shadow .12s;
}
.skill-row:hover { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); }
.sk-head { display: flex; align-items: center; gap: 8px; }
.sk-ico { width: 18px; height: 18px; image-rendering: pixelated; }
.sk-name { text-transform: capitalize; }
.sk-class { font-size: 11px; color: var(--muted); text-transform: capitalize; }
.sk-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
.sk-tag { /* square chips */
  font-size: 11px; line-height: 1.4; padding: 1px 7px;
  border: 1px solid var(--edge); background: var(--panel);
  color: var(--text); font-variant-numeric: tabular-nums; white-space: nowrap;
}
.sk-tag.sk-ranged { background: rgba(90,169,255,0.18); }
.sk-tag.sk-melee { background: rgba(206,64,105,0.18); }
.sk-tag.sk-aoe { background: rgba(244,210,60,0.18); }
.sk-tag.sk-dur { background: rgba(127,201,107,0.16); }
.sk-effect { font-size: 12px; margin-top: 6px; }

.skill-hint { display: block; font-size: 11px; margin-bottom: 8px; }
.skill-row { cursor: pointer; }
.skill-row:focus-visible { outline: 2px solid var(--link); outline-offset: 1px; }

/* --- the SKILL POOL: every move this Pokémon can ever hold, one row each ---------------- */
/* Same five-cell rhythm as `.dex-move-row`, with the state column replacing class+power: the
   Pokédex answers "what is this move", this answers "do I have it yet". */
.lm-count { margin: 0 0 4px; font-size: 15px; font-variant-numeric: tabular-nums; }
.lm-count b { color: var(--link); font-size: 18px; }
.lm-row { display: grid; grid-template-columns: 10px 76px 18px minmax(0,1fr) auto;
  align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); }
.lm-row:last-child { border-bottom: none; }
/* The `:ctl` slot — a queued move's Learn / Skip, and the replace picker it expands into.
   ⚠️ `grid-column: 1 / -1` so it wraps onto its own full-width line: the picker is up to five
   buttons wide (one per known move, plus Cancel) and would otherwise be squeezed into the
   state column and wrap one word per line. */
/* ⚠️ The row gained `padding: 5px 8px` for the picker outline; drop it here so the Learn/Skip
   pair still lines up with the grid cells above it. The picking state keeps its padding,
   because there the outline is the point. */
.lm-row .learn-row { grid-column: 1 / -1; margin: 4px 0 2px; padding-left: 0; padding-right: 0; }
.lm-row .learn-row.picking { padding-left: 8px; padding-right: 8px; }
.lm-row .learn-actions .btn { font-size: 11px; padding: 5px 9px; }
.lm-unavailable { margin: 0 0 8px; font-size: 12px; }
/* The one hint that is an ACTION, not a description — it replaces the retired
   "✨ Wants to learn" heading, so it is the accent colour the queued rows are marked in
   rather than the muted grey the other hints use. */
.lm-waiting { color: var(--accent); }
.lm-key { display: flex; align-items: center; gap: 5px; }
.lm-state { font-size: 11px; color: var(--muted); text-align: right;
  font-variant-numeric: tabular-nums; }
.lm-origin { display: block; font-size: 10px; text-transform: capitalize; }
/* ⚠️ Same reset, same reason, as `.dex-move-row .move-badge`: `.move-badge` is
   `opacity: .5; filter: grayscale(.4)` by default, so a bare badge dropped in here renders
   half-faded and reads as *disabled*. See the note at `.dex-move-row .move-badge`. */
.lm-row .move-badge { opacity: 1; filter: none; min-width: 0; }

/* The state marker. A drawn shape and never a glyph — ✔ (U+2714) is rendered in colour by
   Android and Windows, i.e. an emoji in practice, and the repo rule forbids adding one to a
   player-facing surface. Precedent: `.ev-swatch`. It is aria-hidden and every row also states
   its status in words, so nothing here is carried by colour or shape alone. */
.lm-mark { display: inline-block; width: 10px; height: 10px; border: 2px solid var(--muted);
  border-radius: 50%; }
.lm-known .lm-mark { background: var(--green); border-color: var(--green); }
.lm-pending .lm-mark { background: var(--accent); border-color: var(--accent); }
/* Half-filled: had it, can have it again. */
.lm-restorable .lm-mark { border-color: var(--link);
  background: linear-gradient(90deg, var(--link) 50%, transparent 50%); }
.lm-locked .lm-mark { opacity: 0.45; }
.lm-locked .move-badge, .lm-locked .type-badge { opacity: 0.6; }
.lm-known .lm-state { color: var(--green); }
.lm-restorable .lm-state, .lm-pending .lm-state { color: var(--link); }

/* --- skill SLOT order: the index is the hotbar key, so this reorders keys 1-4 ---
   ⚠️ .sk-ord is a SIBLING of .skill-row, never a child. PokeSummary builds its skill modal
   by re-serializing `row.outerHTML`, so controls inside the row would be cloned dead into
   that modal — and its delegated `.skill-row` click would fire on every ▲/▼ tap. */
.skill-slot { display: flex; align-items: stretch; gap: 8px; }
.skill-slot > .skill-row { flex: 1; min-width: 0; }
.sk-ord { display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 2px; width: 26px; flex: 0 0 26px; }
.sk-slot-no { font-size: 11px; font-weight: 800; color: var(--muted);
  font-variant-numeric: tabular-nums; }
.sk-grip { font-size: 12px; color: var(--muted); cursor: grab; line-height: 1; }
.sk-ord-btn { width: 22px; height: 16px; line-height: 1; font-size: 9px; padding: 0;
  border: 1px solid var(--edge); background: var(--panel-2); color: var(--text); cursor: pointer; }
.sk-ord-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
.sk-ord-btn:disabled { opacity: .35; cursor: default; }
.skill-slot.sk-dragging { opacity: .45; }
.skill-slot.drop-hover > .skill-row { border-color: var(--primary);
  box-shadow: inset 0 0 0 2px var(--primary); }
body.dragging-skill .skill-row { cursor: grabbing; }

/* Skill detail modal (built client-side by the PokeSummary hook, appended to <body>) */
/* Retro/square styling — mirrors .game-modal / .modal (3px edge + inset border, no radius). */
.skillmodal-backdrop {
  position: fixed; inset: 0; z-index: 100000;
  background: rgba(0,0,0,0.6);
  display: flex; align-items: center; justify-content: center; padding: 32px 16px; overflow-y: auto;
}
.skillmodal-backdrop[hidden] { display: none; }
.skillmodal {
  position: relative; width: 360px; max-width: 92vw; max-height: 88vh; overflow-y: auto;
  background: var(--panel); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  padding: 24px; animation: modal-in 0.18s ease;
}
.skillmodal-x { /* same square button as .modal-close */
  position: absolute; top: 14px; right: 14px; width: 32px; height: 32px;
  border: 2px solid var(--edge); background: var(--panel-2);
  color: var(--muted); font-size: 15px; cursor: pointer; line-height: 1;
}
.skillmodal-x:hover { color: var(--text); }
.skillmodal-fx {
  width: 300px; max-width: 100%; height: 172px; margin: 0 auto 14px;
  border: 2px solid var(--edge); overflow: hidden; background: var(--panel-2);
}
.skillmodal-fx canvas { display: block; image-rendering: pixelated; }
/* the cloned skill row inside the modal is a static detail block, not a button */
.skillmodal-detail .skill-row { cursor: default; border: 0; background: transparent; padding: 0; }
.skillmodal-detail .skill-row:hover { background: transparent; border: 0; }
/* the modal clones the ROW only, but keep the wrapper inert if that ever changes */
.skillmodal-detail .skill-slot { display: block; }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-in { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: none; } }
@media (max-width: 480px) { .modal-cols { grid-template-columns: 1fr; gap: 0; } .modal-head { flex-direction: column; text-align: center; } }

/* one line per stat: label · IV bar · stat value */
.stat-line { display: grid; grid-template-columns: 48px 1fr 42px; align-items: center; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--border); }
.stat-line:last-child { border-bottom: none; }
.stat-lbl { color: var(--muted); font-size: 13px; font-weight: 600; }
.iv-bar { height: 10px; border: 2px solid var(--edge); background: var(--bg-2); overflow: hidden; }
.iv-bar > span { display: block; height: 100%; background: linear-gradient(90deg, var(--primary), var(--accent)); transition: width 0.2s ease; }
.stat-val { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; font-size: 15px; }

/* ---- IV hexagon (CoreComponents.stat_hex) ---- */
/* Reusable anywhere a Pokémon is shown; the axis domain is a fixed 0..31. */
.stat-hex { display: block; width: 100%; max-width: 230px; margin: 0 auto 10px; }

/* Evolution lives in the card header, pinned top-right — its own compact block rather than
   a .modal-section, so it sits beside the name/types instead of below the fold. */
.mon-evo { flex: 0 0 auto; margin-left: auto; align-self: flex-start; min-width: 210px;
  border: 2px solid var(--border); background: var(--panel-2); padding: 8px 10px; }
.mon-evo h4 { margin: 0 0 8px; font-family: var(--pixel); font-size: 9px;
  text-transform: uppercase; color: var(--link); }
.mon-evo-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
.mon-evo-row + .mon-evo-row { border-top: 1px solid var(--border); }
.mon-evo-row img { flex: 0 0 auto; }
.mon-evo-name { flex: 1 1 auto; min-width: 0; text-transform: capitalize; font-size: 12.5px; font-weight: 700; }
.mon-evo-name small { display: block; font-weight: 400; font-size: 10.5px; }
.mon-evo-row .btn { flex: 0 0 auto; padding: 6px 10px; font-size: 11px; }
.stat-hex-lbl { font-family: var(--pixel); font-size: 7px; }
.stat-hex-val { font-size: 11px; font-weight: 800; font-variant-numeric: tabular-nums; }

/* compact numeric table under the hexagon — keeps the exact figures the bars used to show */
.mon-stats { display: flex; flex-direction: column; gap: 2px; }
.mon-stat { display: grid; grid-template-columns: 42px 1fr auto; align-items: center; gap: 10px;
  padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
/* With the trained column present the row is four cells, not three. Declared as its own rule
   rather than changing the base so a payload WITHOUT evs (the market snapshot, the PvP/delve
   HUD shapes) keeps the layout it has always had. */
.mon-stat:has(.mon-stat-ev) { grid-template-columns: 42px 1fr 1fr auto; }
.mon-stat:last-child { border-bottom: none; }
.mon-stat-lbl { color: var(--muted); font-weight: 700; }
.mon-stat-iv { color: var(--accent); font-weight: 800; font-variant-numeric: tabular-nums; }
.mon-stat-iv i { color: var(--muted); font-style: normal; font-weight: 400; font-size: 10px; }
/* Trained points read as secondary to the rolled ones: same shape, quieter colour. */
.mon-stat-ev { color: var(--link); font-weight: 800; font-variant-numeric: tabular-nums; }
.mon-stat-ev i { color: var(--muted); font-style: normal; font-weight: 400; font-size: 10px; }
.mon-ev-total { margin: 6px 0 0; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
/* The legend for the hexagon's second shape. Same ice blue as the EV polygon and the trained
   column, so the colour is learned once and read everywhere. */
.ev-swatch { display: inline-block; width: 8px; height: 8px; margin-right: 5px; vertical-align: middle;
  background: var(--link); opacity: 0.75; }
.montip-ev { display: block; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; }
.mon-stat-val { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; font-size: 14px; }

/* HP line in the card header */
.mon-hp { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.mon-hp .bar { flex: 1 1 auto; min-width: 90px; }
.mon-hp .muted { font-size: 11px; font-variant-numeric: tabular-nums; }

/* Buddy level under the Happiness meter — Pokémon GO's row of hearts (`PokemonComponents.
 * buddy_hearts/1`). Each heart is two 16x16 images stacked: the empty slot, and the full heart
 * clipped from the top to whole pixel rows (an inline `clip-path`, computed server-side), so a
 * half-earned tier rises like a bar without a single blended pixel. The hearts sit on the card's
 * identity line ("No. 6 · charizard · Lv 100") and are all it shows; the words live in
 * #global-tip (the `.tip-buddy` variant below), opened by hover, a touch hold, or — this trigger
 * alone — a plain tap (`data-tip-tap` in app.js). */
.mon-ident { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; }
.mon-buddy { cursor: help; }
.buddy-hearts { display: inline-flex; gap: 2px; flex: none; }
#global-tip.tip-buddy {
  width: max-content; max-width: 260px; padding: 10px 12px; background: var(--panel-2); border-width: 3px;
  text-align: left; white-space: normal; text-transform: none; font-weight: 500;
}
#global-tip.tip-buddy b { display: block; font-size: 13px; }
#global-tip.tip-buddy .tip-buddy-detail,
#global-tip.tip-buddy .tip-buddy-help { display: block; margin-top: 4px; font-size: 11px; }
#global-tip.tip-buddy .tip-buddy-detail { font-variant-numeric: tabular-nums; }
#global-tip.tip-buddy .tip-buddy-help { color: var(--muted); }
.buddy-heart { position: relative; width: 16px; height: 16px; flex: none; }
.buddy-heart img { position: absolute; inset: 0; width: 16px; height: 16px; image-rendering: pixelated; display: block; }

/* the in-game card modal is wider than the default game modal so both columns breathe */
.mon-card-modal { max-width: 720px; }

/* ---- provenance: certificate of origin + history ---- */
/* ---- Ranked team preview ------------------------------------------------ *
 * The first arena-specific CSS in this file — the PvP HUD is otherwise entirely reused
 * overworld chrome. It deliberately restyles NOTHING: the cards are `.mon` / `.selectable` /
 * `.select-check` (the Professor's picker vocabulary), the grid is `.dex-grid`, the types are
 * `.type-ico`, and the shell is `.game-modal` inside a `.game-modal-backdrop`. Everything here
 * is layout for two rows of six inside one modal. */
.pvp-preview { max-width: 760px; text-align: center; }
.pvp-preview-sub { margin: 0 0 14px; }
.pvp-preview-clock { font-family: var(--pixel); font-size: 12px; color: var(--link); margin-left: 6px; }
.pvp-preview-side { margin-bottom: 10px; }
.pvp-preview-head { display: flex; justify-content: space-between; align-items: baseline;
  gap: 10px; margin-bottom: 6px; font-size: 13px; }
.pvp-preview-status { font-family: var(--pixel); font-size: 9px; color: var(--muted); }
.pvp-preview-status.on { color: var(--green); }
/* Six across, and small: twelve Pokémon have to fit one modal without scrolling, so this
 * overrides `.dex-grid`'s 150px auto-fill rather than inheriting it. */
.pvp-preview-grid { grid-template-columns: repeat(6, 1fr); gap: 6px; }
.pvp-preview-grid .mon { padding: 6px 4px; min-height: 0; }
.pvp-preview-grid .sprite { width: 48px; height: 48px; image-rendering: pixelated; }
.pvp-preview-grid .name { font-size: 11px; }
.pvp-preview-lv { font-size: 10px; color: var(--muted); }
.pvp-preview-types { display: flex; gap: 2px; justify-content: center; margin-top: 3px; }
.pvp-preview-vs { font-family: var(--pixel); font-size: 11px; color: var(--muted); margin: 4px 0 10px; }
.pvp-preview-note { font-size: 12px; margin: 12px 0 0; }
/* A phone cannot hold six 48px cards in a row; two rows of three per side still fits. */
@media (max-width: 620px) {
  .pvp-preview-grid { grid-template-columns: repeat(3, 1fr); }
  .pvp-preview-grid .sprite { width: 40px; height: 40px; }
}

/* Overlay ladder for the panels that open ON TOP of another one.
   Every `.game-modal-backdrop` is z-index 100, so among equals the LAST in the DOM wins — and
   the generic modal backdrop is the last of them. Anything reachable *from inside* a modal
   therefore has to say so explicitly, or it opens behind the thing that opened it:

     100  generic modal backdrop (Leaderboards, Depot, Collector, …) — and pvp_result
     150  player Inspect      — opened from a Leaderboards row
     160  Pokémon mon card    — opened from Inspect, or from a chat chip over any modal

   Both were reported: Inspect opened behind the Leaderboards and could not be seen. */
.game-modal-backdrop.above-modal { z-index: 150; }
.game-modal-backdrop.above-inspect { z-index: 160; }

.prov-pending { font-size: 13px; margin: 0; }

/* the verdict block. Colour carries meaning here, so each state also has its own
   glyph in the text (✓ / ⚠ / •) — never colour alone. */
.prov-status { border-left: 4px solid var(--border); padding: 8px 0 8px 12px; margin-bottom: 12px; }
.prov-status.ok { border-left-color: #4caf7d; }
.prov-status.warn { border-left-color: #ffb020; }
.prov-status.legacy { border-left-color: var(--muted); }
.prov-status.grant { border-left-color: #b58cff; }
.prov-status.none { border-left-color: var(--border); }

.prov-headline { font-size: 14.5px; font-weight: 700; }
.prov-verdict { font-size: 13px; margin-top: 2px; }
.prov-status.ok .prov-verdict { color: #4caf7d; }
.prov-status.warn .prov-verdict { color: #ffb020; }
.prov-status.grant .prov-verdict { color: #b58cff; }
.prov-status.legacy .prov-verdict, .prov-status.none .prov-verdict { color: var(--muted); }
.prov-changed { font-size: 12px; color: #ffb020; margin-top: 4px; }
.prov-reason { font-size: 13px; color: var(--muted); font-style: italic; margin-top: 4px; }

/* history list — same grammar as the hunt analyzer's scrollable rows */
.prov-hist { max-height: 190px; overflow-y: auto; }
.prov-row { display: flex; align-items: baseline; gap: 8px; padding: 5px 0; font-size: 13px; }
.prov-row + .prov-row { border-top: 1px solid var(--border); }
.prov-icon { flex: 0 0 auto; }
.prov-what { flex: 1 1 auto; min-width: 0; }
.prov-reason-inline { color: var(--muted); font-style: italic; }
.prov-when { flex: 0 0 auto; font-size: 11px; font-variant-numeric: tabular-nums; }

/* The id + "verify" link, deliberately above the collapsed technical block: it is the one
   value a player has to carry to the website to check the certificate themselves.
   `user-select: all` so a single click selects the whole uuid — it is long enough that a
   partial drag-select is the likely outcome otherwise. */
.prov-check { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-top: 12px; padding: 8px 10px; border: 2px solid var(--border); background: var(--bg-2); }
.prov-check .muted { font-size: 11px; }
.prov-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px;
  word-break: break-all; user-select: all; flex: 1 1 220px; }
.prov-check-link { font-size: 11px; white-space: nowrap; }

/* The certificate link on the Auction House's purchase modal. A line of its own between the
   Pokémon and the price, rather than `.prov-check-link`'s inline treatment: there is no `.prov-check`
   row here to sit inside, and this is the one thing on that modal a buyer might not know to look
   for. Muted until hover — it must be findable without competing with "Buy It Now". */
.ah-history-link { display: inline-block; margin-top: 12px; font-size: 12px; color: var(--muted); }
.ah-history-link:hover { color: var(--primary-2); }

.prov-tech { margin-top: 12px; }
.prov-tech summary { cursor: pointer; font-size: 12px; }
.prov-tech .kv { margin-top: 8px; font-size: 12.5px; }
.prov-hash { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; word-break: break-all; }
.prov-caveat { font-size: 11px; margin: 10px 0 0; line-height: 1.5; }

/* ---- ✨ "Wants to learn" (PokemonComponents.pending_moves/1) ----
   Was inline styles back when the block lived in PlayLive's template; it has two callers
   now (the depot detail pane and the overworld inspect card), so it gets real classes. */
/* ONE LINE PER MOVE: the badge on the left, the action on the right. This was a plain block,
   so every restorable move cost two lines and a Pokémon with six of them was a twelve-line
   wall. `wrap` is what makes it degrade on a phone (and under the 620px rule below, where
   `.moves-col` loses its max-width) instead of overflowing. */
.learn-row {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: 10px; margin-bottom: 8px; padding: 5px 8px;
  /* ⚠️ A TRANSPARENT border, not none. `.picking` below only switches its COLOUR, so opening
     the picker cannot nudge every row under it by 2px — a layout jump on the one click this
     whole change exists to make legible. */
  border: 2px solid transparent;
}
/* ⚠️ `.moves-wrap` resets `max-width` but NOT the `width: 100%` and `margin-top: 12px` the
   badge inherits from `.moves-col` — both fight a flex row, and the width one silently pushes
   the action button onto its own line again, i.e. straight back to the bug. */
.learn-row .learn-badge { margin: 0; width: auto; flex: 1 1 auto; }
/* ⚠️ Scoped to `.learn-row >`. There is a FOURTH `.learn-actions` — the sealed-HM teaser pill
   row in professor_live.ex — which is not a learn row at all. */
.learn-row > .learn-actions { flex: 0 0 auto; }
/* The forget-picker is open for THIS row: it becomes a block of its own so the four buttons are
   unmistakably attached to the move above them rather than floating between two rows. The
   prompt names the move as well — belt and braces, because the colour alone is not an answer
   for anyone who cannot see it. */
.learn-row.picking {
  flex-direction: column; align-items: stretch;
  border-color: var(--accent); background: var(--panel-2); margin: 6px 0;
}
/* The gap owns the spacing now that the row is a flex column. */
.learn-prompt { margin: 0; }

/* Stacking is the layout this change exists to remove, so it is the LAST resort: shrink the
   pixel-font button first (`.btn` is 10px type in 13px/18px padding, which is what actually
   runs out of room beside a move name), and only give up the single line under ~400px. */
@media (max-width: 560px) {
  .learn-row { gap: 6px; padding: 5px 6px; }
  .learn-row > .learn-actions .btn { font-size: 9px; padding: 9px 11px; gap: 5px; }
}
@media (max-width: 400px) {
  .learn-row { align-items: stretch; }
  .learn-row .learn-badge,
  .learn-row > .learn-actions { flex: 1 1 100%; }
}
.learn-actions { display: flex; flex-wrap: wrap; gap: 6px; }

/* Professor Oak's restore list — which machine taught a move, and how hard that move hits.
   `.moves-col` is a flex COLUMN, so the two pills need their own row wrapper or they stack.
   Tier colours mirror the wiki's `.tm-t1..3` exactly: a TM's tier is one vocabulary, and
   `.pill` draws its border from currentColor, so the colour paints the border too. */
.prof-machine { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.prof-machine .tm-t1 { color: var(--muted); }
.prof-machine .tm-t2 { color: var(--link); }
.prof-machine .tm-t3 { color: var(--accent); }

/* ---- move hover tooltip ----
   The box itself is #global-tip (see the .gtip block below): the tooltip used to be an
   absolutely-positioned child here, and `.game-modal` / `.game-modal-body` / `.modal` all
   carry `overflow-y: auto`, which computes overflow-x to `auto` too — so a badge anywhere
   near a modal edge had its tooltip sliced off. Only .mt-* content styling lives here now;
   they are deliberately unscoped so they apply once the markup is teleported. */
.move-badge { cursor: help; }
/* .gtip is display:inline-flex for the bare <.tooltip> wrapper; the badge brings its own */
.move-badge.gtip { display: flex; }
.moves-wrap { flex-direction: row; flex-wrap: wrap; max-width: none; }
.moves-wrap .move-badge { opacity: 1; filter: none; }
.mt-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
.mt-name { font-size: 14px; color: var(--text); text-transform: capitalize; }
.mt-meta { display: flex; flex-wrap: wrap; gap: 5px; color: var(--muted); font-size: 11px; font-weight: 700; margin-bottom: 6px; text-transform: capitalize; }
.mt-effect { display: block; font-size: 12.5px; line-height: 1.45; color: var(--text); }

/* ---- team card HP bars ---- */
.bar .hp-ok { background: linear-gradient(90deg, #4caf7d, #6ddf9c); }
.bar .hp-low { background: linear-gradient(90deg, #d9a441, #f0c14b); }
.bar .hp-fainted { background: linear-gradient(90deg, #8a2f3f, #ce4069); }

/* ---- realtime battle: per-move cooldowns ---- */
.hp-bar > span { transition: width 0.25s linear; }
.battle-move {
  position: relative; overflow: hidden;
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  padding: 7px 12px; border: 2px solid var(--edge); font-weight: 800; font-size: 13px;
  text-transform: capitalize; transition: opacity 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
}
.battle-move .mb-name, .battle-move .mb-cd { position: relative; z-index: 1; }
.battle-move .mb-cd { font-size: 11px; font-variant-numeric: tabular-nums; opacity: 0.9; }
.battle-move.on-cd { opacity: 0.6; filter: grayscale(0.3); }
.battle-move .cd-fill {
  position: absolute; left: 0; bottom: 0; height: 3px; width: 0;
  background: var(--accent); opacity: 0; transition: width 0.25s linear;
}
.battle-move.on-cd .cd-fill { opacity: 1; }
.battle-move.used {
  opacity: 1; filter: none; transform: scale(1.06);
  box-shadow: 0 0 0 2px #fff, 0 0 18px 4px rgba(255, 255, 255, 0.55);
  animation: move-pop 0.45s ease;
}
.battle-event {
  margin-top: 14px; text-align: center; font-weight: 800; font-size: 14px;
  padding: 9px; border: 2px solid var(--edge); background: var(--panel-2);
}
.ev-caught { color: #6ddf9c; }
.ev-defeated { color: var(--muted); }
.ev-fainted { color: #f0c14b; }

/* ---- farm progression: player + pokemon level/XP ---- */
.prog-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 26px; align-items: start; }
.prog-block { min-width: 0; }
.prog-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 8px; }
.prog-label { font-weight: 800; color: var(--text); text-transform: capitalize; }
.prog-lvl { font-size: 16px; font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; }
.prog-cap { color: var(--muted); font-weight: 600; font-size: 12px; }
.prog-max { margin-left: 6px; color: var(--xp-b); font-size: 11px; letter-spacing: 0.06em; }
.xp-bar { height: 12px; }
.xp-bar .xp-fill { display: block; height: 100%;
  background: linear-gradient(90deg, var(--xp-a), var(--xp-b));
  box-shadow: 0 0 6px var(--xp-glow); transition: width 0.3s ease; }
.prog-sub { display: flex; justify-content: space-between; gap: 10px; margin-top: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.prog-gain { grid-column: 1 / -1; text-align: center; font-size: 13px; color: var(--muted); border-top: 1px solid var(--border); padding-top: 12px; }
.prog-gain b { color: var(--green, #6ddf9c); }
@media (max-width: 620px) { .prog-panel { grid-template-columns: 1fr; } }

/* ---- profile: the five professions' ladders ----
   ⚠️ LAYOUT ONLY. The bar itself is `.bar.xp-bar > .xp-fill`, the same pair the farm's
   progression panel and the offline report use, so the fill colour comes from --xp-a/--xp-b like
   every other XP meter. The palette block at the top of this file states why: a hand-picked
   gradient here would make "how much XP" look like two different meters again. */
.prof-lads { display: flex; flex-direction: column; gap: 14px; }
.prof-lad { display: grid; grid-template-columns: 28px 1fr; gap: 10px; align-items: start; }
/* `image-rendering: pixelated` — a 64px source drawn at 28 is a large non-integer reduction, and
   smoothing a pixel-art icon at that ratio is exactly what the art rule forbids. */
.prof-lad-ico { image-rendering: pixelated; margin-top: 2px; }
.prof-lad-body { min-width: 0; }
.prof-lad-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 5px; }
.prof-lad-lv { font-size: 13px; font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--muted); }
/* Mastered reads in the XP colour, matching `.prog-max` — one visual vocabulary for "this track
   is finished" wherever a track can finish. */
.prof-lad-lv.max { color: var(--xp-b); }
.prof-lad-foot { margin-top: 5px; font-size: 12px; font-variant-numeric: tabular-nums; min-height: 15px; }

/* ---- team / depot tabs ---- */
/* `flex-wrap` because the ESC panel's bar reached five tabs with the Notifications one, and
   pt-BR/es labels are longer than the English — without it the last tab is simply unreachable
   on a narrow panel rather than moving to a second line. Inert for every other bar sharing this
   class: wrapping only engages once the items would overflow. */
.tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 22px; border-bottom: 2px solid var(--edge); }
/* `position: relative` is here only so a .notif-dot can anchor to a tab (ESC menu →
   Account). Harmless for the team/depot/profile tab bars that share this class — none
   of them positions anything inside a tab. */
.tab { padding: 10px 16px; font-family: var(--pixel); font-size: 9px; line-height: 1.5; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -2px; position: relative; }
.tab:hover { color: var(--text); background: var(--panel); text-decoration: none; }
.tab.on { color: var(--text); border-bottom-color: var(--primary); }
button.tab { background: transparent; border: 0; border-bottom: 2px solid transparent; cursor: pointer; font: inherit; font-family: var(--pixel); font-size: 9px; }
.modal-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 2px 0 20px; align-items: center; }
.modal-actions .btn { padding: 9px 14px; }

/* ---- the Game Menu (Esc) ----------------------------------------------------
   A short vertical stack of doors, WoW's Game Menu shape. It is the one panel in the game
   that is a LIST rather than a screen, so it sizes to its contents.

   ⚠️ Groups are separated by a RULE, not by a gap alone — WoW's menu does the same, and the
   grouping IS the information here: outward links, then system, then the session. Eight
   evenly spaced buttons read as one undifferentiated list and the menu stops being scannable,
   which is the only thing it is for. */
.gmenu { min-width: 220px; }
.gmenu-group { display: flex; flex-direction: column; gap: 6px; padding: 0 0 12px; margin: 0 0 12px; border-bottom: 2px solid var(--edge); }
/* ⚠️ The LAST group carries no rule and no bottom margin — a divider under "Return to Game"
   draws a line against the panel's own edge, two borders a few pixels apart. */
.gmenu-group:last-child { border-bottom: 0; padding-bottom: 0; margin-bottom: 0; }
/* The Log Out form exists only so the POST works; `display: contents` keeps its button as a
   direct flex item of the group so it lines up with every other row instead of becoming a
   nested box of its own. */
.gmenu-group form { display: contents; }

.gmenu-btn {
  display: block; width: 100%; box-sizing: border-box;
  padding: 9px 14px; text-align: center;
  font-family: var(--pixel); font-size: 9px; line-height: 1.5;
  color: var(--text); background: var(--panel);
  border: 2px solid var(--edge); cursor: pointer;
  position: relative;   /* only so a .notif-dot can anchor to the Settings row */
}
.gmenu-btn:hover { border-color: var(--primary); text-decoration: none; }
/* ⚠️ An <a> and a <button> share this class — the community links are real navigations to
   another origin. Without this the anchor takes the browser's link colour and the two rows
   inside one group stop matching. */
a.gmenu-btn { color: var(--text); }
.gmenu-btn-danger:hover { border-color: #ef4444; }
/* Borrowed wholesale from `.tab .notif-dot`, offsets included. */
.gmenu-btn .notif-dot { position: absolute; top: 6px; right: 6px; width: 7px; height: 7px;
  border-radius: 50%; background: #ef4444; pointer-events: none; }

/* ---- ESC settings panel: section headings ----
   Every <h4> inside the four ESC tabs carried the same two inline margins, repeated nine
   times. One rule instead, so a new section is spaced like its neighbours by default.

   ⚠️ `--top` is an explicit modifier and NOT `:first-child`, because the first element of a
   panel is not always the heading — Sound and Controls both open with a `.muted` hint line,
   so a positional selector would apply the wrong margin in some tabs and not others, and
   would silently start applying it to the wrong node the moment a panel gains a lead-in. */
.set-sec { margin: 18px 0 4px; }
.set-sec--top { margin: 4px 0 4px; }

/* ---- ESC menu → Notifications tab ----
   One row per event, one checkbox column per channel. The header and every row carry the SAME
   `grid-template-columns`, which is what lines the checkboxes up — they are separate grids
   rather than one, because a single grid with `display: contents` rows loses the row border and
   the hover target. The two channel columns are a fixed width for the same reason: `auto` would
   size each row to its own checkbox and nothing would align. */
/* ---- 💬 Chat settings modal (`:chat_settings`) — the checklist behind the chat panel's cog.
   Same row anatomy as the notifications grid above: name + one-line hint on the left, the
   checkbox in a fixed column so all five line up. The whole row is the <label>, so the text is
   a click target too. */
.game-modal.chat-cfg-modal { max-width: 460px; }
/* 🔴 The body is `.chat-cfg-body` and must never be `.chat-cfg` — that is the chat panel's
   24x24 cog BUTTON further down this file, and both it and `.modal-section` are single-class
   rules, so the cog (later in source) won and squeezed this entire checklist into a bordered
   24px inline-flex square. It shipped that way. The `img` rule below would have shrunk the
   title icon too, and `:hover` repainted the panel.
   It is also the modal's ONLY section, so `.modal-section`'s top border and offset — which
   exist to separate one section from the one above it — would draw a stray rule above the
   title with nothing on the other side of it. */
.chat-cfg-body { border-top: 0; margin-top: 0; padding-top: 0; }
.chat-cfg-row { display: grid; grid-template-columns: 1fr 40px; align-items: center; gap: 8px;
  padding: 10px 0; border-bottom: 1px solid var(--edge); cursor: pointer; }
.chat-cfg-row:last-child { border-bottom: 0; }
.chat-cfg-ev { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.chat-cfg-ev b { font-size: 13px; }
.chat-cfg-ev .muted { font-size: 11px; line-height: 1.35; }
.chat-cfg-row input { justify-self: center; cursor: pointer; }
.chat-cfg-row:has(input:disabled) { cursor: not-allowed; }
.chat-cfg-row input:disabled { cursor: not-allowed; opacity: .45; }
.notify-grid { margin-top: 10px; }
.notify-head, .notify-row { display: grid; grid-template-columns: 1fr 84px 84px; align-items: center; gap: 8px; }
.notify-head { padding: 0 0 6px; border-bottom: 1px solid var(--edge); font-size: 11px; color: var(--muted); }
.notify-head span { text-align: center; }
.notify-head span:first-child { text-align: left; }
.notify-row { padding: 9px 0; border-bottom: 1px solid var(--edge); }
.notify-row:last-child { border-bottom: 0; }
.notify-ev { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.notify-ev b { font-size: 13px; }
.notify-ev .muted { font-size: 11px; line-height: 1.35; }
/* The cell is what holds the column open when a row has no checkbox for that channel — a shiny
   has no WhatsApp message and a mailbox arrival has no browser popup, and without a sized empty
   cell those rows would collapse and drag the whole grid out of line. */
.notify-cell { display: flex; justify-content: center; align-items: center; min-height: 18px; }
.notify-cell input { cursor: pointer; }
.notify-cell input:disabled { cursor: not-allowed; opacity: .45; }
@media (max-width: 520px) {
  .notify-head, .notify-row { grid-template-columns: 1fr 56px 56px; }
}

/* The cog beside the panel's own <h3>, same art as the HUD rail button. Sized to the pixel
   grid and nearest-neighbour like every other in-DOM game icon; the -3px baseline nudge is
   what sits it optically level with 12px pixel-font capitals rather than on the baseline. */
.modal-title-ico { width: 20px; height: 20px; image-rendering: pixelated; vertical-align: -3px; margin-right: 6px; }

/* ---- NPC shops ---- */
.shop-card {
  display: flex; flex-direction: column; gap: 8px; padding: 22px; text-align: center;
  background: var(--panel); border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
}
.shop-card:hover { text-decoration: none; box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); transform: translate(-2px, -2px); }
.shop-card h3 { margin: 0; font-family: var(--pixel); font-size: 12px; line-height: 1.5; color: var(--text); }
.shop-icon { font-size: 44px; line-height: 1; }
.shop-enter { color: var(--primary); font-family: var(--pixel); font-size: 9px; margin-top: 6px; }
.gold-badge {
  background: linear-gradient(90deg, var(--primary-deep), var(--accent)); color: #1a1400; font-weight: 800;
  padding: 8px 14px 8px 10px; border: 2px solid var(--edge); box-shadow: var(--shadow); font-size: 17px; font-variant-numeric: tabular-nums;
  display: inline-flex; align-items: center; gap: 6px;
}
.gold-badge::before {
  content: ""; width: 24px; height: 24px; flex: none;
  background: url("/assets/game/ui/gold.png") center/contain no-repeat;
  filter: drop-shadow(0 1px 1px rgba(0,0,0,.4));
}
.gold-badge span { font-size: 12px; font-weight: 700; opacity: 0.8; }
.cur-icon { image-rendering: auto; filter: drop-shadow(0 1px 1px rgba(0,0,0,.35)); }
.npc-bubble {
  display: flex; align-items: center; gap: 14px; background: var(--panel);
  border: 3px solid var(--edge); border-left: 6px solid var(--accent); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  padding: 14px 18px; margin: 0 0 18px;
}
.npc-face {
  font-size: 34px; width: 56px; height: 56px; flex: 0 0 auto; display: flex; align-items: center;
  justify-content: center; background: var(--panel-2); border: 2px solid var(--edge);
}
.npc-bubble p { margin: 0; font-style: italic; color: var(--text); }
.shop-row {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 12px 0; border-bottom: 1px solid var(--border); flex-wrap: wrap;
}
.shop-row:last-child { border-bottom: none; }
.shop-row-main { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
/* Both shop rows wrap their left-hand side in a .gtip trigger for the item hover card, so
   the WRAPPER is now the flex child of .shop-row and has to carry the sizing .shop-row-main
   used to. Without `min-width: 0` the ellipsis on .shop-row-desc stops working, because a
   flex item's default min-width:auto refuses to shrink below its content. */
.shop-row > .gtip { flex: 1; min-width: 0; }
.shop-row-name { font-weight: 700; color: var(--text); text-transform: capitalize; }
.shop-row-desc { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shop-row-buy { display: flex; align-items: center; gap: 12px; }

/* The shared quantity control (CoreComponents.qty_stepper/1), as used inside the qty modal:
   a row of buttons + a number field, with a drag-to-choose slider under it.
   ⚠️ Deliberately NOT on a shop row — see the ShopLive moduledoc. */
.qty-stepper { display: flex; flex-direction: column; gap: 10px; width: 100%; }
/* ⚠️ `nowrap`: the row is ONE line, always. It wrapped once the per-purchase cap became 9999 and
   `Max (9999)` outgrew the modal — the controls shrink instead (padding, then the field). */
.qty-stepper-row { display: flex; align-items: stretch; gap: 5px; flex-wrap: nowrap; justify-content: center; }

/* ⚠️ EVERY control in the row is exactly --qty-h tall, set here and nowhere else. `.btn` is
   padding-sized with a 10px pixel font, so "Máx (41)" wrapped to two lines and grew taller
   than its neighbours the moment the number had two digits. `nowrap` stops the wrap,
   `height` stops any future label from re-introducing the same ragged row. */
.qty-stepper { --qty-h: 40px; }
.qty-stepper-row .qty-step {
  height: var(--qty-h); box-sizing: border-box; padding: 0 8px;
  font-size: 10px; line-height: 1; white-space: nowrap;
  justify-content: center; min-width: 34px; flex: 0 1 auto;
}
.qty-field {
  height: var(--qty-h); box-sizing: border-box;
  width: 68px; flex: 0 1 68px; min-width: 52px; text-align: center; font-variant-numeric: tabular-nums; font-weight: 800;
  font-size: 15px; background: var(--panel-2); color: var(--text);
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border); padding: 0 4px;
}
.qty-field:focus { outline: none; border-color: var(--primary); box-shadow: inset 0 0 0 2px var(--primary); }
/* the number input's native spinners fight a 68px field and duplicate the −/+ buttons */
.qty-field::-webkit-outer-spin-button, .qty-field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.qty-field { -moz-appearance: textfield; appearance: textfield; }

/* Drag-to-choose. Square thumb, hard edges — a native round slider reads as a different
   toolkit next to the rest of this UI. */
.qty-range {
  -webkit-appearance: none; appearance: none; display: block; width: 100%; height: 14px; margin: 0;
  background: var(--panel-2); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border); cursor: pointer;
}
.qty-range::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; width: 16px; height: 22px; border-radius: 0;
  background: var(--primary); border: 3px solid var(--edge); box-shadow: 2px 2px 0 var(--edge); cursor: grab;
}
.qty-range::-moz-range-thumb {
  width: 16px; height: 22px; border-radius: 0;
  background: var(--primary); border: 3px solid var(--edge); box-shadow: 2px 2px 0 var(--edge); cursor: grab;
}
.qty-range:focus { outline: none; }
.qty-range:focus::-webkit-slider-thumb { background: var(--primary-2); }

/* The shop modal is centred and its actions should be too — and every one of them the same
   height, for the same reason the stepper row pins one: `.btn` is padding-sized, so any
   label that wraps grows past its neighbours. */
.shop-modal .modal-actions { justify-content: center; }
.shop-modal .modal-actions .btn {
  height: 40px; box-sizing: border-box; padding: 0 14px;
  justify-content: center; white-space: nowrap;
}

/* the 🛒 badge on a row already in the basket */
.chip.in-cart { background: var(--primary); color: var(--edge); border-color: var(--edge); font-weight: 800; }

/* The cart bar's line list. It sits inside .shop-bar-info, which is the flexible side. */
.cart-lines { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.cart-line {
  display: inline-flex; align-items: center; gap: 6px; font-size: 12px;
  background: var(--panel-2); border: 2px solid var(--border); padding: 3px 6px;
}
.cart-line i { font-style: normal; }
.cart-x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 12px; padding: 0 2px; }
.cart-x:hover { color: var(--danger); }
.price { font-weight: 800; font-variant-numeric: tabular-nums; color: #ffd447; white-space: nowrap; }
/* 480: the stepper's six controls plus their gaps come to ~390px with `Máx (9999)` (the cap is
   the largest stack since 2026-10-13), and the modal's 24px padding leaves 386 inside 440. */
.shop-modal { max-width: 480px; text-align: center; }
.qty-row { display: flex; align-items: center; justify-content: center; gap: 12px; margin: 6px 0 18px; }
.qty-btn { width: 44px; justify-content: center; font-size: 20px; font-weight: 800; padding: 8px 0; }
.qty-num { font-size: 30px; font-weight: 800; min-width: 60px; font-variant-numeric: tabular-nums; }
.qty-summary { background: var(--panel-2); border: 2px solid var(--edge); padding: 14px; }
.qty-total { font-size: 16px; margin-bottom: 4px; }
.qty-total b { color: #ffd447; }

/* ---- Shop toolbar: search + sort (Buy & Sell) + Sell filter chips ---- */
.shop-toolbar { display: flex; gap: 8px; align-items: center; margin: 10px 0; flex-wrap: wrap; }
.shop-search { flex: 1; min-width: 160px; }
.shop-search input {
  width: 100%; padding: 8px 12px; background: var(--bg-2); color: var(--text);
  border: 2px solid var(--border); font-size: 14px; border-radius: 0;
}
.shop-search input:focus { outline: none; border-color: var(--primary); box-shadow: 2px 2px 0 var(--edge); }
.shop-toolbar select { font-size: 13px; }
.shop-filters { margin-bottom: 2px; }

/* ---- Shop Sell: selectable rows (reuses .select-check, laid out inline for rows) ---- */
.shop-row.selectable { cursor: pointer; padding-left: 8px; padding-right: 8px; }
.shop-row.selectable:hover { background: var(--panel-2); }
.shop-row.selected { background: var(--panel-2); box-shadow: inset 3px 0 0 var(--primary); }
.shop-row .select-check { position: static; flex: none; }
.shop-row.selected .select-check { background: var(--primary); border-color: var(--edge); color: var(--edge); }

/* ---- Pokémon Collector ---- */
.price-badge { margin-top: 8px; font-weight: 800; color: #ffd447; font-variant-numeric: tabular-nums; font-size: 15px; }

/* ---- Collector bulk selection ---- */
.mon.selectable { cursor: pointer; }
.mon.selectable:hover { box-shadow: inset 0 0 0 2px var(--muted); }
.mon.selected { box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); }
.select-check {
  position: absolute; top: 8px; left: 8px; width: 22px; height: 22px;
  border: 2px solid var(--edge); background: var(--panel-2); display: flex; align-items: center;
  justify-content: center; font-weight: 800; font-size: 13px; color: transparent; line-height: 1;
}
.mon.selected .select-check { background: var(--primary); border-color: var(--edge); color: var(--edge); }

/* ---- Sale protection: a Pokémon on a team or locked by the player ----
   Shown rather than hidden (the lock is a toggle, and a Pokémon that vanishes right after you
   lock it reads as a loss), so the cell stays and only loses its checkbox. The 🛡 occupies the
   exact box .select-check would have — .mon has no free corner and needs none, because
   "this cannot be ticked" is the whole message. Used by both the Collector grid and the
   Auction's listing form. */
.mon.mon-locked { opacity: 0.55; cursor: not-allowed; }
.mon.mon-locked:hover { box-shadow: inset 0 0 0 2px var(--border); transform: none; }
.mon-lock {
  position: absolute; top: 8px; left: 8px; width: 22px; height: 22px;
  display: flex; align-items: center; justify-content: center;
  font-size: 13px; line-height: 1; pointer-events: none;
}
/* The sticky action bar above a list: what is selected on the left, what to do with it on
   the right. Named for the shop rather than for selling since the Buy tab's cart uses it
   too — the Collector's bulk-sell bar is the third host. */
.shop-bar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; position: sticky; top: 8px; z-index: 5; }
.shop-bar-info { font-size: 15px; font-variant-numeric: tabular-nums; }
.shop-bar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* the cart's info column stacks its lines under the totals, so it must not be centred */
.shop-cart { align-items: flex-start; }

/* ---- farm controls: potions, throw ball, VIP ---- */
.farm-controls { display: flex; flex-direction: column; gap: 12px; }
.fc-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
.fc-hp { display: flex; align-items: center; gap: 10px; }
.fc-vip { border-top: 1px solid var(--border); padding-top: 12px; }
.fc-toggles { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.fc-novip { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.vip-badge { background: linear-gradient(90deg, var(--primary-deep), var(--accent)); color: #1a1400; font-family: var(--pixel); font-size: 9px; padding: 5px 10px; border: 2px solid var(--edge); }
.toggle { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; font-size: 14px; cursor: pointer; user-select: none; }
.toggle input { width: 18px; height: 18px; accent-color: var(--primary); cursor: pointer; }
.throw-btn { margin-top: 10px; }

/* ---- defeated wilds catch queue ---- */
.defeated-list { display: flex; gap: 12px; flex-wrap: wrap; }
.defeated-card {
  display: flex; align-items: center; gap: 10px; padding: 10px 12px;
  background: var(--panel-2); border: 2px solid var(--edge);
}
.defeated-card img { image-rendering: pixelated; }
.defeated-info { min-width: 70px; }
.defeated-info .name { font-weight: 700; color: var(--text); font-size: 14px; }

/* ---- farm settings form ---- */
.fc-settings { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.fc-field { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; color: var(--muted); }
.fc-field select {
  background: var(--bg-2, var(--panel-2)); color: var(--text); border: 2px solid var(--border);
  padding: 6px 8px; font-size: 13px; font-weight: 600; cursor: pointer;
}

/* ---- themed dropdown: native <select> matched to the text inputs (see <.select>) ---- */
select.game-select {
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
  background-color: var(--bg-2);
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2 4l4 4 4-4' fill='none' stroke='%238fa3c4' stroke-width='2'/></svg>");
  background-repeat: no-repeat; background-position: right 10px center; background-size: 11px;
  color: var(--text); border: 2px solid var(--border); border-radius: 0;
  padding: 8px 30px 8px 12px; font: inherit; font-size: 14px; line-height: 1.2; cursor: pointer;
}
select.game-select:hover { border-color: var(--primary-deep); }
select.game-select:focus { outline: none; border-color: var(--primary); box-shadow: 2px 2px 0 var(--edge); }
select.game-select option { background: var(--panel); color: var(--text); }
.fc-auto { display: inline-flex; align-items: center; gap: 14px; flex-wrap: wrap; padding-left: 14px; border-left: 1px solid var(--border); }

/* ==== single-page game shell ==== */
.game-root { position: fixed; inset: 0; overflow: hidden; }
.overworld { position: fixed; inset: 0; z-index: 0; }
.overworld canvas { display: block; }

/* ==== UI scale (☰ → Settings → Display) ==========================================
   `zoom`, not `transform: scale()` and not calc() threading:

   · zoom REFLOWS. A scaled panel really occupies its new size, so the rails still
     stack, text still wraps and every max-height scroller still fits. A transform
     would leave the layout at 100% — and, fatally, would make every position:fixed
     DESCENDANT resolve against the transformed box instead of the viewport, which is
     exactly the trap the .hud-rail block below is written to avoid.
   · zoom scales EVERYTHING. A calc(… * var(--ui)) pass would have had to enumerate
     the 2-3px hard borders, the `inset 0 0 0 2px` rings, --shadow's 4px offset, every
     gap and padding, the pixelated sprite boxes and all 400 hard-coded font-sizes —
     in a 3411-line file with no build step and no linter to catch the one you missed.

   ⚠️ THIS IS AN ALLOW-LIST — never put it on html, body or .game-root itself.
   #game-canvas is a SIBLING of every layer below and MUST stay at 1. Phaser runs
   Scale.RESIZE, i.e. the camera is sized in CSS pixels from its parent box, so a
   scaled canvas is literally a wider view of the world — the same free wallhack that
   dprRatio()/appliedZoom() in app.js exist to cancel for browser page zoom. There is
   also no escape hatch once an ancestor is zoomed: effective zoom is the PRODUCT of
   every ancestor, so `zoom: 1` on the canvas would reset nothing.

   ⚠️ `ui.fair_zoom` happens to neutralise this too — it measures the preference against a
   1920x1080 reference viewport, so a doubled local box doubles the camera zoom with it and
   the world box cancels, exactly as it does for browser page zoom. DO NOT relax the rule on
   the strength of that. It is a FLAG: a GM can switch it off in seconds, and the prohibition
   would then be silently load-bearing again with nobody having touched this file. The other
   reasons stand regardless — `#game-canvas` is a sibling of every HUD layer, so scaling it
   desynchronises world coordinates from the layers positioned over them, and a `zoom: 0.5`
   canvas renders the whole world at twice the internal resolution for no visible gain.

   Safe to apply per-layer rather than to one wrapper because every direct child of
   .game-root is already `position: fixed` (.hud-panel gets it from an inline style),
   and fixed ALWAYS creates a stacking context — so each of these already had one
   before zoom was involved, and this cannot introduce the .farm-overlay trap the
   .hud-rail comment below documents.

   ⚠️ A new full-screen layer that is a child of .game-root is covered automatically.
   One built on document.body — the .vstick / #global-tip / .ctx-menu family — is NOT,
   and needs a name in the second rule AND its JS positioning audited first:
   getBoundingClientRect() and MouseEvent.clientX are SCREEN px while offsetWidth,
   clientWidth and anything written back into style.left are LOCAL px. Zoom is what
   makes those two stop being the same number. */
.game-root > *:not(.overworld) { zoom: var(--ui); }

/* Body-level layers: outside .game-root on purpose (morphdom re-patches .hud on every
   `pos` save and would delete a foreign node mid-drag), so outside the rule above too.
   Only the ones positioned purely by CSS are listed — see the ⚠️ above.

   Deliberately ABSENT, and not an oversight:
   · .vstick / .emote-capture — a thumb target is sized to a thumb, not to a
     screen. Shrinking the stick makes the phone this setting exists for HARDER to
     play, and zoom would quietly undercut the 44px tap floor at ~1538 (that floor is
     applied in local px and then multiplied). Leaving them at 1 also keeps the stick's
     own math correct for free: its rect, its pointer events and its knob transform
     agree today only because nothing zooms them.
   · #global-tip / .ctx-menu / #farm-card / .emote-wheel — these position themselves
     from JS in viewport px and need the screen↔local conversion described above before
     they can be scaled. Left at 1 for now: they are small transient overlays, not the
     chrome that "doesn't fit". */
.toast-stack, .dialogue-box, #debug-overlay, .hud-hidden-hint { zoom: var(--ui); }

/* Standalone routes (/farm, /pokedex, /auction, /exchange) render Layouts.app/1, whose
   only two roots are these. GameLive's shell renders neither — it has no <Layouts.frame>
   — and embedded children render .embedded-frame, so nothing here can ever nest inside
   the .game-root rule and scale twice. */
header.nav, main { zoom: var(--ui); }

.hud { position: fixed; inset: 0; z-index: 10; pointer-events: none; }
.hud > * { pointer-events: auto; }

/* ---------------- HUD side rails ----------------
   Two fixed flex columns down the left and right edges. Anything dropped into one
   stacks instead of needing its own top/right offsets — which is how the tutorial
   checklist, the delve objectives and the Hunt Analyzer all ended up on the same
   96px/right corner, drawing over each other.

   Deliberately siblings of .hud rather than children: `.hud > *` above would make the
   whole rail column swallow clicks meant for the canvas.

   ⚠️ This rail IS a stacking context, and cannot not be — `position: fixed` opens one on
   its own, with or without a z-index. So a fixed-position descendant can never out-paint
   the HUD however high its number: the automation modal's z 310 was capped at the rail's
   own level and the team HUD (z 12) drew over it. This comment used to say that dropping
   .farm-overlay's z-index had fixed that, which was half the trap and read as all of it.

   A modal that must clear the HUD therefore has to LEAVE — `<.portal target="#pk-modals">`,
   see the root layout. Adding a z-index/transform/filter here is still wrong, but removing
   one is no longer enough. */
.hud-rail { position: fixed; top: var(--hud-rail-top); bottom: var(--hud-rail-bottom);
  width: 340px; max-width: calc((100vw - 2 * var(--hud-edge)) * var(--ui-inv));
  display: flex; flex-direction: column; gap: var(--hud-gap); pointer-events: none; }

/* ⚠️ HUD PERFORMANCE — no `backdrop-filter` on anything that stays on screen over the canvas
   (2026-10-15). The canvas repaints every frame, so each blurred panel is a compositor layer whose
   backdrop is re-read and re-blurred every frame: 16 of them (hotslots, rail buttons, team cards,
   chat, friends, analyzer, delve HUD, bag) made the HUD 50 layers against 4 with it hidden, and a
   player on a weak GPU measurably lost FPS until Alt+Z. Behind 85-94%-opaque backgrounds the blur
   was invisible — screenshots with and without it differ by 0.2-1.2/255 inside the panels, less
   than the world animating between two frames. Modals may keep theirs; they are transient. The
   same goes for infinite animations on HUD chrome (see the empty-slot skeleton). */
/* HUD transparency (Esc → Settings → Display). --hud-alpha is 1 by default and set by the HUD
   module in app.js; lowering it makes the on-screen panels see-through. Hovering a panel snaps
   it back to solid so a very transparent HUD stays usable, and overlays that live outside these
   containers (context menus, tooltips, modals) are unaffected. */
.hud-rail, .hud-center, .hud-dock, .team-hud, .buffs-hud, .hud-hotbars {
  opacity: var(--hud-alpha, 1);
  transition: opacity 120ms ease;
}
.hud-rail:hover, .hud-center:hover, .hud-dock:hover, .team-hud:hover, .buffs-hud:hover, .hud-hotbars:hover {
  opacity: 1;
}

/* ---------------- Screenshot mode (HUD hide) ----------------
   `body.hud-hidden`, toggled by HUDHIDE in app.js off the `hide_hud` keybind (Alt+Z by
   default). Everything chrome goes; MODALS, NPC dialogue and the emote wheel deliberately
   stay — they are content, and a modal that still opens is how a player who pressed this by
   accident finds their way back (Esc still reaches Settings → Controls over a hidden HUD).

   ⚠️ `.hud` AND `.hud-rail`, because the two side rails are SIBLINGS of `.hud`, not
   children — see the .hud-rail comment above, which explains why. Hiding only `.hud` leaves
   the quest tracker and the Hunt Analyzer sitting in the middle of the shot, on the one
   screen edge nobody thinks to check. Everything else inside `.game-root` — .hud-center,
   .team-hud, .hud-hotbars, .hud-dock, .hud-exit, .hud-corner, .buffs-hud — descends from
   `.hud` and inherits. The rest of this list is the body-level layers, which live outside
   `.game-root` entirely.

   ⚠️ `visibility`, never `display: none` and never `opacity: 0`. All three look identical
   in a screenshot and only one of them is right:
     · `opacity: 0` leaves every panel HIT-TESTABLE. An invisible chat dock still eats the
       clicks meant for the canvas underneath it, which reads as the game freezing.
     · `display: none` destroys layout, and two things measure this chrome while it is up —
       the `--toast-lift` ResizeObserver reads `.hud-corner`'s offsetHeight, and the chat tab
       carousel treats a 0 clientWidth as "fits" and stops syncing its arrows (see
       .claude/rules/client/dock.md). Both would silently take a wrong reading and keep it.
   `visibility: hidden` removes hit-testing and keeps every measurement live.

   The `:hover { opacity: 1 }` rule above needs no exception: visibility is not opacity, so a
   hidden panel cannot be hovered back into view. */
body.hud-hidden .hud,
body.hud-hidden .hud-rail,
body.hud-hidden .toast-stack,
body.hud-hidden .vstick,
body.hud-hidden #global-tip,
body.hud-hidden .ctx-menu,
body.hud-hidden #farm-card { visibility: hidden; }

/* The way back. On <body>, so it is not a descendant of anything hidden above — a restore
   prompt inside the HUD is a restore prompt nobody can read. Bottom-CENTRE rather than in
   the toast corner: the stack it would otherwise sit in is one of the things that just
   disappeared, and centre is where the eye already is after the screen empties.
   Its text is written by HUDHIDE and names the player's own binding. */
.hud-hidden-hint {
  position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
  z-index: 720; pointer-events: none;
  padding: 9px 16px; background: var(--panel); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  font-size: 13px; line-height: 1.3; color: var(--muted); white-space: nowrap;
  max-width: calc((100vw - 32px) * var(--ui-inv)); overflow: hidden; text-overflow: ellipsis;
  opacity: 0; transition: opacity 260ms ease;
}
.hud-hidden-hint.on { opacity: 1; }
.hud-rail.left { left: var(--hud-edge); align-items: flex-start; }
.hud-rail.right { right: var(--hud-edge); align-items: flex-end; }

/* ---------------- Safe area: keep the HUD off the notch ----------------
   `root.html.heex` sets `viewport-fit=cover`, so the page paints edge to edge — required for the
   mobile-fullscreen work, and the ONLY "fullscreen" an iPhone can have. The price is that every
   layer fixed to a screen edge would otherwise sit under the notch, the home indicator or a
   rounded corner. This adds the inset back, in one place.

   ⚠️ Every value here is `* var(--ui-inv)` because all of these live inside the `zoom: var(--ui)`
   subtree (`.game-root > *:not(.overworld)` and the body-level list below it), and the insets are
   raw screen px. `.vstick` is the one that must NOT be here — it is deliberately unzoomed, and it
   has read `env(safe-area-inset-bottom)` directly since before these tokens existed.

   ⚠️ These are ADDED to the existing offsets rather than replacing them, so on a device with no
   insets (every desktop, most Android) each token is 0 and this block is a no-op. That is what
   makes it safe to apply unconditionally instead of behind a breakpoint.

   ⚠️ LANDSCAPE is where left/right bite, and landscape is how a phone holds this game. A rule
   that only padded top/bottom would look correct in every portrait screenshot and still put the
   menu rail under the notch in play. */
.hud-rail { top: calc(var(--hud-rail-top) + var(--hud-safe-t) * var(--ui-inv));
  bottom: calc(var(--hud-rail-bottom) + var(--hud-safe-b) * var(--ui-inv)); }
.hud-rail.left { left: calc(var(--hud-edge) + var(--hud-safe-l) * var(--ui-inv)); }
.hud-rail.right { right: calc(var(--hud-edge) + var(--hud-safe-r) * var(--ui-inv)); }
/* Fixed-size panels keep their natural height (flex: 0 0 auto) so a crowded rail
   shrinks the one panel that can scroll — the analyzer — rather than clipping them
   all. z-index applies to flex items even at position: static. */
.hud-rail > * { pointer-events: auto; flex: 0 0 auto; z-index: 11; }
/* The Hunt Analyzer belongs to a nested LiveView, so four wrappers sit between the
   rail and the panel. display:contents dissolves them so the analyzer itself becomes
   the flex item and stacks with the panels GameLive renders directly. */
.hud-rail .farm-overlay, .hud-rail .farm-overlay > *,
.hud-rail .embedded-frame, .hud-rail .farm-view { display: contents; }

/* ---------------- HUD centre stack ----------------
   The third column, and the same argument as the two rails above: the horizontal centre
   never got one, so everything centred picked its own `top:` and its own z-index and they
   collided exactly as the side panels used to. The queued-for-PvP banner was centred at
   top: 12px with z-index 14 — inside the team strip's band AND above it — so it painted
   over the middle team cards; .buffs-hud and .farm-settling were both on top: 96px; and a
   buff row that wrapped to two lines ran into the target frame at 118px.

   It starts BELOW `.team-hud`, at the same --hud-rail-top band the side rails do. The strip
   stays a full-bleed fixed bar so its six cards still spread, which is also what keeps
   --hud-rail-top a constant instead of something derived from a now-dynamic height.

   ⚠️ Centred with `left: 0; right: 0` + `align-items: center`, NEVER a translateX(-50%) —
   the .hud-rail comment above is about a transform trapping fixed descendants, and it is
   the same hazard one column over. Every item moved in here gave up its own translate.

   ⚠️ A CHILD of `.hud`, and deliberately BEFORE `.hud-dock` in the markup. The dock is also
   z 11 and also inside `.hud`, so DOM order breaks the tie — and the dock has to keep
   winning it, because `body.touchctl` widens the rails to the whole viewport under 820px
   and a centre column that out-painted the chat panel there would be the same bug in a new
   place. Hoisting this out to be a sibling of `.hud` would lift it clear of `.hud`'s z-10
   context and do exactly that.

   ⚠️ NOT a `<.portal>` target. The obvious way to get FarmLive's .farm-settling in here is
   to portal it, and morphdom re-patches `.hud` on every `pos` save and deletes foreign
   nodes — which is why #pk-toasts and #pk-modals live at body level. A body-level centre
   stack would then sit outside .game-root's stacking context, paint over every modal, and
   need its own `body:has(…)` rule: the .dialogue-box trap. GameLive holds the flag instead
   and FarmLive broadcasts it on the farm_shell: topic the two already share.

   Order is: the queue banner (the only item with a hit target, so it is first and never
   moves), the target frame, then the two that grow or blink — the wrapping buff row and the
   settling notice — last, so nothing reflows under the cursor. */
.hud-center { position: fixed; top: var(--hud-rail-top); left: 0; right: 0;
  display: flex; flex-direction: column; align-items: center; gap: var(--hud-gap);
  padding: 0 var(--hud-edge); z-index: 11; pointer-events: none; }
/* `.hud > *` sets pointer-events: auto at the same specificity, and this column spans the
   full width over the world canvas — without this every click between its items is eaten. */
.hud > .hud-center { pointer-events: none; }
.hud-center > * { pointer-events: auto; flex: 0 0 auto; max-width: 100%; }

/* The queued-for-PvP banner. ⚠️ It carried `class="hud-panel"` and an inline style, and
   `.hud-panel` has no base rule in this file — only the compound `.hud-panel.bag-panel` far
   below — so it drew as bare pixel-font text with no background, border or shadow over the
   team cards it was covering. Same chrome as `.party-hud` / `.quest-hud`: a HUD panel should
   look like one wherever it is. */
.hud-queue { display: flex; gap: 10px; align-items: center; padding: 8px 14px;
  background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }
.hud-queue .hud-queue-text { font-family: var(--pixel); font-size: 9px; }

.hud-hotbars { position: fixed; left: 50%;
  bottom: calc(12px + var(--hud-safe-b) * var(--ui-inv)); transform: translateX(-50%);
  display: flex; flex-direction: column; gap: 6px; align-items: center; }
.hotbar { display: flex; gap: 6px; }
.hotslot {
  position: relative; width: 52px; height: 52px; padding: 0; cursor: pointer;
  background: rgba(24, 34, 49, 0.85); border: 2px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border);
  display: flex; align-items: center; justify-content: center;
}
.hotslot:hover { border-color: var(--muted); }
/* use feedback (both bars): gold pulse on an accepted use — mouse (:active) + JS (.pressed).
   Blocked slots (cooldown / out of stock) get the red .denied shake instead (below). */
.hotslot:not(.empty):not(.on-cd):not(.depleted):active,
.hotslot.pressed {
  border-color: #ffd447;
  box-shadow: inset 0 0 0 2px #ffd447, var(--shadow);
  transform: translate(1px, 1px);
}
.hotslot.denied {
  border-color: #ce4069; box-shadow: inset 0 0 0 2px #ce4069, var(--shadow);
  animation: hotslot-deny 0.32s ease;
}
@keyframes hotslot-deny {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-3px); } 40% { transform: translateX(3px); }
  60% { transform: translateX(-2px); } 80% { transform: translateX(2px); }
}
.hotslot.on { border-color: var(--primary); box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); }
.hotslot img { width: 44px; height: 44px; image-rendering: pixelated; }
.hotslot .hk { position: absolute; bottom: -3px; right: 2px; font-family: var(--pixel); font-size: 8px; color: #fff; background: var(--edge); padding: 2px 4px; }
/* A keyboard shortcut on a device with no keyboard. Both hotbars — the skill bar and the
   consumable bar — tap their slots, so the badge is a label for something the player cannot
   do, sitting on the corner of a 52px target.

   Gated on `body.touchctl` ALONE, deliberately outside the ≤820px reflow block below: that
   block is about layout not fitting, this is about a keyboard not existing. `touchctl` is
   `(pointer: coarse)` plus the player's own ☰ → Settings override, so a tablet in landscape
   at 1024px loses the badge too, and a 2-in-1 docked to a keyboard gets it back — a width
   query answers neither question. Same reasoning as `body.touchctl .pk-tip`, which drops the
   hover tooltip for want of a hover.

   ⚠️ `display: none`, not a transparent colour or `font-size: 0` — .hk paints its own
   `var(--edge)` chip behind the text, and an emptied one leaves a small solid box stuck to
   every slot corner that reads as a rendering fault.

   The element stays in the DOM carrying its `data-kb`, so `Keys.applyLabels` keeps filling it
   and flipping the setting back off restores the real binding with no re-render. */
body.touchctl .hotslot .hk { display: none; }
/* ---- .pk-tip: the retro hover tooltip, shared by the whole HUD ----------------
   One look for the skill bar (move detail), the consumable bar (item detail) and the
   menu rail (what each button opens). Pure CSS on purpose: every host below is
   position:relative with no overflow clip, so a sibling absolute box is enough and
   there is no JS layer to keep in sync with LiveView patches.

   `body.touchctl` hides the lot — see the touch block further down. */
.pk-tip {
  position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
  width: max-content; max-width: 260px; z-index: 60; pointer-events: none;
  opacity: 0; visibility: hidden; transition: opacity 0.1s ease;
  background: var(--panel); border: 2px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  padding: 8px 10px; display: flex; flex-direction: column; gap: 3px;
  font-size: 11px; line-height: 1.4; color: var(--text); text-align: left;
  white-space: normal; text-transform: none; font-weight: 400;
}
/* Menu rail variant. .hud-corner is pinned to `right: 14px`, so a centred tip on the
   last button would run off the screen — and CSS cannot clamp to the viewport the way
   the JS #global-tip layer does. Anchoring the tip's RIGHT edge to the button's makes
   it grow leftward into the screen instead, which is always safe on that rail. */
.pk-tip.tip-nw { left: auto; right: 0; transform: none; }

.hotslot:hover > .pk-tip, .hotslot:focus-visible > .pk-tip,
.hud-menu-btn:hover > .pk-tip, .hud-menu-btn:focus-visible > .pk-tip { opacity: 1; visibility: visible; }

/* 🔴 Lift the HOST, not the tooltip, and this is the only thing that works.
   Both tooltip hosts used to carry `backdrop-filter: blur(2px)` (dropped 2026-10-15, see the HUD
   performance note at `.hud-rail`), and a backdrop-filter other than `none` makes an element a
   STACKING CONTEXT. Keep the lift anyway: any future filter/transform/opacity on a host re-creates
   the trap. So `.pk-tip`'s own `z-index: 60` above is resolved INSIDE
   its host and cannot compete with the host's siblings at all: every button paints in DOM order,
   which drew each tooltip BEHIND every later icon in the rail — and, because `.hud-corner` is
   `flex-wrap: wrap-reverse` and tooltips open upward, behind the whole row above it too.
   Raising `.pk-tip` further cannot fix that; it is already at the top of a box it must escape.
   Raising the host lifts the entire subtree, tooltip included, clear of its siblings.
   ⚠️ 59 is bounded at BOTH ends on purpose: above every sibling host (all `z-index: auto`), and
   below the 60-level fixed overlays — `.bag-shell` and `.emote-wheel` — which must keep covering
   the rail. Do not raise it to 60: that ties with those and resolves on DOM order instead.
   ⚠️ `:focus-visible` as well as `:hover`, matching the rule above — a keyboard user reaches the
   same tooltip and would otherwise get the buried version. */
.hud-menu-btn:hover, .hud-menu-btn:focus-visible,
.hotslot:hover, .hotslot:focus-visible { z-index: 59; }

/* ⚠️ The move card's body is `CoreComponents.move_tip_body/1`, shared with `move_badge/1`'s
   teleported tooltip, so it uses the UNSCOPED `.mt-*` classes — `.tip-head`/`.tip-meta`/
   `.tip-effect` below are scoped to `.pk-tip` and would be unstyled the moment that markup is
   teleported into `#global-tip`. These three rules only reconcile spacing: `.pk-tip` is a flex
   column with `gap: 3px`, and `.mt-head`/`.mt-meta` carry their own bottom margins for the
   wider `tip-move` box. Without them the HUD card gains ~13px of dead space.
   ⚠️ Scoped overrides only — `move_icon_tip_test.exs` asserts each `.mt-*` still has a
   top-level rule, because that is what makes them survive the teleport. */
.pk-tip .mt-head { margin-bottom: 0; gap: 6px; justify-content: space-between; }
.pk-tip .mt-meta { margin-bottom: 0; font-size: 11px; }
.pk-tip .mt-name { font-size: 12px; font-weight: 700; }

/* `.tip-head`/`.tip-key` are still the menu rail's (`hud_tip/1`) and the consumable bar's. */
.pk-tip .tip-head { display: flex; align-items: center; gap: 6px; justify-content: space-between; }
.pk-tip .tip-head b { text-transform: capitalize; }
.pk-tip .tip-meta { display: flex; flex-wrap: wrap; gap: 4px; color: var(--muted); font-variant-numeric: tabular-nums; }
.pk-tip .tip-meta.cap { text-transform: capitalize; }
.pk-tip .tip-effect { color: var(--text); }
/* the live keybind, stamped by HotbarKeys from the [data-kb] inside the tip */
.pk-tip .tip-key { flex: none; font-family: var(--pixel); font-size: 7px; color: #fff;
  background: var(--edge); padding: 2px 4px; }
.pk-tip .tip-key:empty { display: none; }
.hotslot.item .item-emoji { font-size: 24px; }
.hotslot .item-qty { position: absolute; top: -4px; left: 2px; font-size: 11px; font-weight: 800; color: #ffd447; }
.hotslot.empty { opacity: 0.4; cursor: default; }

/* consumable-bar drag & drop */
.hotslot.item.depleted { filter: grayscale(1) brightness(0.65); }
.hotslot.item.depleted .item-qty { color: #ce4069; }
.hotslot.drop-hover { border-color: var(--primary); box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); opacity: 1; }
body.shift-held #consumable-bar .hotslot.item { cursor: grab; }
body.dragging-item .hud-hotbars { z-index: 200; } /* above .game-modal-backdrop (100) during a drag */
body.dragging-item #consumable-bar .hotslot { border-color: var(--muted); opacity: 1; } /* advertise targets */
.islot[draggable="true"] { cursor: grab; }

/* ==== touch controls ====================================================
   Virtual joystick + context interact button, for phones. Both nodes live on
   document.body, NOT inside .hud — .hud sits in .game-root, whose subtree
   LiveView re-patches on every `pos` save while walking, i.e. exactly while the
   stick is held, and morphdom would delete a foreign node mid-drag. Same
   reasoning (and same fix) as .emote-wheel.

   z-index 58 puts it under .emote-capture (59), so an open emote wheel owns input.
   That is ALL it does.

   ⚠️ It does NOT put the stick under any modal, and it cannot. `.game-root` is
   `position: fixed`, which creates a stacking context all by itself (no z-index
   needed — fixed and sticky always do), so every z-index inside it, including
   .game-modal-backdrop's 100, is scoped to that context and never compares against
   a body-level sibling. .game-root paints at z:auto and the stick at 58, so the
   stick is above the ENTIRE game subtree, always. Measured with elementFromPoint
   over an open full-screen modal: the hit is .vs-knob, not the modal.
   This is why an open modal has to HIDE the stick (see the rule below) rather than
   out-rank it. Raising the modal's z-index would change nothing at all. */
.vstick {
  position: fixed; z-index: 58; left: 96px; bottom: 96px;
  width: 132px; height: 132px; transform: translate(-50%, 50%);
  background: rgba(10, 16, 28, 0.55);
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  opacity: 0.72; transition: opacity 120ms ease-out;
  touch-action: none; user-select: none; -webkit-user-select: none;
  -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;
}
.vstick.on { opacity: 1; }
/* inset square, not a ring — the HUD has no circles anywhere */
.vstick::before { content: ""; position: absolute; inset: 12px; border: 2px solid rgba(35, 64, 110, 0.55); }
.vstick .vs-knob {
  position: absolute; left: 50%; top: 50%; width: 54px; height: 54px; margin: -27px 0 0 -27px;
  background: var(--panel-2); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--primary-deep);
  will-change: transform;   /* transform-only moves: no layout thrash at 60Hz */
}
.vstick.on .vs-knob { box-shadow: inset 0 0 0 2px var(--primary); }
/* ⚠️ There is no context interact button any more. It used to sit on the opposite thumb from
   the stick, back when an NPC and a delve marker were reachable only by keybind. Both are
   reachable by tapping the thing itself now (the tap walks there and interacts on arrival),
   so it was a second thumb-sized control for something the player had already tapped. */
/* Also hides via `hidden` while the world is paused — a modal, an NPC dialogue. See
   syncStickVis() in app.js. `display` is spelled out because this is set from JS and nothing
   else here sets display. */
.vstick[hidden] { display: none; }

/* Anything owning the screen removes the touch controls. This is the AUTHORITY, and the JS
   above is the fast path — because the stick cannot be covered (see the ⚠️ note at the top
   of this block), a stick that is merely still-rendered is a stick sitting on top of the
   dialog and swallowing its taps.

   CSS rather than JS alone because this asks "is a layer on screen right now", which the DOM
   answers directly, while `syncStickVis()` can only answer "did a pause push arrive". Some
   dialogs never send one: BagLive's mint / use / repel pickers are plain `.modal-backdrop`
   layers over a deliberately-live overworld, so `hook.paused` stays false and the stick used
   to sit on top of them eating the taps meant for the Pokémon list underneath.

   ⚠️ Maintenance: a new full-screen layer belongs in this list. It will look correct without
   it — a modal's own z-index says nothing about the stick — and be wrong only on a phone.

   The `:is()` holds one selector today (it held two until the interact button was removed) and
   is kept as a list on purpose: modal_stacking_test.exs reads this rule to prove every
   body-level node it classifies as :hidden is actually in it.
   Deliberately absent: `.bag-shell`, which has no backdrop because the overworld stays live
   behind it, so its stick must stay too (GameLive skips the `overworld:modal` push for the
   bag for the same reason). */
body:has(
  .game-modal-backdrop, .modal-backdrop, .farm-pop-backdrop, .farm-set-backdrop,
  .farm-report-backdrop, .skillmodal-backdrop:not([hidden]), .help-overlay,
  .kicked-overlay, .exit-ask-overlay, .sleep-screen
) :is(.vstick) { display: none; }

/* ...and the same authority for every OTHER node that lives on <body>.
   ⚠️ This is not a z-index question and cannot be solved with one. `.game-root` is
   `position: fixed`, so it is a stacking context, and every z-index inside it —
   `.game-modal-backdrop`'s 100 included — is scoped to that context and never compares
   against a body-level sibling. A body-level node therefore paints over EVERY modal at any
   z-index, exactly as the ⚠️ note above says of the stick. The only fix is to take it off
   screen, which is what this does.
   `.dialogue-box` (z 220) was the live one: Clerk Pip's shop modal opens FROM a dialogue, so
   the box sat on top of the dim it should have been under.
   Deliberately NOT here: `#global-tip`, because modals have their own `.gtip` tooltips and it
   must out-rank them; `#pk-update-banner`, which is telling you the client is stale and should
   survive anything; `#evo-overlay` / `#lvl-overlay` / `#medal-overlay` / `#duel-cd` / `#pull-cd`, which are
   interruptive by design (`#medal-overlay` is the one of those that takes a click — its PANE is
   `pointer-events: none` and only the banner is clickable, so it cannot swallow a click aimed at
   a modal underneath it; `#pull-cd` takes no clicks at all, which is why a party countdown may
   paint over an open Depot without being able to interfere with it). `#celebrate-overlay` and
   `#celebrate-fx` (app.js `CELEBRATE`) are the same kind: the banner's pane is
   `pointer-events: none` with only the banner clickable, and the particle layer takes nothing.
   `.ctx-menu` and `.emote-wheel` need no rule — `overworld:modal` closes both. */
body:has(
  .game-modal-backdrop, .modal-backdrop, .farm-pop-backdrop, .farm-set-backdrop,
  .farm-report-backdrop, .skillmodal-backdrop:not([hidden]), .help-overlay,
  .kicked-overlay, .exit-ask-overlay, .sleep-screen
) :is(.dialogue-box, #farm-card, .emote-badge, #tut-arrow) { display: none; }
/* ⚠️ This rule was written in anticipation and resolved to 0 until `viewport-fit=cover` landed
   in root.html.heex — it is live now, and the stick genuinely sits ~34px higher on a notched
   phone. `left` joins it for landscape, where the cutout is on a side rather than the top.
   ⚠️ **Bare `env()`, with no `* var(--ui-inv)`** — unlike every other consumer of the insets in
   this file. `.vstick` is deliberately excluded from the `zoom: var(--ui)` allow-list (see the
   note above it: thumb targets keep their 44px floor), so it is already in screen px and the
   multiplication that is mandatory everywhere else would be the mirror-image bug here. */
@supports (padding: env(safe-area-inset-bottom)) {
  .vstick { bottom: calc(96px + env(safe-area-inset-bottom));
    left: calc(96px + env(safe-area-inset-left)); }
}

/* Gesture suppression. `touch-action` appeared exactly ONCE in this file before
   (the farm map) — nothing protected the world canvas, so a drag scrolled the
   page, a two-finger pinch zoomed the document out of its own fixed HUD, and a
   long press popped the iOS Copy/Share callout mid-battle. */
#game-canvas, #game-canvas canvas {
  touch-action: none; user-select: none; -webkit-user-select: none;
  -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;
}

/* Hold-to-inspect (LONGPRESS in app.js). iOS answers a long press with its OWN callout — Copy /
   Look Up / Add to Photos over an item icon — and it arrives INSTEAD of the game's menu, not
   after it, which is literally what a player got when they held an item in the bag.
   `-webkit-touch-callout` is the only switch that turns that off and it has to be somewhere on
   the pressed element's ancestor chain.

   ⚠️ Every selector on that line, because they are SIBLINGS rather than one tree. `#pk-modals` /
   `#pk-toasts` are body-level portals declared in root.html.heex, and `#ctx-menu` / `#global-tip`
   are parked on document.body by app.js — the FULLSCREEN module's note spells out the same list
   for the same reason. A rule on `.game-root` alone reaches the bag (a HUD panel) and misses
   every portalled modal, i.e. the Depot, the shop and the Auction House. (`#global-tip` is
   belt-and-braces — it is `pointer-events: none`, so no touch reaches it today at all.)

   ⚠️ Scoped to the game shell, never `body`: the website's pages are documents, where the callout
   belongs. `user-select` is deliberately NOT set here — chat messages, trade notes and the
   `user-select: all` id spans stay selectable; the triggers that must not start a selection say
   so themselves in the rule below. */
.game-root, #pk-modals, #pk-toasts, #ctx-menu, #global-tip { -webkit-touch-callout: none; }
/* No double-tap zoom on the game UI. A quick second tap on any HUD button zoomed the whole page
   — iOS Safari ignores the viewport's `user-scalable=no`, but it does honour this.
   `manipulation` still allows panning, so every scrolling list keeps scrolling; `touch-action`
   intersects down the tree, so the canvas's own `none` above is unaffected. Same element list
   as the callout rule, for the same portal reason. Pinch is handled by NOZOOM in app.js. */
.game-root, #pk-modals, #pk-toasts, #ctx-menu, #global-tip { touch-action: manipulation; }

/* Every long-press trigger. A hold on one of these means "tell me about this", so the finger must
   not also be starting a text selection — on iOS the selection handles appear over the card and
   the next tap dismisses them instead of choosing a row. An explicit list, never an ancestor:
   the shells above have to stay selectable. */
.gtip, .islot, .team-card, .fr-row, .party-row, .mon, [data-mon-id], #ctx-menu {
  user-select: none; -webkit-user-select: none;
}

/* ==== retro CRT overlay (ESC → Settings → ✨ Effects, default off) ==================
   Scanlines and a soft tube falloff, as a DOM layer over the canvas. SCREENFX in app.js
   creates and removes this element; everything about how it LOOKS is here.

   ⚠️ Deliberately not a postFX pass. Phaser 3.80 ships no scanline FX, so the shader
   routes were a custom PostFXPipeline (forbidden — removed in Phaser 4, see AMBIENCE)
   or faking the tube with the built-in Barrel. The second is the tempting one and it is
   specifically wrong here: a permanent barrel resamples every tile edge, and under
   `pixelArt` nearest filtering that gives a 32px grid pixels of visibly different sizes
   across the screen — the "crisp pixels, no smoothing" rule broken by the very toggle
   that is supposed to be a love letter to old hardware. A CSS layer also costs no GPU
   pass and still works on the Canvas renderer, where postFX is a silent no-op.

   ⚠️ It is a CHILD of #game-canvas, which is why it is `absolute` and needs no z-index
   war with the HUD: it covers the world and nothing else. The HUD staying un-scanlined
   is the point — the panels are where the text is.

   ⚠️ No flicker, no roll, no animation. A real tube does all three; a full-screen
   periodic luminance change is a photosensitivity hazard, and unlike a 500ms transition
   this is a state players leave on for hours.

   The 3px pitch is in CSS pixels and stays there: #game-canvas is excluded from the
   `.game-root > *:not(.overworld) { zoom: var(--ui) }` rule, so the UI-size slider
   cannot stretch the lines into bands. */
#game-canvas .crt-scan {
  position: absolute; inset: 0; pointer-events: none;
  background-image:
    radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 52%, rgba(0, 0, 0, 0.26) 100%),
    repeating-linear-gradient(to bottom,
      rgba(0, 0, 0, 0.26) 0, rgba(0, 0, 0, 0.26) 1px,
      rgba(0, 0, 0, 0) 1px, rgba(0, 0, 0, 0) 3px);
}
body.touchctl { overscroll-behavior: none; }   /* kills pull-to-refresh + rubber band */
/* the grey flash on tap reads as a rendering bug on a pixel-art HUD */
.hud-menu-btn, .hotslot, .team-card, .chat-tab { -webkit-tap-highlight-color: transparent; }

/* Mobile HUD reflow — only on touch devices AND only when the screen is actually
   narrow, so a touchscreen laptop keeps the desktop layout. Budget on a 390px
   phone: stick bottom-left, hotbars bottom-right above it, the corner rail
   wrapping upward, chat above that. */
@media (max-width: 820px) {
  body.touchctl { --hud-rail-bottom: 268px; }
  body.touchctl .hud-hotbars { left: auto; right: 12px; bottom: 12px; transform: none; }
  /* wrap-reverse, not wrap: the rail is anchored bottom-right, so it must grow
     UP into free space — plain wrap grows down and off the screen */
  body.touchctl .hud-corner { bottom: 118px; right: 8px; max-width: 200px; flex-wrap: wrap-reverse; gap: 6px; }
  /* the token, not a width/height pair — `.team-hud`'s exit-gutter reservation is built
     from it and has to shrink in step */
  body.touchctl { --hud-btn: 38px; }
  /* width only while EXPANDED — a collapsed chat is a 44px icon (see the
     .chat-panel.collapsed rules), and this selector outranks them on specificity,
     so without :not() it would stretch the icon back to full width */
  /* the dock carries the offset now — it is what is fixed-positioned */
  body.touchctl .hud-dock { bottom: 258px; }
  body.touchctl .chat-panel:not(.collapsed) { width: calc((100vw - 24px) * var(--ui-inv)); }
  body.touchctl .friends-rail:not(.collapsed) { width: calc((100vw - 24px) * var(--ui-inv)); }
  body.touchctl .chat-msgs { height: 96px; }
  body.touchctl .hud-rail { width: calc((100vw - 2 * var(--hud-edge)) * var(--ui-inv)); }
  /* the hover tooltip has no touch equivalent and would stick open after a tap */
  body.touchctl .pk-tip { display: none; }
}

/* moves hotbar: slots are tinted by .type-* (backgrounds defined above) */
.hotslot.move { font-weight: 800; font-size: 15px; color: #fff; text-shadow: 0 1px 2px #0009; }
.hotslot.move .move-abbr { pointer-events: none; }
/* cooldown overlay — JS-owned (phx-update ignore): the darken + radial sweep is set as an
   inline conic-gradient background by the render loop; here we only style the number. */
.hotslot .cdov { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-size: 17px; font-weight: 800; color: #fff; text-shadow: 0 1px 3px #000, 0 0 4px #000;
  pointer-events: none; border-radius: inherit; }
.hotslot.on-cd { filter: brightness(0.72); }        /* secondary cue on the icon */
.hotslot.on-cd .move-abbr { visibility: hidden; }
/* ...except under a GLOBAL cooldown, which draws no number (see the render loop): the abbr
   is hidden above only because the countdown takes its place, and with nothing to make room
   for, hiding it would blank the slot on every single cast. The dial alone is the cue. */
.hotslot.on-cd.on-gcd .move-abbr { visibility: visible; }

/* Teleport, mid-cycle: a mark stands and the next press is the blink back. The slot's dial is
   deliberately EMPTY here — the cooldown starts on the return — so without a cue of its own the
   button is indistinguishable from an idle move whose press did nothing, which is exactly what
   Teleport was before Mark & Recall. Set by `hook.applyRecallMark` off the frame, never off the
   press: the server owns whether a mark still stands. */
.hotslot.move.recall-armed { border-color: #20d6c7; box-shadow: inset 0 0 0 2px #20d6c7, var(--shadow); }
.hotslot.move.recall-armed .move-abbr { color: #a6fcdb; }

/* ---- generic hover tooltip (items, trade slots, stat labels, ...) ---- */
.gtip { display: inline-flex; }
.gtip-body { display: none !important; }
/* A disabled <button> dispatches no mouse events at all, so the delegated `mouseover` that
   fills #global-tip never fires while the pointer is over one — a .gtip wrapped around a
   disabled button would silently show nothing. Making the button transparent to hit-testing
   lets the hover reach the wrapper. The not-allowed cursor has to move up with it, since
   `cursor` does not apply to an element that is not a pointer target. */
.gtip > .btn[disabled] { pointer-events: none; }
.gtip:has(> .btn[disabled]) { cursor: not-allowed; }
#global-tip { position: fixed; z-index: 500; width: 210px; padding: 8px 10px;
  background: #0e1520; border: 2px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow); font-size: 12px;
  line-height: 1.4; pointer-events: none; }
/* Variant carried over from the .gtip-body's own class list. Move detail is a head + meta +
   effect paragraph, which reads badly at the 210px default. The text resets are scoped HERE
   rather than on #global-tip so the item/trade/stat tooltips keep the look they have. */
#global-tip.tip-move {
  width: max-content; max-width: 260px; padding: 11px 13px;
  background: var(--panel-2); border-width: 3px;
  text-align: left; white-space: normal; text-transform: none; font-weight: 500;
}

/* ---- The Pokémon hover card (PokemonComponents.mon_tip/1) ----
   Same variant mechanism as .tip-move: the class rides along from the .gtip-body. A hexagon
   plus a two-line header does not fit the layer's 210px default. */
#global-tip.tip-mon {
  width: 240px; padding: 10px 12px; background: var(--panel-2); border-width: 3px;
  text-align: left; white-space: normal; text-transform: none; font-weight: 500;
}
#global-tip.tip-mon .montip-head { display: flex; align-items: center; gap: 9px; }
#global-tip.tip-mon .montip-sprite { width: 44px; height: 44px; image-rendering: pixelated; flex: none; }
#global-tip.tip-mon .montip-id { min-width: 0; line-height: 1.35; }
/* The hexagon is `width:100%; max-width:230px` by default — inside a 240px box that would run
   its axis labels into the padding. */
#global-tip.tip-mon .stat-hex { max-width: 190px; margin: 8px auto 2px; }
#global-tip.tip-mon .montip-iv { display: block; text-align: center; color: var(--muted); font-size: 11px; }
#global-tip.tip-mon .montip-q { display: block; margin-top: 8px; text-transform: capitalize; }
#global-tip.tip-mon .montip-lock {
  display: block; margin-top: 6px; padding-top: 6px;
  border-top: 1px solid var(--border); color: var(--muted); font-size: 11px;
}

/* ---- The stat breakdown hover card (PokemonComponents.stat_tip/1) ----
   Same variant mechanism as .tip-move / .tip-mon: the class rides along from the .gtip-body.
   Three columns of six rows do not fit the layer's 210px default.
   ⚠️ `.stip-x` is pushed right with margin-left:auto rather than given a column, because it is
   absent on the three additive rows — a real grid would leave a hole where the multiplier isn't. */
#global-tip.tip-stat {
  width: max-content; max-width: 250px; padding: 10px 12px;
  background: var(--panel-2); border-width: 3px;
  text-align: left; white-space: normal; text-transform: none; font-weight: 500;
}
#global-tip.tip-stat .stip-head { display: flex; justify-content: space-between; gap: 12px;
  padding-bottom: 5px; border-bottom: 1px solid var(--border); }
#global-tip.tip-stat .stip-sub { display: block; margin: 4px 0 5px; color: var(--muted); font-size: 11px; }
#global-tip.tip-stat .stip-row { display: flex; align-items: baseline; gap: 10px; padding: 2px 0; }
#global-tip.tip-stat .stip-n { font-variant-numeric: tabular-nums; font-weight: 800; }
#global-tip.tip-stat .stip-row .stip-n { margin-left: auto; }
#global-tip.tip-stat .stip-x { margin-left: auto; color: var(--muted); font-weight: 400;
  font-variant-numeric: tabular-nums; }
/* With a multiplier present it takes the auto margin, so the number sits flush after it. */
#global-tip.tip-stat .stip-x + .stip-n { margin-left: 0; min-width: 42px; text-align: right; }
#global-tip.tip-stat .stip-foot { display: block; margin-top: 6px; padding-top: 6px;
  border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; }

/* ⚠️ A cell that IS the trigger keeps its own display — `.gtip`'s inline-flex must never
   redefine a grid item. Explicit rather than inherited from source order, which currently
   decides it BOTH WAYS: `.mon` is declared above `.gtip` and would lose it, `.dcell` below
   and would keep it by luck. Either way the next person to reorder this file changes layout
   they were not touching. Same reason `.shop-row > .gtip` and `.ha-tiles > .gtip` exist. */
.mon.gtip { display: block; }
.dcell.gtip { display: flex; }
.dparty-slot.gtip { display: flex; }
.buff-chip.gtip { display: inline-flex; }
/* ⚠️ Sixth appearance, and the loudest: a <tr> flipped to inline-flex does not merely shift —
   the whole row collapses out of the table and every cell stacks. The Exchange's market list is
   the first place the trigger is a table row rather than a grid/flex item. */
.xrow.gtip { display: table-row; }
/* ⚠️ Seventh and eighth. The bag's header title is an <h1> and its capacity readout a block —
   both are the trigger themselves (a `<span class="gtip">` wrapper cannot contain an <h1>), so
   both need their display back. */
.bag-head-title.gtip { display: block; }
.bag-cap-read.gtip { display: block; }
/* ⚠️ Ninth and tenth, for the stat-breakdown card. Both triggers are the row/cell ITSELF — a
   `<span class="gtip">` wrapper cannot sit between `.mon-stats` and its rows, nor inside a <tr>,
   without becoming the item in their place. `.mon-stat` is a grid (three columns, four with the
   trained one); `.cmp-num` is a table cell, the `.xrow` trap one level down. */
.mon-stat.gtip { display: grid; }
.cmp-num.gtip { display: table-cell; }

/* ---- WoW-style item grid / slots (bag today, trade later) ---- */
.item-grid { display: grid; grid-template-columns: repeat(auto-fill, 52px); gap: 6px; margin-top: 12px; }
.islot { position: relative; width: 52px; height: 52px; padding: 0; cursor: pointer;
  background: rgba(14, 21, 32, 0.9); border: 2px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border);
  display: flex; align-items: center; justify-content: center; }
.islot:hover { border-color: var(--muted); }
.islot.sel { border-color: var(--primary); box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); }
.islot:disabled { cursor: default; }
.islot img { width: 42px; height: 42px; image-rendering: pixelated; }
.islot .islot-glyph { font-size: 24px; }
.islot .qty { position: absolute; bottom: 1px; right: 3px; font-size: 11px; font-weight: 800;
  color: #fff; text-shadow: 0 1px 2px #000, 0 0 3px #000; }
.islot.empty { border-style: dashed; box-shadow: none; opacity: 0.3; cursor: default; }
.islot-ball { border-color: #2d4f79; }
.islot-med { border-color: #2d5c41; }
.islot-stone { border-color: #533d6b; }
.islot-tm { border-color: #2d5c66; }
.islot-key { border-color: #6b5a1e; }

/* ---- Bag header: title | capacity | gold -----------------------------------
   One row, three regions, all CLASSED. `bag_head_test.exs` and `bag_detail_layout_test.exs`
   both exist because an inline style beats any media query, and this row is the thing most
   likely to break at phone width — the 820px block below is what stops it, and it can only
   reach these boxes if the markup stays out of the way.
   ⚠️ The two `.gtip` display pins for this header live in the pin block above, next to the
   comment that explains them — not here. */
.bag-head { display: flex; align-items: center; justify-content: space-between;
  gap: 14px; flex-wrap: wrap; margin-bottom: 14px; }
/* Restates `.page-head h1`'s type, because the title is no longer a `.page-head` child. */
.bag-head-title { font-family: var(--pixel); font-size: 20px; line-height: 1.5; margin: 0;
  color: var(--primary-2); text-shadow: 2px 2px 0 var(--edge); }
/* `flex: 1` is what centres the capacity between the two ends rather than packing it left. */
.bag-head-cap { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; }
.bag-cap-read { min-width: 150px; max-width: 260px; }
.bag-cap-line { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; }
/* A modifier on `.bar` (12px, striped) rather than a second bar vocabulary: only the height and
   the fill change, so the border and the clipping stay shared.
   ⚠️ 8px, not 4px, and the arithmetic is the reason: `* { box-sizing: border-box }` is set at the
   top of this file, and `.bar` carries a 2px border, so the FILL is `height - 4`. At 4px that is
   zero — the bar rendered as its own border with nothing in it, which is exactly how it was
   reported ("I'm overcapacity but the bar is empty"). 8px leaves the 4px of fill this wanted.
   Any future slimming has to come off the border, not off this number. */
.bar.bag-cap-bar { height: 8px; margin-top: 3px; }
.bar.bag-cap-bar > span { background: #4caf7d; }
/* The over-capacity cue. One colour in one place, on the fraction and the fill together —
   the SENTENCE moved into the tooltip, the cue deliberately did not. */
.bag-cap-read.over b { color: #ce4069; }
.bag-cap-read.over .bar.bag-cap-bar > span { background: #ce4069; }
.bag-head-gold { flex: 0 0 auto; font-size: 13px; }
.bag-head-gold b { color: var(--xp-b); }

.bag-toolbar { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; margin-top: 4px; }
/* Selected-item detail: icon + text + a column of actions. `flex-wrap` is what lets the
   actions drop to their own line on a narrow panel (see the 820px block below) — the two
   inner boxes carry classes rather than inline styles for exactly that reason, since an
   inline `style` wins over any media query. */
.bag-detail { display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-start; margin-top: 14px; padding: 12px;
  background: rgba(14, 21, 32, 0.7); border: 2px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border); }
.bag-detail-glyph { font-size: 34px; }
.bag-detail-info { flex: 1 1 200px; min-width: 0; }
.bag-detail-actions { display: flex; flex-direction: column; gap: 6px; flex: 0 0 auto; }

.qty-picker { background: var(--panel); border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  padding: 16px 18px; width: 280px; }
.qty-picker .qty-row { display: flex; gap: 8px; align-items: center; }
.qty-picker .qty-row input { width: 70px; text-align: center; background: var(--panel-2);
  border: 2px solid var(--border); color: var(--text); padding: 6px; border-radius: 0; }

/* Quality tiers — per-instance roll shifting performance ~±10%.
   Rendered as the tier NAME in its colour, never as a filled pill: quality sits next to the
   species name, the type badges and the rarity pill, and as a solid box it pulled the eye
   away from all three. Colour alone still reads at a glance. There is deliberately no
   `.pill.q-*` variant — if you find yourself wanting one, that is the bug.

   The one sanctioned non-text use is the depot box cell's BORDER (`.dcell`, via
   CoreComponents.q_color/1 → the same `--q-*` vars below). That rule above is about
   quality competing with a name, type badges and a rarity pill for the same eye; a box
   cell has none of them — just a sprite — so colour is the only channel available there.
   The vars exist so the word and the border can never drift apart. */
:root {
  --q-unstable: #8a93a0;
  --q-diluted: #b8c0ca;
  --q-refined: #4caf7d;
  --q-potent: #42a5f5;
  --q-pristine: #ab47bc;
  --q-absolute: #ffd447;
}
.q-unstable { color: var(--q-unstable); }
.q-diluted { color: var(--q-diluted); }
.q-refined { color: var(--q-refined); }
.q-potent { color: var(--q-potent); }
.q-pristine { color: var(--q-pristine); }
.q-absolute { color: var(--q-absolute); text-shadow: 0 0 6px #ffd44766; }

/* Dark chips (casino currency) — a real badge, and the one thing that used to borrow
   `.pill.q-absolute` purely for its gold box. */
.pill.chips { background: #5c4a12; color: #ffd447; box-shadow: 0 0 8px #ffd44755; }

/* Sale protection on the Pokémon card, beside tier and quality — a state, not a notification.
   `.pill` is `border: 2px solid currentColor`, so the variant is one colour. */
.pill.lock-pill { color: #7dd3fc; }

/* Bottom-left dock: friends rail stacked on top of the chat panel; above the analyzer (8)
   & farm (5), below modals (100).

   ⚠️ The dock owns the fixed positioning that .chat-panel used to. The two panels have to
   stay glued as either folds, and giving the rail its own `bottom` would mean tracking the
   chat's height — which changes with collapse, tab count and viewport. `align-items:
   flex-start` keeps the dock hugging its children, so it never covers anything. */
.hud-dock { position: fixed; left: calc(12px + var(--hud-safe-l) * var(--ui-inv));
  bottom: calc(12px + var(--hud-safe-b) * var(--ui-inv)); z-index: 11;
  display: flex; flex-direction: column; gap: 8px; align-items: flex-start;
  max-width: calc((100vw - 24px) * var(--ui-inv)); }

/* chat panel — collapsible; positioned by .hud-dock above */
.chat-panel { position: relative; width: 340px; max-width: 100%;
  display: flex; flex-direction: column; overflow: hidden;
  background: rgba(18, 26, 38, 0.94); border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  font-size: 13px; }
.chat-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; cursor: pointer;
  border-bottom: 2px solid var(--border); user-select: none; }
.chat-panel.collapsed .chat-head { border-bottom: none; }
.chat-title { font-family: var(--pixel); font-size: 9px; display: inline-flex; align-items: center; gap: 6px; }
.chat-status { color: var(--muted); font-size: 11px; flex: 1; }
.chat-badge { background: #ce4069; color: #fff; font-size: 10px; font-weight: 800;
  padding: 1px 6px; }
.chat-toggle { margin-left: auto; width: 24px; height: 24px; cursor: pointer;
  border: 2px solid var(--edge); background: var(--panel-2); color: var(--text); }
/* The cog opens the chat settings modal (GameLive `:chat_settings`). It pushes the toggle right; the toggle's own `margin-left: auto`
   then has nothing left to take, so the two sit together at the end of the header. */
.chat-cfg { margin-left: auto; width: 24px; height: 24px; padding: 1px; cursor: pointer;
  border: 2px solid var(--edge); background: var(--panel-2); display: inline-flex;
  align-items: center; justify-content: center; }
.chat-cfg img { width: 18px; height: 18px; image-rendering: pixelated; pointer-events: none; }
.chat-cfg.on, .chat-cfg:hover { background: rgba(28, 38, 55, 0.95); border-color: var(--border); }
/* The tab strip is a single-line carousel: world/trade/help/guild/party/combat/system plus one
   tab per open whisper, which is unbounded. It used to `flex-wrap: wrap`, so every few tabs grew
   another ROW — each one stealing 24px from the game and shoving the message list down, with the
   panel silently getting taller the more people talked to you.

   ⚠️ `min-width: 0` on `.chat-tabs` is load-bearing and invisible when missing. A flex item
   defaults to `min-width: auto`, which refuses to shrink below its content — so the strip would
   simply widen past the panel instead of scrolling, `scrollWidth === clientWidth` for ever, and
   the arrows would never appear because nothing ever reports overflow.
   ⚠️ `flex: 0 0 auto` on `.chat-tab` for the mirror-image reason: without it the tabs squash
   into unreadable slivers rather than overflowing. */
.chat-tabbar { display: flex; align-items: stretch; gap: 2px; padding: 6px 8px 0; }
.chat-tabs { display: flex; gap: 4px; flex: 1; min-width: 0; overflow-x: auto;
  scroll-behavior: smooth; scrollbar-width: none; -ms-overflow-style: none; }
/* No visible scrollbar — it would sit across the tabs' bottom border, which is the seam that
   joins the active tab to the message list. The arrows are the affordance instead. */
.chat-tabs::-webkit-scrollbar { display: none; }
/* ◂ / ▸ rather than any emoji: same vocabulary as `.chat-toggle`'s ▾/▸ one row up, so the panel
   keeps one arrow language. Hidden entirely (not just disabled) when everything fits. */
.chat-tabs-nav { flex: 0 0 auto; width: 18px; padding: 0; cursor: pointer; position: relative;
  border: 2px solid var(--edge); border-bottom: none; background: transparent;
  color: var(--muted); font-size: 10px; line-height: 1; }
.chat-tabs-nav:hover:not(:disabled) { color: var(--text); }
.chat-tabs-nav:disabled { opacity: 0.3; cursor: default; }
/* An unread conversation scrolled off THIS side. Without it a carousel reintroduces exactly the
   bug the whisper-tab fix removed: a pink count in the header pointing at a tab you cannot see. */
.chat-tabs-nav.has-unread::after { content: ""; position: absolute; top: 2px; right: 2px;
  width: 5px; height: 5px; background: #ce4069; }
.chat-tab { position: relative; flex: 0 0 auto; border: 2px solid var(--edge); background: transparent; color: var(--muted);
  border-bottom: none; padding: 4px 10px; font-family: var(--pixel); font-size: 8px; cursor: pointer;
  text-transform: capitalize; white-space: nowrap; }
.chat-tab.on { background: rgba(28, 38, 55, 0.8); color: var(--text); }
/* combat + system: the glyph IS the label (see TAB_LABELS in app.js). Symmetric padding
   because there is no word to sit left of, `none` because capitalize does nothing to a
   symbol, and a size bump because these fall through Press Start 2P to a system face and
   8px reads as a smudge next to the 8px lettering beside them. */
.chat-tab.icon { padding: 4px 7px; font-size: 11px; text-transform: none; line-height: 1; }
.chat-tab-close { margin-left: 6px; color: var(--muted); }
.chat-tab-close:hover { color: #ff8a8a; }
.chat-unread { background: #ce4069; color: #fff; font-size: 9px; font-weight: 800;
  padding: 0 4px; margin-left: 5px; }
.chat-msgs { height: 200px; overflow-y: auto; padding: 8px 10px; background: rgba(12, 18, 27, 0.6);
  display: flex; flex-direction: column; gap: 2px; }
.chat-row { line-height: 1.35; overflow-wrap: anywhere; }
.chat-row b { color: #8fa8dd; font-weight: 700; }
/* a right-clickable / tappable sender name — inspect or whisper them */
.chat-row b.chat-name { cursor: pointer; }
.chat-row b.chat-name:hover { text-decoration: underline; }
.chat-row.whisper { color: #c79ae8; }
.chat-row.whisper b { color: #b06fe0; }
.chat-sys { color: var(--muted); font-style: italic; font-size: 12px; }
.chat-sys.log-combat { color: #e8a87c; font-style: normal; }
.chat-sys.log-system { color: #7cc7e8; font-style: normal; }
.chat-ts { color: var(--muted); font-size: 10px; margin-right: 5px; font-style: normal; }
/* WhatsApp-style day band inside the log — drawn by chatDayLabel() the moment the local
   calendar day changes. An ordinary flex CHILD of .chat-msgs (which is `flex-direction:
   column; gap: 2px`), so there is no margin collapsing to work around.
   ⚠️ Kept to ~18px total on purpose: body.touchctl shrinks .chat-msgs to 96px below, so
   anything taller costs a phone reader a whole message. */
.chat-day { display: flex; align-items: center; gap: 6px; margin: 5px 0 1px; }
.chat-day:first-child { margin-top: 0; }
.chat-day::before, .chat-day::after { content: ""; flex: 1 1 0; min-width: 0; height: 2px; background: var(--border); }
/* The panel's own lettering — same 8px Press Start 2P as .chat-tab. The latin + latin-ext
   @font-face subsets at the top of this file cover U+0000-00FF, so Terça, Sábado and
   Miércoles render in the pixel face rather than falling through to Courier.
   `flex: 1 1 0; min-width: 0` on the rules above is what lets them collapse to nothing on a
   narrow phone panel before this label is ever squeezed. */
.chat-day span { flex: 0 0 auto; font-family: var(--pixel); font-size: 8px; line-height: 1;
  color: var(--muted); padding: 3px 6px; border: 2px solid var(--border);
  background: var(--edge); white-space: nowrap; }
.chat-input { display: flex; gap: 6px; padding: 8px; border-top: 2px solid var(--border); }
.chat-input input { flex: 1; background: var(--panel-2); border: 2px solid var(--border);
  color: var(--text); padding: 6px 9px; font-size: 13px; border-radius: 0; }
.chat-input .btn { padding: 6px 12px; }
/* ⚠️ `.chat-tabbar`, not `.chat-tabs` — the strip gained a wrapper with the scroll arrows in it,
   and hiding only the inner element would leave two floating arrow buttons on a collapsed panel
   (which on a phone is a 44px icon). */
.chat-panel.collapsed .chat-tabbar, .chat-panel.collapsed .chat-msgs, .chat-panel.collapsed .chat-input,
.chat-panel.collapsed .chat-cfg { display: none; }
.chat-panel.collapsed { width: auto; }

/* Small screens: a COLLAPSED chat shrinks to a single 44px icon button, matching
   the .hud-menu-btn rail it sits beside. Collapsed still meant a full-width bar
   ("💬 Chat · status · ▸") pinned across the bottom-left, which on a phone ate a
   whole row of screen to say nothing. The unread badge is the one thing that
   survives — it is the entire reason to glance at a collapsed chat. */
@media (max-width: 720px) {
  /* 44px border-box square — the panel's own 3px borders count toward it, so the
     head fills the remainder rather than setting its own height (which made the
     icon a 44x50 rectangle next to the square .hud-menu-btn rail) */
  .chat-panel.collapsed { width: 44px; min-width: 44px; height: 44px; }
  .chat-panel.collapsed .chat-head { padding: 0; height: 100%; justify-content: center; gap: 0; }
  .chat-panel.collapsed .chat-label,
  .chat-panel.collapsed .chat-status,
  .chat-panel.collapsed .chat-toggle { display: none; }
  .chat-panel.collapsed .chat-title { gap: 0; }
  .chat-panel.collapsed .chat-ico { font-size: 18px; line-height: 1; }
  /* badge rides the corner of the icon instead of sitting in the header flow */
  .chat-panel.collapsed .chat-badge { position: absolute; top: -6px; right: -6px;
    z-index: 1; padding: 1px 5px; border: 2px solid var(--edge); }
  .chat-panel.collapsed { overflow: visible; }   /* let the badge escape the box */
}

/* ---- friends rail: the compact roster above the chat panel ---------------------
   Same chrome as .chat-panel so the two read as one dock, and the same collapse
   idiom (CollapsePanel toggles `.collapsed`; CSS does the hiding, content stays
   server-rendered). Kept deliberately small: the list scrolls at ~5 rows rather
   than growing up the screen. */
.friends-rail { position: relative; width: 340px; max-width: 100%;
  display: flex; flex-direction: column; overflow: hidden;
  background: rgba(18, 26, 38, 0.94); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  font-size: 13px; }
.fr-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
  border-bottom: 2px solid var(--border); user-select: none; }
/* ⚠️ The collapse toggle is THIS, not .fr-head — see the markup comment in game_live. The
   `+` button is a sibling, so clicking it cannot fold the rail. */
.fr-toggle { flex: 1; display: flex; align-items: center; gap: 8px; cursor: pointer; min-width: 0; }
.fr-add { border: 2px solid var(--edge); background: var(--panel-2); color: var(--text);
  cursor: pointer; width: 22px; height: 22px; line-height: 1; font-size: 14px; padding: 0; }
.fr-add:hover { color: #6ee06e; }
.fr-add-form { display: flex; gap: 6px; padding: 6px; border-bottom: 2px solid var(--border); }
.fr-add-form input { flex: 1; min-width: 0; background: var(--panel-2); border: 2px solid var(--border);
  color: var(--text); padding: 4px 8px; font-size: 12px; }
.friends-rail.collapsed .fr-head { border-bottom: none; }
.fr-title { font-family: var(--pixel); font-size: 9px; display: inline-flex; align-items: center; gap: 6px; }
.fr-count { color: var(--muted); font-size: 11px; flex: 1; }
.fr-badge { background: #ce4069; color: #fff; font-size: 10px; font-weight: 800; padding: 1px 6px; }
.fr-caret { width: 16px; text-align: center; color: var(--muted); }
.fr-body { display: flex; flex-direction: column; }
.friends-rail.collapsed .fr-body { display: none; }
/* The `+` opens a form that lives INSIDE .fr-body, so a visible-but-inert button while
   collapsed would be a dead control. Hidden at every width, not just on phones. */
.friends-rail.collapsed .fr-add { display: none; }
.friends-rail.collapsed { width: auto; }

/* ~4 rows, then scroll. The rail sits ON TOP of the chat panel's own 200px message
   list, so letting it grow with the roster would push chat off a short screen. */
.fr-list { list-style: none; margin: 0; padding: 3px 6px; max-height: 112px; overflow-y: auto; }
.fr-row, .fr-req { display: flex; align-items: center; gap: 6px; padding: 1px 4px; font-size: 12px; }
/* The whole row opens the context menu (click or right-click), so it must read as hittable. */
.fr-row { cursor: pointer; }
.fr-row:hover { background: rgba(255, 255, 255, 0.06); }
/* ⚠️ `min-width: 0` is load-bearing, not tidying. A flex item defaults to `min-width: auto`,
   which refuses to shrink below its content — so a long username pushed the row's trailing
   text ("waiting for an answer") straight out past the panel edge instead of ellipsising. */
.fr-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Friendship-tier heart (Good → Forever). Color is the tier; the tooltip carries the words,
   so the heart itself is aria-hidden. */
.fr-heart { flex: 0 0 auto; font-size: 10px; line-height: 1; }
.fh-good { color: #8bc34a; }
.fh-great { color: #4aa3e0; }
.fh-ultra { color: #b06ee0; }
.fh-best { color: #e0b84a; }
.fh-forever { color: #e05a8a; }
.fr-reqs { padding: 4px 6px; border-bottom: 2px solid var(--border); background: rgba(206, 64, 105, 0.12); }
.fr-empty { color: var(--muted); font-size: 12px; margin: 0; padding: 8px 10px; }
.fr-btn { border: 2px solid var(--edge); background: var(--panel-2); color: var(--text);
  cursor: pointer; padding: 0 6px; font-size: 12px; line-height: 20px; }
.fr-btn.ok { color: #6ee06e; }
.fr-btn.no { color: #ff8b8b; }
/* Outgoing requests — muted, below the roster, only when there are any. */
.fr-sent { border-top: 2px solid var(--border); padding: 4px 6px; }
.fr-await { color: var(--muted); font-size: 11px; white-space: nowrap; flex: 0 0 auto; }

/* Collapsed, the badge is the entire reason to glance at the rail — same treatment
   the chat panel gives its unread count. */
@media (max-width: 720px) {
  .friends-rail.collapsed { width: 44px; min-width: 44px; height: 44px; overflow: visible; }
  .friends-rail.collapsed .fr-head { padding: 0; height: 100%; justify-content: center; gap: 0; }
  .friends-rail.collapsed .fr-label,
  .friends-rail.collapsed .fr-count,
  .friends-rail.collapsed .fr-add,
  .friends-rail.collapsed .fr-caret { display: none; }
  /* ⚠️ `.fr-head` centres (above) and it is NOT enough: `.fr-toggle` is `flex: 1`, so it
     stretches across the whole 38px head and then left-aligns its own row — with the label,
     count and caret all `display: none` here, the lone 👥 ends up 8px left of the puck's
     centre. Measured, not guessed. The stretch itself stays: it is what makes the entire
     44px touch target fold the rail. `.chat-head` and `.quest-hud-head` need nothing like
     this because they ARE the toggle — there is no `flex: 1` layer between them and their
     icon, which is why this rail did not match them despite the markup comment saying it did. */
  .friends-rail.collapsed .fr-toggle { justify-content: center; flex: 1; }
  .friends-rail.collapsed .fr-title { gap: 0; }
  .friends-rail.collapsed .fr-ico { font-size: 18px; line-height: 1; }
  .friends-rail.collapsed .fr-badge { position: absolute; top: -6px; right: -6px;
    z-index: 1; padding: 1px 5px; border: 2px solid var(--edge); }
}

/* layout-transparent: it only carries CollapsePanel's `.collapsed` class, so the
   rail below keeps its own fixed positioning (same idiom as .team-slot) */
.hud-corner-wrap { display: contents; }
/* ⚠️ It WRAPS, and the max-width is what makes it. The rail is 11 buttons at its narrowest
   and 15 with the calendar, party, bulletin and GM entries showing — 586px to 802px of 46px
   icons — while the hotbars are centred on the same bottom band. Their edges crossed on any
   window under ~1426px at eleven buttons, and under ~1750px at fourteen: on effectively every
   desktop, the menu icons sat on top of the consumable bar.

   The reserve is the hotbars' own geometry, spelled out rather than rounded to a magic
   number: half a full 4-slot bar (4 × 52 + 3 × 6 = 226), this rail's own right offset, and a
   gap so they never quite touch. Above that width nothing changes — the rail stays one row.

   `wrap-reverse`, not `wrap`, for the reason the touch rule below already gives: the rail is
   anchored bottom-right, so extra rows must grow UP into free space; plain wrap grows down
   and off the screen. `flex-end` keeps every row against the right edge instead of leaving
   the short one adrift in the middle of the max-width box.

   ⚠️ `50vw` is multiplied by --ui-inv and the px terms are not: the rail lives inside the
   zoomed chrome, where a viewport unit still means the REAL viewport and is then scaled again
   (see the --ui-inv note at the top of this file), while the px lengths are already in the
   zoomed space the hotbars are measured in. */
.hud-corner { position: fixed; right: calc(14px + var(--hud-safe-r) * var(--ui-inv));
  bottom: calc(12px + var(--hud-safe-b) * var(--ui-inv)); display: flex; gap: 8px;
  flex-wrap: wrap-reverse; justify-content: flex-end;
  max-width: calc(50vw * var(--ui-inv) - 113px - 14px - 12px); }
/* the rail WRAPS to fit on a desktop (above), so the ☰ stays a small-screen affordance
   only — folding is for a phone, where even two rows would eat the screen. Keeping
   the .collapsed rule inside the media query too is load-bearing: the open/closed
   flag is remembered per device in localStorage, so a rule that applied at every
   width would leave someone who collapsed on a phone with an invisible menu — and
   no button to bring it back — the next time they opened the game on a desktop. */
/* Hides the ☰ on desktop. The icon-button contract further down sets `display: grid` at ZERO
   specificity (`:where`), so this wins regardless of order — the .hud-corner scoping is
   belt-and-braces from when that rule was a plain `.hud-menu-btn` and won on source order. */
.hud-corner .hud-corner-toggle { display: none; }
/* Small screens: 11 icon buttons that never wrapped (~590px wide) on a 390px
   screen. The ☰ appears and folds the rest away; collapsed, the rail IS the one
   button. This block must sit AFTER the `display: none` above — both selectors
   score the same, so source order is the only thing separating them. */
@media (max-width: 720px) {
  .hud-corner .hud-corner-toggle { display: grid; }   /* grid: matches .hud-menu-btn's own centering */
  .hud-corner-wrap.collapsed .hud-corner > :not(.hud-corner-toggle) { display: none; }
  /* nothing is left to wrap, so the rail must not still reserve the wrapped width */
  .hud-corner-wrap.collapsed .hud-corner { max-width: none; }
}
.hud-menu-btn { position: relative; width: var(--hud-btn); height: var(--hud-btn); cursor: pointer;
  font-size: 20px; color: var(--text); background: rgba(24, 34, 49, 0.9); border: 2px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border);
  }
.hud-menu-btn:hover { border-color: var(--muted); }
.hud-menu-btn.gm { border-color: var(--accent); box-shadow: inset 0 0 0 2px var(--primary-deep); }
.hud-menu-btn.gm:hover { border-color: var(--primary-2); }
.hud-menu-btn.danger { border-color: #ce4069; box-shadow: inset 0 0 0 2px #6e2436; }
.hud-menu-btn.danger:hover { border-color: #ff7a7a; }
/* Exit Instance door — top-right, above the team HUD strip. A flex row since 💤 Sleep
   joined it while farming; the door stays rightmost so its position never moves.

   ⚠️ "Above" is z-order (14 vs .team-hud's 12), and that is the whole hazard: this cluster
   is pinned to the viewport edge while `.team-hud` spans `left: 8px; right: 8px`, so on any
   screen narrow enough for the strip to reach that edge the door paints over the last cards
   AND swallows their clicks. The strip reserves the gutter — see `--team-hud-reserve` — and
   it can only do so if this rule's geometry stays expressed in `--hud-btn` / `--hud-exit-gap`.
   Hard-code a size back in here and the reservation silently stops matching. */
.hud-exit { position: fixed; top: 10px; right: 14px; z-index: 14; display: flex;
  gap: var(--hud-exit-gap); }
.hud-menu-btn .hk { position: absolute; bottom: -3px; right: 2px; font-family: var(--pixel); font-size: 7px;
  color: #fff; background: var(--edge); padding: 2px 4px; }
/* unread-mail red dot (📬 button) */
.hud-menu-btn .notif-dot { position: absolute; top: -4px; right: -4px; width: 11px; height: 11px;
  border-radius: 50%; background: #ef4444; border: 2px solid var(--edge); pointer-events: none; }

/* The in-game clock on the 📅 rail button. Written by the GameClock hook — one in-game minute is
   3.75 real seconds, so it cannot be server-rendered.
   ⚠️ position: absolute, for the same reason .notif-dot above is: .hud-menu-btn is
   `display: grid; place-items: center` (see the rule near .menu-ico), so a second child in the
   flow would make it a two-row grid and push the icon off centre. Out of flow, the icon's
   centring is untouched and the strip is a caption band on the bottom edge.
   ⚠️ 7px is the smallest size this UI already uses (.pk-tip .tip-key), and Press Start 2P is
   designed to be legible there. The 46px button has ~38px of inner width and "14:32" is 5
   characters at a ~1em advance, so it only just fits — which is why the touch rule below drops a
   point rather than letting it clip.
   ⚠️ --pixel is monospace end to end ("Press Start 2P", "Courier New", monospace), so the digits
   are fixed-width by construction; tabular-nums is belt-and-braces for the fallback stack. Without
   it the strip visibly wobbles every time the minute changes. */
.hud-clock {
  position: absolute; left: 0; right: 0; bottom: 0; pointer-events: none;
  font-family: var(--pixel); font-size: 7px; line-height: 10px; text-align: center;
  font-variant-numeric: tabular-nums; letter-spacing: 0;
  color: var(--text); background: rgba(11, 18, 32, 0.82);
}
.hud-clock:empty { display: none; }        /* before the hook's first paint, show nothing */
/* Lift the icon clear of the caption band. Only on the button that has one, so every other
   rail icon stays exactly where it was.
   ⚠️ Targets `.menu-ico`, not the retired `.menu-ico-glyph`: the calendar button was an emoji
   span until 2026-08-29 and is an `<img>` now. Left on the old class this rule silently stops
   matching, and the only symptom is the calendar icon sitting on top of the clock — on one
   button, and only while the `events.calendar` flag is on. */
.hud-menu-btn.has-clock .menu-ico { transform: translateY(-4px); }
body.touchctl .hud-clock { font-size: 6px; line-height: 9px; }
body.touchctl .hud-menu-btn.has-clock .menu-ico { transform: translateY(-3px); }
/* Same dot on a tab (ESC menu → Account, unconfirmed email). Tucked INSIDE the tab
   rather than hanging off its corner: tabs sit flush in a row, so a negative offset
   would overlap the neighbour. No border — there is no button edge to sit against. */
.tab .notif-dot { position: absolute; top: 6px; right: 4px; width: 7px; height: 7px;
  border-radius: 50%; background: #ef4444; pointer-events: none; }

/* Same dot on a POKÉMON — it is waiting on a decision (a move to learn, or an evolution it
   can do now). Three hosts, three offsets, because each card's corners are already spoken for.

   ⚠️ There is no bare `.notif-dot` rule, on purpose: the class only paints where a host has
   opted in and tuned it. A new surface that just adds class="notif-dot" renders NOTHING.
   Keep the 2px edge border where the dot sits against a card edge, drop it where it doesn't —
   the same call `.tab` above makes.

   All three are `pointer-events: none` so the dot can never become the click target inside a
   button that is itself the control (swap / select), and none of them are focusable. */

/* Team HUD: top-RIGHT, because `.team-key` owns top-left. z-index clears `.team-cdov`
   (z 1), the switch-cooldown scrim — which is on screen exactly when a farm level-up has
   just made a move pending, i.e. the moment this dot matters most. */
.team-card .notif-dot { position: absolute; top: -4px; right: -4px; width: 10px; height: 10px;
  border-radius: 50%; background: #ef4444; border: 2px solid var(--edge); z-index: 2;
  pointer-events: none; }

/* Depot party rail: the row is a flex line with no free corner, so the dot sits against the
   left edge, clear of the 44px sprite. `.dparty-slot` gains `position: relative` below for
   exactly this — the same one-line move `.tab` documents above. */
.dparty-slot .notif-dot { position: absolute; top: 4px; left: 4px; width: 8px; height: 8px;
  border-radius: 50%; background: #ef4444; pointer-events: none; }

/* Depot box cell: top-LEFT. `.dcell-shiny` (✨) owns top-right and `.dcell-meta` owns the
   bottom strip. Inset rather than hanging off the corner because a cell bottoms out at 44px
   and `container-type: inline-size` makes it a containing block — a negative offset would
   crowd the neighbouring cell in a tight grid. */
.dcell .notif-dot { position: absolute; top: 2px; left: 2px; width: 8px; height: 8px;
  border-radius: 50%; background: #ef4444; pointer-events: none; z-index: 1; }

/* Profile card: inline beside the name, not a corner overlay — the header already has the
   evolution block pinned top-right. */
.modal-head .notif-dot { display: inline-block; vertical-align: middle; margin-left: 8px;
  width: 9px; height: 9px; border-radius: 50%; background: #ef4444; pointer-events: none; }

/* ---- Tutorial pointer ----------------------------------------------------
   Bobbing arrow + gold pulse on whatever the current guided-tutorial objective
   needs the player to click. Server-rendered class (GameLive's @hud_hints keyed
   on @tutorial_step), so it patches like any other assign — no JS. This exists
   because the footprint trail is world-space Phaser inside the canvas and can
   never point at the DOM HUD.
   Geometry notes: .hud-corner is fixed bottom-right, so the arrow can only come
   from ABOVE; .hud-menu-btn is position:relative with no overflow clip and an
   unused ::after; it must be position:absolute, not fixed — the button is its
   containing block. Centred above, so it clears both
   .hk (bottom-right) and .notif-dot (top-right). */
.hud-menu-btn.tutorial-point { border-color: var(--accent);
  animation: tutorial-pulse 1.1s ease-in-out infinite; }
.hud-menu-btn.tutorial-point::after { content: ""; position: absolute;
  left: 50%; bottom: calc(100% + 8px); margin-left: -9px; width: 0; height: 0;
  border: 9px solid transparent; border-top-color: var(--accent);
  filter: drop-shadow(0 2px 0 var(--edge));
  animation: tutorial-bob 0.9s ease-in-out infinite; pointer-events: none; }
@keyframes tutorial-pulse {
  0%, 100% { box-shadow: inset 0 0 0 2px var(--border), 0 0 8px #ffc93c99; }
  50% { box-shadow: inset 0 0 0 2px var(--border), 0 0 16px #ffc93cee; }
}
@keyframes tutorial-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-7px); }
}
/* The drop target for a live `slot_filled` objective: pulse the empty item-bar slots while
   the bag is open. Both halves are gated on GameLive's `quest_drag_hint?/1` — NOT on the
   objective's `hud_hint`, which several "use a potion" quests also set to "bag" and which
   therefore taught the drag to players who had already done it. */
.hotbar.tutorial-point .hotslot.empty { border-color: var(--accent);
  animation: tutorial-pulse 1.1s ease-in-out infinite; }

/* The drag SOURCE for the same objective: while GameLive renders .tutorial-drag on the bag
   shell, every draggable item glows (data-category is only set on the four consumable
   categories, and the slot_filled objective accepts any of them) and the Potion — the
   item Oak's script names — pulses harder so it reads as THE one to grab. */
.bag-shell.tutorial-drag .islot[data-category] { border-color: var(--accent);
  animation: tutorial-pulse 1.1s ease-in-out infinite; }
.bag-shell.tutorial-drag .islot[data-category="medicine_hp"] {
  outline: 2px solid var(--accent); outline-offset: 1px;
  animation: tutorial-pulse 0.7s ease-in-out infinite; }

/* The connector between them: a dashed gold curve drawn by ConsumableDnd's
   syncTutorialLink() into a body-level SVG (screen px, zoom 1 — same layer rules as
   #global-tip). z-index sits above the bag shell (60) and the drag-raised hotbars (200)
   but below #global-tip (500). The dash marches toward the slot. */
#tutorial-link { position: fixed; inset: 0; width: 100vw; height: 100vh;
  pointer-events: none; z-index: 450; }
#tutorial-link path { stroke: var(--accent); stroke-width: 3; fill: none;
  stroke-dasharray: 8 6; stroke-linecap: round;
  filter: drop-shadow(0 1px 0 var(--edge));
  animation: tutorial-dash 0.9s linear infinite; }
#tutorial-link marker path { stroke: none; fill: var(--accent); animation: none; }
@keyframes tutorial-dash { to { stroke-dashoffset: -14; } }

/* ---- top team bar: six Pokémon cards (wide/landscape) ---- */
/* ⚠️ The right end of this strip is SHARED with `.hud-exit` — the 🚪 door, plus 💤 Sleep while
   farming — which is fixed to the viewport edge at z 14 and therefore paints over whatever
   card is under it and eats that card's clicks. `--team-hud-reserve` is the horizontal twin of
   `--hud-rail-top`: the width this strip gives back so the cluster has somewhere to be.

   It is keyed off two SERVER-RENDERED classes, `has-exit` / `has-sleep`, emitted by GameLive
   from the same two expressions `.hud-exit` and its 💤 button are gated on. That is the point —
   a reservation derived from a second predicate would eventually disagree with the thing it is
   reserving for, and the failure looks exactly like this bug did.

   The 12px: this strip sits 8px from the edge and `.hud-exit` sits 14px, so the cluster
   intrudes 6px further than its own width — plus 6px of breathing room.

   ⚠️ Invisible on a wide screen. The cards cap at max-width 200px and centre, so under ~1350px
   (~1458px with 💤) the centred group starts reaching under the door and not before. It read as
   a phone bug and was a 1366x768 laptop bug too. */
.team-hud { position: fixed;
  top: calc(8px + var(--hud-safe-t) * var(--ui-inv));
  left: calc(8px + var(--hud-safe-l) * var(--ui-inv));
  right: calc(8px + var(--hud-safe-r) * var(--ui-inv)); z-index: 12;
  display: flex; gap: 6px; justify-content: center; flex-wrap: nowrap;
  padding-right: var(--team-hud-reserve, 0px); }
.team-hud.has-exit { --team-hud-reserve: calc(var(--hud-btn) + 12px); }
/* compound on purpose: two 0,2,0 rules would be separated only by source order */
.team-hud.has-exit.has-sleep {
  --team-hud-reserve: calc(2 * var(--hud-btn) + var(--hud-exit-gap) + 12px); }
/* `.mon-badges` — the active mon's status / stage / screen pills, once docked under this
   strip — is gone. Those effects render on each Pokémon's own nameplate now (app.js
   `renderEffectIcons`), so there is nothing left for the band under --hud-rail-top to hold
   and the side rails have it to themselves. */
.team-slot { display: contents; }
.team-card {
  position: relative; flex: 1 1 0; min-width: 0; max-width: 200px; padding: 6px 9px 6px 8px;
  display: flex; flex-direction: row; align-items: center; gap: 8px;
  background: rgba(19, 26, 36, 0.86);
  border: 2px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border); color: var(--text);
  cursor: pointer; text-align: left; font: inherit;
  transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease, background .12s ease;
}
button.team-card:hover { border-color: var(--muted); transform: translateY(-1px); }
.team-card.active { border-color: var(--primary); background: rgba(24, 40, 33, 0.9);
  box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); }
.team-card.active::after { content: ""; position: absolute; inset: -3px;
  box-shadow: 0 0 15px 2px color-mix(in srgb, var(--primary) 55%, transparent); pointer-events: none; }

/* fainted (0 HP) mons fade out until revived/healed */
.team-card.fainted { opacity: 0.45; }
/* Gold pulse when this Pokémon gains a level (JS adds .levelup for ~1s).
   The scale step is doing real work: the mon that levels while farming is the ACTIVE one,
   whose card already wears .team-card.active's permanent gold ring and glow — so a pure
   box-shadow change lands on top of a card that is already gold, in the corner of the
   screen, while the player is watching the arena. Movement is what the eye catches. */
.team-card.levelup { animation: team-levelup .9s ease-out 1; }
@keyframes team-levelup {
  0%   { box-shadow: inset 0 0 0 2px var(--border); transform: scale(1); }
  25%  { box-shadow: inset 0 0 0 2px #ffd447, 0 0 18px 4px rgba(255, 212, 71, 0.75); transform: scale(1.09); }
  60%  { transform: scale(0.98); }
  100% { box-shadow: inset 0 0 0 2px var(--border); transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  @keyframes team-levelup {
    0%   { box-shadow: inset 0 0 0 2px var(--border); }
    25%  { box-shadow: inset 0 0 0 2px #ffd447, 0 0 18px 4px rgba(255, 212, 71, 0.75); }
    100% { box-shadow: inset 0 0 0 2px var(--border); }
  }
}
.team-card.fainted .team-art img { filter: grayscale(1) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.5)); }

/* switch-cooldown overlay: darkens the card and drains top-down while switching is locked */
.team-cdov { position: absolute; inset: 0; z-index: 1; pointer-events: none; overflow: hidden; }
.team-cdov-fill { position: absolute; left: 0; right: 0; top: 0; background: rgba(0, 0, 0, 0.55);
  transition: height .2s linear; }
.team-cdov-num { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-family: var(--pixel); font-size: 10px; color: #fff; text-shadow: 0 1px 2px #000; }

.team-key { position: absolute; top: -7px; left: -7px; min-width: 18px; height: 18px; padding: 0 4px;
  display: flex; align-items: center; justify-content: center; line-height: 1;
  font-family: var(--pixel); font-size: 8px; color: #fff;
  background: var(--edge); border: 1px solid var(--border); z-index: 1; }
.team-card.active .team-key { background: var(--primary); color: #06210f; border-color: var(--primary); }
.team-card.empty .team-key { opacity: 0.55; }

.team-art { width: 42px; height: 42px; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; }
.team-art img { width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated;
  filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.5)); }

.team-info { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.team-top { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; }
.team-name { flex: 1 1 auto; min-width: 0; font-size: 12px; font-weight: 700; line-height: 1.1;
  text-transform: capitalize; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.team-card.active .team-name { color: var(--text); }
.team-lvl { flex: 0 0 auto; padding: 1px 5px; line-height: 1.4; font-family: var(--pixel); font-size: 7px;
  color: #ffd447; background: var(--edge); border: 1px solid var(--border); }

.team-hpline { display: flex; align-items: center; gap: 6px; }
.team-hp-num { flex: 0 0 auto; font-size: 10px; font-weight: 800; font-variant-numeric: tabular-nums;
  line-height: 1; color: var(--muted); }
.team-card.active .team-hp-num { color: var(--text); }

.team-bar { position: relative; height: 6px; background: rgba(255, 255, 255, 0.1); overflow: hidden; }
.team-bar.hp { flex: 1 1 auto; }
.team-bar .fill { position: absolute; left: 0; top: 0; bottom: 0; transition: width .25s ease; }
.team-bar.hp .fill.ok { background: linear-gradient(90deg, #2ea043, #35c46a); }
.team-bar.hp .fill.mid { background: linear-gradient(90deg, #d9a520, #f5c542); }
.team-bar.hp .fill.low { background: linear-gradient(90deg, #d97a2b, #ef9a3c); }
.team-bar.hp .fill.dead { background: #e5533c; }
/* The Well Fed absorb shield, on the SAME bar as the HP.
   WoW's default reading: the shield fills the MISSING-HP gap to the right of the fill, and what
   does not fit is a fixed bright cap at the end. The bar NEVER changes width — one that grew
   would make "100/100 plus a shield" look like more than full health, which is the one thing a
   health bar must never say.
   ⚠️ Shared with the party rail by design (see the .party-hud block): one health language, and
   `.team-bar` is already `position: relative; overflow: hidden` so both children are clipped.
   ⚠️ `left` is set inline from the HP percent and transitions WITH it — without that the shield
   lags the drain by a quarter second and a gap opens underneath it. */
.team-bar .absorb { position: absolute; top: 0; bottom: 0; left: 0; width: 0;
  background: linear-gradient(90deg, rgba(166, 252, 219, 0.85), rgba(166, 252, 219, 0.55));
  transition: width .25s ease, left .25s ease; pointer-events: none; }
/* PRESENCE, not magnitude — a fixed cap saying "there is more shield than fits". At full HP the
   whole pool is overflow and this is the only tell, which is the common case because the pool
   refills out of combat.
   ⚠️ `right: 0`, so it is pinned to the bar's end and `overflow: hidden` cannot eat it. */
.team-bar .absorb-spark { position: absolute; top: 0; bottom: 0; right: 0; width: 4px;
  background: #e9fff8; box-shadow: 0 0 5px rgba(166, 252, 219, 0.9); pointer-events: none; }
.team-bar .absorb-spark[hidden] { display: none; }
.team-bar.cd { height: 4px; }
.team-bar.cd .fill { background: linear-gradient(90deg, #4a91ff, #8ad0ff); box-shadow: 0 0 6px rgba(90, 169, 255, 0.6); }
.team-bar.xp { height: 4px; }
.team-bar.xp .fill { background: linear-gradient(90deg, var(--xp-a), var(--xp-b)); box-shadow: 0 0 6px var(--xp-glow); }

/* empty-slot skeleton */
.team-card.empty { cursor: default; border-style: dashed; box-shadow: none; background: rgba(19, 26, 36, 0.5); }
.team-card.empty:hover { transform: none; border-color: var(--edge); }
.sk { position: relative; overflow: hidden; background: rgba(255, 255, 255, 0.06); }
.team-art.sk { }
.team-name.sk { height: 10px; width: 72%; }
.team-bar.sk { flex: 1 1 auto; background: rgba(255, 255, 255, 0.06); }
/* Static on purpose: a shimmer says "loading", and an empty slot is not loading anything — it
   was also 4 infinite compositor animations per empty card, forever (see HUD PERFORMANCE). */

@media (max-width: 720px) {
  .team-hud { top: 6px; gap: 4px; }
  .team-card { padding: 5px 6px; gap: 5px; }
  .team-art { width: 34px; height: 34px; }
  .team-name { font-size: 11px; }

  /* The exit cluster STACKS here, so the strip only ever gives back one button's width.
     Side by side it would be ~96px, and on a 390px phone the strip is already edge to edge
     (active card ~140px + five non-shrinkable 44px avatars) — the reservation comes out of
     the active card, which is the one card whose name, level and bars have to stay readable.
     Stacked it keeps ~90px instead of ~44px.

     ⚠️ column-REVERSE. The door is the last child, and the rule this cluster is written under
     is that its position never moves when 💤 comes and goes. Reversed, the door is the TOP
     item at the same 10px it has on desktop; plain `column` would push it down by a button
     every time the player starts farming — the same invariant, broken on the other axis. */
  .hud-exit { flex-direction: column-reverse; align-items: flex-end; }
  .team-hud.has-exit.has-sleep { --team-hud-reserve: calc(var(--hud-btn) + 12px); }

  /* ⚠️ Load-bearing, and not merely cosmetic. A stacked cluster is 10 + btn + gap + btn tall:
     94px at 38px, which clears --hud-rail-top (96px), and 110px at 46px, which would put 💤
     over the right rail's first panel — the reported bug traded for the same bug one layer
     down. Width-driven rather than touch-gated (the 820px block above): a window this narrow
     is narrow whatever is pointing at it, and .hud-corner is already a single toggle here. */
  body { --hud-btn: 38px; }

  /* Only the ACTIVE Pokémon keeps its name, level and bars; the other five (and
     any empty slot) collapse to just the avatar. The strip is flex-wrap: nowrap
     across six cards, so on a phone they squeeze until every name is an ellipsis
     and every bar is a few pixels wide — six unreadable cards. The active mon is
     the only one whose HP you act on in the moment, so it is the one that keeps
     the detail, and it takes the width the others give back. */
  .team-card:not(.active) .team-info { display: none; }
  .team-card:not(.active) {
    flex: 0 0 auto; max-width: none; gap: 0;
    padding: 5px; min-width: 44px; justify-content: center;
  }
  .team-card.active { flex: 1 1 auto; max-width: 240px; }
}
@media (max-width: 480px) {
  .team-card { padding: 4px 5px; gap: 4px; }
  .team-art { width: 30px; height: 30px; }
  .team-lvl, .team-hp-num { display: none; }
  /* keep the collapsed cards a 44px tap target even as the art shrinks */
  .team-card:not(.active) { padding: 5px; min-width: 44px; }
}
@media (prefers-reduced-motion: reduce) {
  .sk::after { animation: none; }
  .mon:hover, .shop-card:hover { transform: none; }
  .btn:active, .nav-links a.cta:active { transform: none; }
}

.game-modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); z-index: 100; display: flex; align-items: flex-start; justify-content: center; padding: 32px 16px; overflow-y: auto; }
.game-modal { position: relative; width: 100%; max-width: 860px; background: var(--bg); border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 22px 24px 28px; max-height: calc(92vh * var(--ui-inv)); overflow-y: auto; }
.game-modal .modal-close { position: absolute; top: 12px; right: 14px; z-index: 2; }
/* A FRAMED panel brings its own ring — `<.league_frame>` (CoreComponents), the Gyms board — so
   the modal box draws nothing: no border, no inset keyline, no shadow, no padding. The frame IS
   the chrome. Without this the board was double framed: this box's `.panel` pair outside, the
   nine-slice plate inside it, and the plate's hollow inner 32px let the keyline show through.
   ⚠️ Two-class, so it is ALSO spelled out in the ≤820px full-bleed block, where the bare
   `.game-modal` rule pads the top to clear the pinned close button and would win on specificity. */
.game-modal.framed-modal { background: transparent; border: 0; box-shadow: none; padding: 0; }
/* With the padding gone an inner close button would land on the frame's riveted corner plate;
   18/20px puts it on the top strip instead. (The Gyms modal's own close lives in the control
   column outside the box, so this covers a framed panel that renders one inside.) */
.game-modal.framed-modal .modal-close { top: 18px; right: 20px; }

/* bag as a HUD panel (like chat): centered, no backdrop, the game stays live.
   z 60: above the HUD chrome (10-12), below a stacked modal backdrop (100) and
   the hotbars' drag raise (200) so drops still land on the consumable bar. */
/* the bag shell centers the panel + its control column as a group (like a modal),
   so the ✕/? sit OUTSIDE the scrolling panel instead of clipped inside it */
/* ⚠️ The drag offset is COMPOSED onto the centering translate, never replacing it — the
   DragModal hook writes --drag-x/--drag-y rather than `style.transform` for exactly this
   reason. Writing the transform directly would drop the -50% and snap the bag to the middle
   of nowhere the moment it was dragged. */
.bag-shell { position: fixed; left: 50%; top: 50%; z-index: 60;
  transform: translate(-50%, -50%) translate(var(--drag-x, 0px), var(--drag-y, 0px));
  display: flex; align-items: flex-start; }
.hud-panel.bag-panel { position: relative;
  width: min(860px, calc((100vw - 32px) * var(--ui-inv))); max-height: calc(82vh * var(--ui-inv)); overflow-y: auto;
  background: rgba(19, 26, 36, 0.94); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 22px 24px 28px; }
.bag-shell > .modal-controls { align-self: flex-start; margin-left: 10px; }
.embedded-frame { min-width: 0; }

/* keybinds editor */
.kb-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; }
.kb-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--border); }
.kb-label { color: var(--muted); font-size: 13px; }
.kb-key { min-width: 78px; text-align: center; font-family: var(--pixel); font-size: 9px; padding: 7px 10px; }
.kb-key.listening { border-color: var(--primary); color: var(--primary); }
.kb-foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
@media (max-width: 620px) { .kb-list { grid-template-columns: 1fr; } .hud-hotbars { bottom: 10px; } .hotslot { width: 44px; height: 44px; } }

/* ==== farm world ==== the farm renders in the shared #game-canvas (z0); this wrapper
   carries only the farm DOM (the Hunt Analyzer + the bridge). It is layout-transparent
   (display: contents, set by the .hud-rail rules near the top) so the analyzer stacks
   in the right rail; the class stays because these two descendant rules — and the
   .farm-set-backdrop one further down — are scoped to it. */
.farm-overlay { background: none; }
.farm-overlay .embedded-frame { max-width: none; margin: 0; padding: 0; }
.farm-overlay .hunt-analyzer { pointer-events: auto; }

/* Floating session stats (Hunt Analyzer) — a flex item of .hud-rail.right, and the
   only one allowed to shrink: min-height 0 + its own overflow-y means a crowded rail
   scrolls the analyzer instead of pushing it off-screen. These land here rather than
   on `.hud-rail > *` because its DOM parent is the display:contents .farm-view, so
   the child selector misses it. */
.hunt-analyzer { width: 340px; max-width: 100%;
  max-height: 100%; min-height: 0; flex: 0 1 auto; overflow-y: auto; z-index: 11;
  background: rgba(18, 26, 38, 0.94); border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  font-size: 13px; }
.hunt-analyzer.collapsed { max-height: none; overflow: visible; width: auto; }
/* Brief notice while a woken hunt's last idle seconds are banked and the field is deliberately
   held back (FarmLive `start_battle_unless_settling/2`). Usually on screen for under a second.
   ⚠️ FarmLive owns the flag but GAMELIVE renders this, as the last item of `.hud-center` — it
   used to anchor itself at `top: 96px`, which is the same pixel `.buffs-hud` was on. It cannot
   be portalled out of FarmLive into that column (morphdom would delete it), so the boolean
   travels on the `farm_shell:` topic instead; see `start_battle_unless_settling/2`. */
.farm-settling { padding: 8px 14px; border-radius: 8px; background: rgba(12,16,22,.88);
  border: 1px solid #2b3948; color: #cfe3ff; font-size: 13px; pointer-events: none; }
.ha-head { display: flex; align-items: center; gap: 6px; padding: 10px 12px; border-bottom: 2px solid var(--border); }
.hunt-analyzer.collapsed .ha-head { border-bottom: none; }
/* ⚠️ `padding: 0` and the flex centring are BOTH load-bearing, and their absence is not a
   nudge — it is off by a third of the button. Chrome's UA sheet gives a <button>
   `padding: 1px 6px`, and with `* { box-sizing: border-box }` above that leaves this 26px box
   a content area of 26 - 4(border) - 12(padding) = **10x20**, offset 8px from the left edge
   and 3px from the top. A text glyph survived it only because `text-align: center` overflows
   that slot symmetrically — and even then it sat ~2px high, because nothing ever centred it
   vertically. Anything that is not a single centred glyph is simply in the wrong place: the
   Warehouse door's 22px icon measured **6px right and 1px low** of centre and spilled past
   the border. Centre the box once, here, rather than per icon. */
.ha-btn { width: 26px; height: 26px; border: 2px solid var(--edge); padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--panel-2); color: var(--text); cursor: pointer; font-size: 13px; }
.ha-btn.danger { color: #ff8a8a; }
.ha-btn:hover { border-color: var(--muted); }
/* An .ha-btn that carries an icon instead of a glyph — the bag header's Warehouse door.
   ⚠️ 22px is not a taste: `* { box-sizing: border-box }` above makes the 26px button's content
   box exactly 22px, and `ui/warehouse.png` is authored at 22 so every source pixel is one
   screen pixel. Any other pair is a fractional downscale — the first attempt reused the 48px
   NPC role icon here and the crate rendered as an unreadable smudge, because that file carries
   13px of transparent padding for the nameplate float. `image-rendering: pixelated` is the
   belt-and-braces `.ha-wx-ico` already wears. Centring is the BUTTON's job (see its rule
   above) — this used to carry `margin: auto`, which does nothing for a block wider than its
   content box and nothing at all vertically, so the icon sat 6px right and 1px low. */
.ha-btn img { width: 22px; height: 22px; image-rendering: pixelated; display: block; }
/* The weather pill, beside the zone name in the analyzer head. Deliberately quiet — it is a
   label, not a control, so it takes no border and no hover: the two buttons to its right are
   the only things in this row that do anything, and a third bordered box would read as a
   third button. `image-rendering: pixelated` because the icon is 48px art shown at 16 and the
   whole client renders nearest-neighbour (see the repo art rules). */
.ha-wx { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px;
  color: var(--muted); font-size: 11px; white-space: nowrap; }
.ha-wx-ico { width: 16px; height: 16px; image-rendering: pixelated; }
/* First-run nudge on the ⚙ automation button. Reuses the HUD's coach-mark language
   (`tutorial-pulse` + the bobbing arrow) rather than inventing a second one, so "the game
   wants you to look here" means the same thing everywhere. `position: relative` is required
   for the arrow — .ha-btn had no positioning of its own. */
.ha-btn.tutorial-point { position: relative; border-color: var(--accent);
  animation: tutorial-pulse 1.1s ease-in-out infinite; }
.ha-btn.tutorial-point::after { content: ""; position: absolute;
  left: 50%; bottom: calc(100% + 6px); margin-left: -7px; width: 0; height: 0;
  border: 7px solid transparent; border-top-color: var(--accent);
  filter: drop-shadow(0 2px 0 var(--edge));
  animation: tutorial-bob 0.9s ease-in-out infinite; pointer-events: none; }

/* Phones: a COLLAPSED analyzer shrinks to a single icon button, the same move the chat panel
   makes one section up and for the same reason. Collapsed still meant a full-width bar
   ("<zone> · weather · 📊 ⚙ ▸") across the right rail, which on a 390px screen spends a row of
   the field on something the player already knows — they can see the zone they are hunting.

   640px rather than the chat's 720px: the analyzer only exists inside the farm overlay, and
   this is the breakpoint the farm and delve HUD rules around it already use.

   ⚠️ This sits AFTER every `.ha-btn` rule above on purpose. `.hunt-analyzer.collapsed .ha-btn`
   and `.hunt-analyzer.collapsed .ha-toggle` are both three classes, so the specificity is tied
   and SOURCE ORDER is the only thing keeping the toggle on screen. Reorder these two lines and
   the panel folds shut with no way to reopen it.

   ⚠️ The first-run ⚙ survives the fold, deliberately. farm_live.ex puts that gear in the HEAD
   precisely so its pulse is visible while the analyzer is collapsed; dropping it here would
   quietly undo that on the devices least likely to go looking for the automation panel. Once
   the player saves it once, `.tutorial-point` goes and the puck is a single icon.

   No new glyph for the panel itself: the art rule forbids introducing an emoji into a
   player-facing string and there is no pixel icon for the hunt yet, so the caret it already
   had does the work. A real icon can replace it without touching anything else here. */
@media (max-width: 640px) {
  .hunt-analyzer.collapsed { width: auto; min-width: 44px; height: 44px; }
  .hunt-analyzer.collapsed .ha-head { padding: 0; height: 100%; justify-content: center; gap: 4px; }
  .hunt-analyzer.collapsed .ha-head > b,
  .hunt-analyzer.collapsed .ha-wx { display: none; }
  .hunt-analyzer.collapsed .ha-btn { display: none; }
  .hunt-analyzer.collapsed .ha-toggle,
  /* 34px inside the 44px puck (38px of inner box after the 3px borders) — the .ha-btn default
     is 26px, which is a small target for a thumb and is the only control the puck has left. */
  /* ⚠️ `inline-flex`, not `block`. This re-declares `display` to undo the `none` above it,
     and `block` would drop the centring `.ha-btn` sets — leaving the ⚙ riding high in a
     34px puck, which is the one control the collapsed analyzer has left. */
  .hunt-analyzer.collapsed .ha-btn.tutorial-point { display: inline-flex; width: 34px; height: 34px; }
}
/* The one-line explainer under it. Full-width and clickable: it opens the same panel the
   gear does, so the hint is the affordance rather than a caption pointing at one. */
.ha-hint { display: block; width: 100%; margin-bottom: 10px; padding: 7px 9px;
  background: rgba(255, 201, 60, 0.12); border: 2px solid var(--accent);
  color: var(--text); font-size: 11px; text-align: left; cursor: pointer; }
.ha-hint:hover { background: rgba(255, 201, 60, 0.2); }
.ha-body { padding: 10px 12px 12px; }
/* hunt lifetime countdown (6h free / 12h VIP) */
.ha-ttl { margin-bottom: 8px; }
.ha-ttl-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 12px; }
.ha-ttl-top .muted { font-size: 11px; }
.ha-ttl-bar { height: 8px; margin-top: 4px; }
.ha-ttl-bar > span { background: linear-gradient(90deg, #4a91ff, #8ad0ff); transition: width 1s linear; }
.ha-ttl-bar.low > span { background: linear-gradient(90deg, #d9532b, #ef7a3c); }
.ha-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.ha-tile { background: rgba(28, 38, 55, 0.7); border: 2px solid var(--edge);
  padding: 8px 10px; display: flex; flex-direction: column; gap: 1px; }
.ha-tile b { font-size: 15px; }
/* Tile labels are hidden here — the number alone shows, and hovering a tile puts its label
   in the global tooltip (FarmLive's `ha_tile/1`). There is deliberately NO :hover reveal
   any more: the old `.ha-tiles:hover .ha-tile span` animated all six labels from max-height
   0 to 20px together, so pointing at one number resized the grid and shoved the rest of the
   panel down. The span still renders because `.farm-report-tiles .ha-tile span` below
   overrides this to show the labels permanently in the away-report, which reuses .ha-tile. */
.ha-tile span { color: var(--muted); font-size: 11px;
  max-height: 0; opacity: 0; overflow: hidden; }
/* Each tile is wrapped in a .gtip trigger, so the WRAPPER is the grid item now and has to
   fill its column — .gtip is inline-flex by default, which would shrink-wrap the tile. */
.ha-tiles > .gtip { display: block; }
.ha-rates { display: flex; gap: 6px; margin-top: 8px; }
.ha-rates span { flex: 1; text-align: center; background: rgba(28, 38, 55, 0.7); border: 2px solid var(--edge);
  padding: 5px 4px; font-size: 12px; font-weight: 700; }
.ha-section { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 8px; }
.ha-sec-title { font-family: var(--pixel); font-size: 8px; letter-spacing: 0; text-transform: uppercase;
  color: var(--link); margin-bottom: 6px; }
.ha-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; }

/* Capture-log rows: one throw button per ball in the bag (see FarmLive.capture_log/1).
   The row must WRAP — four balls plus a long species name overflows the analyzer column,
   which is narrow on a phone — and the name needs `min-width: 0` or flex refuses to let it
   shrink and pushes the buttons out of the panel instead. */
.ha-throw-row { flex-wrap: wrap; }
.ha-throw-name { flex: 1 1 auto; min-width: 0; }
.ha-balls { display: flex; align-items: center; gap: 4px; margin-left: auto; }
.ha-ball { position: relative; width: 30px; height: 30px; flex: 0 0 30px; padding: 0; cursor: pointer;
  background: var(--bg-2); border: 2px solid var(--edge); color: var(--text); font-size: 12px;
  line-height: 1; display: flex; align-items: center; justify-content: center; }
.ha-ball:hover:not(:disabled) { border-color: var(--muted); }
.ha-ball:disabled { opacity: 0.35; cursor: default; }
.ha-ball-ico { width: 22px; height: 22px; image-rendering: pixelated; }
/* Count sits in the corner of the sprite, like the hotbar's .item-qty — the button is too
   small to give it a column of its own. */
.ha-ball-qty { position: absolute; right: -1px; bottom: -2px; font-family: var(--pixel); font-size: 8px;
  color: var(--text); text-shadow: 1px 1px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000, -1px -1px 0 #000; }
.ha-ball-yes { border-color: var(--primary-deep); }
.ha-confirm { gap: 6px; font-size: 12px; }
.ha-noballs { font-size: 12px; }

/* section title with an inline control on the right (VIP auto-sell on session drops).
   Wraps rather than squeezing — the analyzer column is narrow on a phone. */
.ha-sec-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.ha-sec-head .ha-sec-title { margin-bottom: 0; margin-right: auto; }
.ha-sell { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 700;
  cursor: pointer; user-select: none; }
.ha-sell input { width: 14px; height: 14px; accent-color: var(--primary); cursor: pointer; }
/* the non-VIP variant is a button, not a disabled checkbox — the whole thing is the
   upsell's hit area, so it must not look or behave like a dead control */
button.ha-sell.locked { background: none; border: none; padding: 0; color: inherit; font: inherit;
  font-size: 11px; font-weight: 700; }
button.ha-sell.locked:hover { color: var(--link); }
.ha-sell-box { width: 14px; height: 14px; display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--edge); font-size: 9px; line-height: 1; }
.ha-sell .fa-vip { cursor: pointer; }
.ha-sell-cap { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 11px; margin-bottom: 4px; }
.ha-sell-cap .fa-pill { padding: 2px 8px; font-size: 11px; }
.ha-sell-hint { font-size: 10px; margin: 0 0 6px; line-height: 1.35; }
.ha-sold { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 6px;
  padding: 4px 6px; background: rgba(28, 38, 55, 0.7); border: 2px solid var(--edge); }
.ha-sold b { margin-left: auto; color: #ffd447; display: inline-flex; align-items: center; gap: 3px; }

/* scrollable analyzer lists (recent catches / session drops) */
.ha-scroll { max-height: 190px; overflow-y: auto; padding-right: 4px; }
.ha-scroll.drops { max-height: 130px; }
.ha-catch { align-items: flex-start; border-bottom: 1px solid var(--border); padding: 5px 0; }
.ha-catch:last-child { border-bottom: none; }
.ha-catch-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ha-catch-top { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-size: 13px; }
.ha-catch-top .pill { padding: 1px 6px; font-size: 9px; border-width: 1px; }
.ha-stats { font-size: 10px; font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }

/* ---------------- Delve progress HUD (mirrors the Hunt Analyzer) ---------------- */
.delve-hud { width: 288px; max-width: 100%;
  background: rgba(18, 26, 38, 0.94);
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  font-size: 13px; }
.delve-hud.collapsed { width: auto; }
.dh-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 2px solid var(--border); }
.delve-hud.collapsed .dh-head { border-bottom: none; }
.dh-title { flex: 1; font-weight: 700; }
.dh-body { padding: 10px; display: flex; flex-direction: column; gap: 10px; }
.dh-result { text-align: center; font-weight: 800; padding: 6px; border: 2px solid var(--edge); }
.dh-result.clear { color: #7fe08a; background: rgba(40, 90, 50, 0.5); }
.dh-result.wipe { color: #ff8a8a; background: rgba(90, 40, 40, 0.5); }
.dh-obj-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.dh-count { font-weight: 800; font-variant-numeric: tabular-nums; }
/* ⚠️ The dampening bar is a `<.tooltip>` trigger, so a `.gtip` wrapper sits between `.dh-body`
   and the bar — and `.gtip { display: inline-flex }` would make that wrapper a flex ROW whose
   only item is a bar with no intrinsic width, collapsing it to nothing. `block` hands the bar
   back the full-width block it had. Seventh appearance of this rule; see the `.mon.gtip` /
   `.xrow.gtip` family for why each one is declared explicitly rather than left to source order. */
.dh-body > .gtip { display: block; }
.dh-bar { height: 10px; }
.dh-bar > span { background: linear-gradient(90deg, #6ec1ff, #b98cff); transition: width 0.4s ease; }
.dh-hint { margin-top: 5px; font-size: 12px; color: var(--muted); }
.dh-secondary { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.dh-secondary li { display: flex; align-items: center; gap: 6px; color: var(--muted); }
.dh-secondary li.done { color: var(--text); }
.dh-check { width: 16px; text-align: center; color: var(--muted); }
.dh-secondary li.done .dh-check { color: #7fe08a; }
.dh-secondary li.failed { color: var(--muted); text-decoration: line-through; }
.dh-secondary li.failed .dh-check { color: #ce4069; }
/* The sequence lock's code, once the note has been read. A row of pips rather than a sentence:
   the player is matching symbols on keypads, not reading. */
.dh-seq { display: flex; gap: 6px; margin: 6px 0 2px; }
.dh-pip {
  min-width: 20px; padding: 2px 6px; border-radius: 6px; text-align: center;
  font-weight: 800; font-size: 12px; font-family: monospace;
  background: var(--panel-2); border: 1px solid var(--border); color: var(--muted);
}
.dh-pip.on { background: #9d7bff22; border-color: #9d7bff; color: #cbb8ff; }
.dh-party { font-size: 12px; font-weight: 700; border-top: 1px solid var(--border); padding-top: 8px; }

/* ---------------- Delve key art + briefing (DelvesLive) ----------------
   Source art is 1024x288 (32x9 tiles) indexed to the 64-colour master palette, generated by
   tools/sprites/make_delve_banners.py and living under priv/static/images/delves/ — TRACKED,
   unlike everything in assets/game/. `background-size: cover` + `pixelated`, the same treatment
   the profile banners get. ⚠️ The URL is built server-side with a ?v= bust; do NOT move it into
   this file, or a redrawn banner never reaches a warm browser (the character-creation wizard's
   key art has exactly that bug). */
/* ⚠️ `aspect-ratio`, not a fixed height. The art was already band-cropped from 21:9 down to
   32:9 when it was baked; a fixed height plus `cover` would crop it a SECOND time at whatever
   the panel width happened to be, throwing away the top and bottom of a picture that was
   composed to be exactly this shape. 32/9 matches the 1024x288 source, so `cover` has nothing
   left to trim and the whole banner is always on screen. */
.dlv-hero { position: relative; aspect-ratio: 32 / 9; margin-bottom: 14px;
  background-size: cover; background-position: center; image-rendering: pixelated;
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border); }
/* Bottom-weighted so the art stays readable at the top while the text sits on near-solid
   ground — the same gradient .wizard-scrim uses over the creation key art. */
.dlv-hero-scrim { position: absolute; inset: 0;
  background: linear-gradient(rgba(8, 17, 32, 0.15), rgba(8, 17, 32, 0.88)); }
.dlv-hero-text { position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 14px; }
.dlv-hero-name { font-family: var(--pixel); font-size: 13px; line-height: 1.5; color: var(--text);
  text-shadow: 2px 2px 0 var(--edge); }
.dlv-hero-tag { font-size: 12px; font-weight: 700; color: var(--accent); margin-top: 5px; }
@media (max-width: 640px) { .dlv-hero-name { font-size: 10px; } .dlv-hero-text { padding: 7px 10px; } }

/* ---- professions teaser: five key-art cards, none of them playable yet -------------------
   Cloned from `.dlv-hero` above, including its `aspect-ratio` argument — the art is the same
   1024x288 band-cropped shape from the same pipeline, so a fixed height would crop a picture
   that was composed to be exactly this. `image-rendering: pixelated` is right here (1024 into
   an ~812px modal is close to 1:1); the website's `.feature-banner` deliberately uses `auto`
   instead, because a 512px source into a ~342px card is a large non-integer reduction where
   `pixelated` stipples. */
/* ⚠️ `.prof-intro`, `.prof-tabs` and the whole `.prof-tab*` family were deleted on 2026-08-25
   with the panel's header. **Do not re-add the styles "since the markup is easy" — the markup is
   the thing that was removed.** The Professions panel is opened by one craftsman and shows one
   trade; a five-tab strip is precisely what makes the cook's window able to open Spherecrafting,
   which is what the five NPCs in the artisans' quarter exist to prevent. `handle_event("tab", …)`
   is gone too, so a re-added strip would be five dead buttons.
   `professions_test.exs` refuses `.prof-tabs` and `phx-click="tab"` in the rendered panel.
   ⚠️ `.prof-subtab` below is a DIFFERENT control — Recipes | Specialization, inside one
   profession — and stays. */
.prof-card { margin-bottom: 22px; }
.prof-hero { position: relative; aspect-ratio: 32 / 9;
  background-size: cover; background-position: center; image-rendering: pixelated;
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border); }
/* ⚠️ Weighted harder to the bottom than `.dlv-hero-scrim`'s flat 0.15 -> 0.88, and the reason is
   what the art is FOR. On the delve card one banner decorates a briefing, so an even wash costs
   nothing; here five banners are the whole feature and the text occupies only the bottom fifth,
   where the scrim still reaches 0.9. Above that it clears almost completely, so the picture is
   seen rather than dimmed. Do not flatten it back to match the delve — they are answering
   different questions. */
.prof-hero-scrim { position: absolute; inset: 0;
  background: linear-gradient(rgba(8, 17, 32, 0) 0%, rgba(8, 17, 32, 0.06) 42%,
    rgba(8, 17, 32, 0.55) 72%, rgba(8, 17, 32, 0.92) 100%); }
/* A flex row, unlike `.dlv-hero-text`: the icon sits beside the name rather than above it, and
   is what still identifies the card if the banner has not loaded. */
.prof-hero-text { position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 14px;
  display: flex; align-items: center; gap: 10px; }
.prof-icon { width: 32px; height: 32px; image-rendering: pixelated; flex: 0 0 auto; }
.prof-hero-name { font-family: var(--pixel); font-size: 13px; line-height: 1.5; color: var(--text);
  text-shadow: 2px 2px 0 var(--edge); }
.prof-hero-tag { font-size: 12px; font-weight: 700; color: var(--accent); margin-top: 5px; }
/* Top-right, where the scrim is at its lightest — the "Em breve" pill is the one thing on the
   card that must read before the player starts on the prose. */
.prof-soon { position: absolute; top: 8px; right: 10px; z-index: 2;
  font-family: var(--pixel); font-size: 8px; line-height: 1; letter-spacing: 0.5px;
  color: #fff; background: #000b; border: 2px solid var(--edge); padding: 6px 8px; }
.prof-body { padding: 10px 2px 0; text-align: left; }
.prof-text { margin: 0; font-size: 12px; line-height: 1.65; color: var(--text); opacity: 0.86; }
.prof-bullets { list-style: none; margin: 9px 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: 6px; }
.prof-bullets li { font-size: 11px; color: var(--muted);
  background: var(--panel); border: 2px solid var(--border); padding: 4px 8px; }
/* ⚠️ `.prof-vote` / `.prof-voted` went on 2026-09-03 with the `prof_*` vote button, which asked
   which profession to build first once all five were built. Do not re-add the rules without the
   button AND its handler — `professions_live.ex`'s moduledoc has the argument. */
.prof-footer { margin-top: 18px; font-size: 11px; text-align: center; }

/* ---- the profession's menu: what it will make ------------------------------------------
   Only Gastronomy has one today, and the whole block is skipped when the list is empty — so
   these rules never apply on the other four tabs rather than collapsing to nothing there. */
.prof-menu { margin-top: 16px; padding-top: 12px; border-top: 2px solid var(--border); }
.prof-menu-head { font-size: 11px; font-weight: 700; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.06em; }
.prof-menu-note { margin: 4px 0 8px; font-size: 11px; color: var(--muted); opacity: 0.85; }
/* The Well Fed rules. Stacked rather than a wrapping chip row like `.prof-bullets`: these are
   three sentences that must each be read, not three tags to be scanned past — and a chip row
   would put them on one line where the middle one is the easiest to miss. The left rule is the
   cue that they belong together and govern everything below. */
.prof-menu-rules { list-style: none; margin: 0 0 14px; padding: 0 0 0 10px;
  border-left: 2px solid var(--primary); display: flex; flex-direction: column; gap: 3px; }
.prof-menu-rules li { font-size: 11px; color: var(--text); opacity: 0.8; line-height: 1.5; }
/* A second section (Apothecary's tonics) needs air above it; the first inherits `.prof-menu`'s
   own top border instead, so the two do not stack a rule on a rule. */
.prof-sec + .prof-sec { margin-top: 18px; padding-top: 14px; border-top: 2px solid var(--border); }
/* Icon left, text right. `align-items: flex-start` so a two-line effect does not drag the
   48px icon down to the middle of the row. */
.prof-meal { display: flex; align-items: flex-start; gap: 10px; padding: 9px 0;
  border-bottom: 1px solid var(--border); }
.prof-meal:last-child { border-bottom: 0; }
/* 48px source at 48px — 1:1, unlike the bag grid's `.islot img`, which shows 32px art in a
   42px box. Nothing here inherits `.islot`. */
.prof-meal-ico { width: 48px; height: 48px; image-rendering: pixelated; flex: 0 0 auto; }
.prof-meal-txt { min-width: 0; }
/* Consumable vs permanent, on the ball fittings only. It earns a per-item tag because it
   DIFFERS between them — unlike the tonics' cooldown, which was identical on all six and lives
   in that section's rules instead. */
.prof-tag { margin-left: 7px; font-family: var(--pixel); font-size: 7px; color: var(--muted);
  background: var(--panel-2, var(--panel)); border: 2px solid var(--border); padding: 2px 5px;
  vertical-align: middle; }
.prof-meal-name { font-family: var(--pixel); font-size: 9px; line-height: 1.5; color: var(--text); }
.prof-meal-fx { margin: 5px 0 0; font-size: 12px; line-height: 1.55; color: var(--text);
  opacity: 0.86; }
/* The three ingredients, as chips that borrow `.prof-bullets li`'s look so the tab stays one
   visual system. 32px source at 32px, 1:1 again. */
.prof-meal-mats { list-style: none; margin: 7px 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: 5px; }
.prof-meal-mats li { display: flex; align-items: center; gap: 5px; font-size: 10px;
  color: var(--muted); background: var(--panel); border: 2px solid var(--border);
  padding: 3px 7px 3px 4px; }
.prof-meal-mats img { width: 32px; height: 32px; image-rendering: pixelated; flex: 0 0 auto; }
/* A gathering category: the same chips, but they are the section's whole content rather than
   one recipe's ingredients, so they get room to breathe and are not indented under a row. */
.prof-yield { margin-top: 2px; gap: 6px; }
/* ⚠️ `<.tooltip>` wraps its trigger in a `span.gtip`, which would otherwise sit between the
   flex `li` and its icon+name and collapse the row. Making the wrapper the flex container keeps
   the chip laid out exactly as it was before it gained a hover card. */
.prof-meal-mats .gtip { display: flex; align-items: center; gap: 5px; }
.prof-meal .gtip { flex: 0 0 auto; }
/* The hover card's own two lines, matching `item_tip/1`'s shape: a muted type under the name,
   then one line of detail. */
.prof-tip-kind { display: block; font-size: 11px; margin-top: 2px; }
.prof-tip-line { display: block; margin-top: 4px; font-size: 12px; max-width: 260px; }

/* ---- crafting (the `professions.crafting` flag) --------------------------------------------
   Everything below renders only when the server ships a craft board; with the flag off the
   panel is the teaser and none of these selectors match anything. */

/* The profession's ladder, above the menu. */
.prof-lvl { margin: 10px 0 2px; }
.prof-lvl-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.prof-lvl-n { font-family: var(--pixel); font-size: 9px; color: var(--text); }
.prof-lvl-exp { font-size: 11px; }
.prof-lvl-bar { height: 6px; margin-top: 5px; border: 2px solid var(--border);
  background: var(--panel-2); overflow: hidden; }
.prof-lvl-bar i { display: block; height: 100%; background: var(--accent); }

/* A recipe you cannot reach yet: the level pill goes muted rather than the row disappearing.
   Locked rows are shown and disabled, never filtered — the guild shop's rule, because a locked
   recipe is the only visible reason to level the profession. */
.prof-tag.off { color: var(--danger); opacity: 0.9; }
/* The level gate on a gathering category. ⚠️ `.prof-menu-head` is `text-transform: uppercase`
   with `letter-spacing: 0.06em` — right for a two-word heading, wrong for the SENTENCE this tag
   carries ("Found in the wild from level 15"), which the heading's own typography would render
   as spaced-out capitals. Every other `.prof-tag` in the panel sits beside a recipe name, where
   neither property applies, so this is the one place it has to be undone. */
.prof-menu-head .prof-tag { text-transform: none; letter-spacing: 0; }

/* 🔴 The ingredient you are short of. `.short` is set only for `have: 0`, so it marks the one
   thing standing between the player and the craft rather than colouring the whole row. */
.prof-mat-n { font-family: var(--pixel); font-size: 7px; color: var(--muted); margin-left: 1px; }
.prof-meal-mats li.short { opacity: 0.55; }
.prof-meal-mats li.short .prof-mat-n { color: var(--danger); opacity: 1; }

/* 🔴 The 14 craftables whose item does nothing yet. Their effect line above describes what it
   WILL do, and the materials it spends cannot be re-obtained, so this line is what keeps a
   deliberate craft from reading as a broken item. */
.prof-meal-warn { margin: 5px 0 0; font-size: 11px; line-height: 1.5; color: var(--danger);
  opacity: 0.85; }

.prof-craft { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.prof-craft-fee { display: inline-flex; align-items: center; gap: 4px;
  font-family: var(--pixel); font-size: 8px; color: var(--text); }
.prof-craft-exp { font-size: 11px; }
.prof-craft .btn { margin-left: auto; }
/* ⚠️ MANDATORY, and the same rule (and the same reason) as `.gs-buy`'s: `#global-tip` resolves
   a hover with `closest(".gtip")`, and a disabled button is unreliable about dispatching mouse
   events — so without this the reason a craft is refused can never be shown. */
.prof-craft .gtip { margin-left: auto; }
.prof-craft .gtip > .btn[disabled] { pointer-events: none; }
@media (max-width: 640px) {
  .prof-meal-ico { width: 40px; height: 40px; }
  .prof-meal-mats img { width: 24px; height: 24px; }
  .prof-meal-mats li { font-size: 9px; }
}
@media (max-width: 640px) {
  .prof-hero-name { font-size: 10px; }
  .prof-hero-text { padding: 7px 10px; gap: 8px; }
  .prof-icon { width: 24px; height: 24px; }
}

/* ---- the crafting window ---------------------------------------------------------------
   A recipe list beside a recipe detail pane. Rendered for every player: `GET /professions`
   carries no flag check, and `professions.crafting` gates only whether Craft can be pressed. */

/* ⚠️ `.prof-locked-note` went with the header too. It was the banner saying the crafting rollout
   is off for you; that fact now reaches the player through `craft_reason/2` on the disabled Craft
   button, which is where they meet it. See the note above `.prof-card`. */

/* The Recipes | Specialization strip. ⚠️ `border: 0` before the bottom edge, the same rule
   `button.tab` carries: without it the UA's default button border boxes each control and the two
   read as separate buttons rather than as one with a current item. */
.prof-sub { display: flex; gap: 4px; margin: 12px 0 12px; border-bottom: 2px solid var(--border); }
.prof-subtab { border: 0; border-bottom: 2px solid transparent; margin-bottom: -2px;
  background: transparent; color: var(--muted); cursor: pointer; padding: 7px 14px;
  font-family: var(--pixel); font-size: 8px; line-height: 1.4; }
.prof-subtab:hover { color: var(--text); background: var(--panel); }
.prof-subtab.on { color: var(--text); border-bottom-color: var(--primary); background: var(--panel); }

/* ---- the bench ---------------------------------------------------------------------------
   Crafts in flight for this profession, above both sub-panes because it is the one thing on the
   screen that changes without anybody clicking. */
.prof-queue { border: 2px solid var(--border); background: var(--panel-2); padding: 10px 12px;
  margin-bottom: 14px; }
.prof-queue-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.prof-queue-title { font-family: var(--pixel); font-size: 8px; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.06em; }
.prof-queue-slots { font-size: 11px; }

/* ⚠️ ONE LINE, always — a carousel, not a stack. A subscriber's bench is `1 + profession level`
   slots (21 at the cap), and as a column that was ~1,080px of rows inside a 1,080px-wide modal:
   the recipe list and the detail pane were pushed clean off the screen. `.chat-tabs` is the model,
   for the identical reason — an unbounded list that must never grow a second row.

   ⚠️ `min-width: 0` is load-bearing and invisible when missing. A flex item defaults to
   `min-width: auto`, which refuses to shrink below its content — so the strip would simply widen
   past the panel instead of scrolling, `scrollWidth === clientWidth` for ever, and the arrows
   would never appear because nothing ever reports overflow. `prof_bench_test.exs` pins it. */
.prof-bench { display: flex; align-items: stretch; gap: 4px; margin-top: 9px; }
.prof-jobs { display: flex; flex-direction: row; gap: 6px; flex: 1; min-width: 0;
  overflow-x: auto; scroll-behavior: smooth; scrollbar-width: none; -ms-overflow-style: none; }
/* No visible scrollbar — it would sit under the tiles and the arrows are the affordance. */
.prof-jobs::-webkit-scrollbar { display: none; }

/* ◂ / ▸, the same two glyphs `.chat-tabs-nav` uses one panel over, so the client keeps one arrow
   vocabulary. Hidden entirely (not merely disabled) while everything fits. */
.prof-bench-nav { flex: 0 0 auto; width: 18px; padding: 0; cursor: pointer;
  border: 2px solid var(--border); background: transparent; color: var(--muted);
  font-size: 10px; line-height: 1; }
.prof-bench-nav:hover:not(:disabled) { color: var(--text); }
.prof-bench-nav:disabled { opacity: 0.3; cursor: default; }

/* ⚠️ `flex: 0 0 auto` for the mirror-image reason to `min-width: 0` above: without it 21 tiles
   squash into unreadable slivers rather than overflowing, and again nothing ever reports the
   overflow the arrows key off. Pinned.
   ⚠️ `min-height` is what keeps an EMPTY bench from collapsing. `align-items` defaults to
   `stretch`, so the dashed sockets take their height from the tallest tile beside them — and a
   free player with nothing queued has no tile beside them at all, which is a 0px-tall strip. */
.prof-job, .prof-slot { flex: 0 0 auto; width: 86px; min-height: 104px; }

/* `position: relative` is what the × hangs off — see `.prof-job .gtip` below. */
.prof-job { position: relative; display: flex; flex-direction: column; align-items: center;
  gap: 4px; padding: 6px 5px; border: 2px solid var(--border); background: var(--panel); }
/* Finished, waiting for room. Marked rather than dimmed — it is the one tile that wants a click. */
.prof-job.ready { border-color: var(--accent); }
.prof-job-ico { width: 32px; height: 32px; image-rendering: pixelated; flex: 0 0 auto; }
/* ⚠️ `width: 100%`, not `flex: 1 1 auto` — the tile is a COLUMN now, so the free axis is the one
   the bar and the name need to fill, and `flex` grows the wrong one. */
.prof-job-txt { width: 100%; min-width: 0; }
/* Two lines, clamped. Every craftable in the game has a long name ("Ensopado Moomoo-Sitrus"), so a
   single ellipsised line in an 86px tile shows about four characters; two shows most of it. */
.prof-job-name { font-size: 11px; line-height: 1.3; text-align: center; overflow: hidden;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.prof-job-held { margin-top: 3px; font-size: 10px; line-height: 1.4; color: var(--accent);
  text-align: center; }
/* ⚠️ There is NO `.btn.tiny` rule in this file — `tiny` is a no-op class that ~a dozen call sites
   pass, so Collect renders at the full `.btn` 13px/18px padding and 10px pixel face. That was
   merely large in a full-width row; in an 86px tile it is wider than the tile. Scoped down here
   rather than by inventing a global `.tiny`, which would move every one of those call sites at
   once. */
.prof-job .btn { width: 100%; margin-top: auto; justify-content: center; font-size: 8px;
  padding: 6px 4px; border-width: 2px; }
.prof-job .btn:active { transform: translate(2px, 2px); }

/* ⚠️ A server-computed width and NO transition. `.prof-lvl-bar`'s idiom, and here the absence of
   the transition is the load-bearing half: the panel re-renders this every second from the
   deadline the server holds, so a CSS animation would be a second, drifting opinion about how
   far along the craft is. On a thirty-second craft a second's disagreement is visible. */
.prof-job-bar { height: 5px; margin-top: 5px; border: 2px solid var(--border);
  background: var(--panel-2); overflow: hidden; width: 100%; }
.prof-job-bar i { display: block; height: 100%; background: var(--primary); }

/* `margin-top: auto` on both bottom-row children, so the countdown and the Collect button sit on
   one line across a strip of tiles whose names ran to one line and two. */
.prof-job-left { flex: 0 0 auto; margin-top: auto; font-family: var(--pixel); font-size: 8px;
  color: var(--text); font-variant-numeric: tabular-nums; }
/* ⚠️ `tabular-nums` on the countdown, or the row jitters left and right once a second as the
   digits change width — on a panel whose whole job is to be watched. */

.prof-job-x { flex: 0 0 auto; border: 0; background: transparent; color: var(--muted);
  cursor: pointer; font-size: 15px; line-height: 1; padding: 1px 4px; }
.prof-job-x:hover { color: var(--danger); background: var(--panel-2); }
/* ⚠️ The corner position goes on `.gtip` — the tooltip component's OWN span — never on the button
   inside its `:trigger`. `#global-tip` resolves a hover with `closest(".gtip")` and copies the
   body out to a layer parked on `document.body`, so the scroller's `overflow-x` cannot clip the
   card; moving the button out from under its own wrapper is what would break it. */
.prof-job .gtip { position: absolute; top: 1px; right: 1px; }

/* An empty slot. Dashed, the idiom the tree's sockets use and for the same reason: a solid empty
   box is indistinguishable from art that failed to load, which is the exact confusion every
   `onerror` in this panel exists to avoid. */
.prof-slot { align-self: stretch; border: 2px dashed var(--border); background: var(--panel); }

/* 🔴 The free account's pitch, and it is a PARAGRAPH. It must never become `.fa-lock` /
   `.fa-lock-note` — those are `position: absolute; inset: -6px` and belong only inside a
   positioned box sized to exactly what is being locked. The VIP rule records one on the wrong
   element becoming an invisible overlay across a whole panel that swallowed every click a free
   player made on every tab, including the free controls. Nothing here is locked: a free player's
   single craft works completely. */
.prof-queue-vip { margin: 9px 0 0; font-size: 11px; line-height: 1.5; color: var(--muted); }

/* Two panes. ⚠️ `minmax(0, …)` on both: a grid item's automatic minimum is its content, so
   without it a long recipe name pushes the column wider than the track and the window grows a
   horizontal scrollbar instead of the list scrolling vertically. The same trap `.dpt` documents. */
.prof-craft-win { display: grid; grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
  gap: 14px; align-items: start; }

/* The list. Fixed height rather than flowed: this renders inside GameLive's modal behind two
   untyped wrappers, so a percentage chain has nothing to resolve against — `.dpt`'s trick. */
.prof-recipes { max-height: 460px; overflow-y: auto; border: 2px solid var(--border);
  background: var(--panel-2); padding: 6px; }
.prof-rsec + .prof-rsec { margin-top: 10px; }
.prof-rlist-head { font-size: 10px; font-weight: 700; color: var(--muted); text-transform: uppercase;
  letter-spacing: 0.06em; padding: 6px 6px 5px; }

.prof-rrow { display: flex; align-items: center; gap: 7px; width: 100%; text-align: left;
  border: 0; border-left: 3px solid transparent; background: transparent; cursor: pointer;
  padding: 5px 6px; color: var(--text); }
.prof-rrow:hover { background: var(--panel); }
.prof-rrow.on { background: var(--panel); border-left-color: var(--primary); }
/* A recipe you cannot reach yet is shown and dimmed, never filtered — the guild shop's rule,
   because a locked recipe is the only visible reason to level the profession. It stays
   CLICKABLE: the detail pane is where the level requirement and the ingredients are, which is
   exactly what a player looking at a locked row wants to read. */
.prof-rrow.locked .prof-rrow-name, .prof-rrow.locked .prof-rrow-ico { opacity: 0.55; }
.prof-rrow-ico { width: 28px; height: 28px; image-rendering: pixelated; flex: 0 0 auto; }
.prof-rrow-name { flex: 1 1 auto; min-width: 0; font-size: 11px; line-height: 1.35;
  overflow: hidden; text-overflow: ellipsis; }
/* WoW's bracketed count. Rendered only when non-zero, so it reads as "you can make this" rather
   than as a column of zeroes on the state every player is in today. */
.prof-count { flex: 0 0 auto; font-family: var(--pixel); font-size: 7px; color: var(--bg);
  background: var(--accent); padding: 3px 5px; }
.prof-rrow .prof-tag { margin-left: 0; flex: 0 0 auto; }

/* The detail pane. */
.prof-detail { border: 2px solid var(--border); background: var(--panel-2); padding: 12px;
  min-height: 220px; }
.prof-detail-empty { font-size: 12px; margin: 0; }
.prof-detail-head { display: flex; align-items: center; gap: 10px; }
.prof-detail-ico { width: 48px; height: 48px; image-rendering: pixelated; flex: 0 0 auto; }
.prof-detail-id { min-width: 0; }
.prof-detail-name { font-family: var(--pixel); font-size: 10px; line-height: 1.5; color: var(--text); }
.prof-detail-kind { font-size: 11px; margin-top: 4px; }
.prof-detail-fx { margin: 10px 0 0; font-size: 12px; line-height: 1.6; color: var(--text);
  opacity: 0.9; }
.prof-reagents-head { margin-top: 12px; padding-top: 10px; border-top: 2px solid var(--border);
  font-size: 10px; font-weight: 700; color: var(--muted); text-transform: uppercase;
  letter-spacing: 0.06em; }
.prof-reagents { margin-top: 7px; }

/* ⚠️ The count reads as prose, not as a number in a badge. "Your ingredients make 4" is a
   sentence about the bag; a bare "4" beside a Gold fee reads as a second price. */
.prof-can { font-size: 11px; color: var(--accent); }
.prof-can.none { color: var(--muted); }

/* ---- the specialization tree -------------------------------------------------------------
   A frame with empty sockets. ⚠️ Nothing here is a button and nothing here is clickable:
   there is no column behind a point and no endpoint that could spend one. A socket that does
   nothing when pressed reads as broken; one that visibly cannot be pressed reads as unbuilt. */
.prof-tree { border: 2px solid var(--border); background: var(--panel-2); padding: 14px; }
.prof-tree-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.prof-tree-pts { font-family: var(--pixel); font-size: 9px; color: var(--accent); }
.prof-tree-note { margin: 8px 0 0; font-size: 11px; line-height: 1.6; max-width: 62ch; }

.prof-tier { display: flex; align-items: center; gap: 12px; margin-top: 14px; padding-top: 12px;
  border-top: 2px solid var(--border); }
.prof-tier-lvl { flex: 0 0 74px; font-family: var(--pixel); font-size: 8px; color: var(--muted); }
.prof-tier-slots { display: flex; flex-wrap: wrap; gap: 8px; }
/* The socket. Dashed rather than solid so it reads as a placeholder at a glance — a solid
   empty square is indistinguishable from art that failed to load, which is the exact confusion
   every `onerror="this.style.display='none'"` in this panel exists to avoid. */
.prof-node { display: block; width: 40px; height: 40px; border: 2px dashed var(--border);
  background: var(--panel); }
/* A socket with something IN it. ⚠️ A different element from `.prof-node`, not a modifier on it:
   the blank socket is a 40px square and this is a labelled box with a name, three pips and a
   button, so one class carrying both would be two layouts fighting over one width. Four of the
   five professions still render the square, and must keep doing so — see `tier_nodes/1`. */
.prof-nodebox { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center;
  gap: 4px; width: 92px; padding: 7px 5px; border: 2px solid var(--border);
  background: var(--panel); }
/* Solid and primary-edged once a point is in it, so a spent tree is readable at a glance from
   across the panel rather than by counting pips. */
.prof-nodebox.on { border-color: var(--primary); background: var(--panel-2, var(--panel)); }
.prof-node-name { font-family: var(--pixel); font-size: 7px; line-height: 1.4; text-align: center;
  color: var(--text); overflow-wrap: anywhere; hyphens: auto; }
.prof-node-pips { display: flex; gap: 3px; }
/* ⚠️ Square, not round. Every other small state dot in this panel is a `.notif-dot`, and a round
   pip here would read as "unread" rather than as a rank you bought. */
.prof-pip { width: 7px; height: 7px; border: 2px solid var(--border); background: transparent; }
.prof-pip.on { background: var(--primary); border-color: var(--primary); }
.prof-tree-reset { margin-top: 14px; }
/* ⚠️ `.gtip` is the flex item here, not `.prof-node` — the hover card wraps each socket, so the
   wrapper is what the row's gap and sizing act on. Without `flex: 0 0 auto` an `inline-flex`
   child stretches to fill the row and the sockets drift apart. Same class of trap as
   `.mon.gtip { display: block }`, which `mon_tip_test.exs` pins by file read. */
.prof-tier-slots > .gtip { flex: 0 0 auto; }
/* A locked ROW dims as a whole rather than each socket: the gate is the level, which is one
   fact about the row, and dimming per node would suggest the nodes differ. */
.prof-tier.locked { opacity: 0.45; }

/* The announcement, which Foraging and Refining keep permanently and every profession falls
   back to when the board could not be fetched. */
.prof-soon-block { margin-top: 4px; }

@media (max-width: 760px) {
  /* One column. The panes stack rather than squeezing: at two columns below this the recipe
     names ellipsis away to nothing and the reagent rows wrap onto two lines each. */
  .prof-craft-win { grid-template-columns: minmax(0, 1fr); }
  .prof-recipes { max-height: 260px; }
  .prof-tier { flex-direction: column; align-items: flex-start; gap: 7px; }
  .prof-tier-lvl { flex: 0 0 auto; }
  .prof-node { width: 34px; height: 34px; }
  .prof-nodebox { width: 78px; padding: 6px 4px; }
  .prof-job-ico { width: 26px; height: 26px; }
  /* Narrower tiles, so three still fit across a 390px full-bleed modal. The strip scrolls by
     touch drag here; the arrows stay for a small laptop. */
  .prof-job, .prof-slot { width: 74px; min-height: 96px; }
}

/* A briefing section inside the delve card — lore, objectives, record. */
.dlv-sec { margin-top: 10px; padding-top: 10px; border-top: 2px solid var(--border); text-align: left; }
.dlv-sec-head { font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase;
  letter-spacing: 0.06em; margin-bottom: 6px; }
.dlv-lore { margin: 0; font-size: 12px; line-height: 1.65; color: var(--text); opacity: 0.86; }

.dlv-objs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
.dlv-obj { display: flex; align-items: baseline; gap: 8px; font-size: 12px; }
.dlv-obj-mark { color: var(--muted); font-weight: 800; }
.dlv-obj-label { flex: 1; }
/* The tag carries the meaning a checklist otherwise implies wrongly: an optional objective read
   as mandatory stops a player entering at a level they could actually clear. */
.dlv-obj-tag { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.dlv-obj.done .dlv-obj-mark { color: var(--green); }
.dlv-obj.done .dlv-obj-label { color: var(--muted); text-decoration: line-through; }
.dlv-obj.done .dlv-obj-tag { color: var(--green); }

.dlv-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.dlv-stat { display: flex; flex-direction: column; align-items: center; padding: 6px 4px;
  border: 2px solid var(--edge); background: rgba(0, 0, 0, 0.24); }
.dlv-stat-n { font-size: 15px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
.dlv-stat-l { font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;
  margin-top: 2px; text-align: center; }

/* ⚠️ `.mon.sel` and `.mon.locked` were EMITTED BY delves_live.ex AND STYLED BY NOTHING — the
   selected delve had no selected state at all and a locked one no dimming beyond the native
   `disabled` attribute. The team picker two blocks down uses `.selected`, which does have a
   rule; these are the delve roster's. Kept as their own selectors rather than renamed to
   `.selected`, because `sel` is what the four other `.sel` hosts in this file already use. */
.mon.sel { border-color: var(--primary); box-shadow: inset 0 0 0 2px var(--primary-deep); }
.mon.locked { opacity: 0.45; cursor: default; }
.mon.locked:hover { transform: none; box-shadow: inset 0 0 0 2px var(--border); }
/* "today's first clear here still pays 10x + a TM". Top-right because a delve card leaves both
   top corners free (`.no` and `.rar` are Pokédex-only hosts).
   ⚠️ `pointer-events: none` is load-bearing, not tidiness: the card IS the <button>, so without
   it the badge becomes the click target and swallows the pick — the same trap `.notif-dot`'s
   rules carry. And note there is no bare `.dlv-bonus` rule to inherit: like `.notif-dot`, this
   class paints only where a host opts in. */
.mon .dlv-bonus { position: absolute; top: 6px; right: 6px; width: 24px; height: 24px;
  image-rendering: pixelated; pointer-events: none; z-index: 2; }

/* ---------------- Delve level picker (DelvesLive) ----------------
   The stepper only clamps; GameService.Delves.check_level_cap/2 is the authority. */
.dlv-pick { margin-top: 10px; padding-top: 10px; border-top: 2px solid var(--border);
  display: flex; flex-direction: column; gap: 6px; align-items: center; }
.dlv-pick-head { font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
.dlv-stepper { display: flex; align-items: stretch; gap: 6px; }
.dlv-step, .dlv-max { font: inherit; font-weight: 800; cursor: pointer; color: var(--text);
  background: var(--panel-2, rgba(255, 255, 255, 0.06));
  border: 2px solid var(--edge); box-shadow: inset 0 0 0 1px var(--border); padding: 4px 10px; }
/* ⚠️ A HELD stepper must not start a text selection or the iOS callout sheet. The `HoldStep`
   hook turns a hold into a repeat, and without these a long press on a phone selects the −
   glyph and raises Copy / Look Up instead — the same half-a-feature `-webkit-touch-callout`
   is for the long-press context menus. `touch-action: manipulation` drops the 300ms
   double-tap delay too, which is most of what made the taps feel sluggish. */
.dlv-step { min-width: 34px; font-size: 18px; line-height: 1;
  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
  touch-action: manipulation; }
.dlv-max { font-size: 11px; letter-spacing: 0.04em; }
.dlv-step:hover:not(:disabled), .dlv-max:hover:not(:disabled) { background: rgba(255, 212, 71, 0.16); }
.dlv-step:disabled, .dlv-max:disabled { opacity: 0.35; cursor: default; }
.dlv-level { display: flex; flex-direction: column; align-items: center; justify-content: center;
  min-width: 86px; padding: 2px 10px; border: 2px solid var(--edge); background: rgba(0, 0, 0, 0.28); }
.dlv-level-label { font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.dlv-level-n { font-size: 22px; font-weight: 800; line-height: 1.1; color: #ffd447; font-variant-numeric: tabular-nums; }
.dlv-scale { font-size: 12px; font-weight: 700; }
.dlv-tm, .dlv-cap { font-size: 11px; color: var(--muted); text-align: center; }
.dlv-tm:empty { display: none; }

/* ---------------- Delve reward summary ----------------
   Shown only on a clear. `items` empty is an honest unlucky roll, not a broken payout. */
.delve-reward { max-width: 420px; text-align: center; }
.dr-title { margin: 0 0 10px; font-size: 17px; }
/* The trophy on a win (CoreComponents.trophy/1) — it replaced a 🏆 emoji in the duel, delve and
   raid headings. Pixel art at 1:1; when ui/trophy.png is missing the <img> removes itself and
   `.cel-trophy-css` draws a small gold cup from borders, so the heading never shows a broken
   image or loses its mark. */
.cel-trophy { display: inline-block; width: 32px; height: 32px; vertical-align: -8px; margin-right: 6px; position: relative; }
.cel-trophy img { width: 32px; height: 32px; image-rendering: pixelated; display: block; }
.cel-trophy-css::before { content: ""; position: absolute; left: 8px; top: 4px; width: 16px; height: 14px;
  background: #ffd447; border: 2px solid #050b16; border-radius: 0 0 8px 8px; box-shadow: inset -3px 0 0 #c79a1e; }
.cel-trophy-css::after { content: ""; position: absolute; left: 11px; top: 22px; width: 10px; height: 6px;
  background: #c79a1e; border: 2px solid #050b16; }
.dr-stats { display: flex; flex-wrap: wrap; gap: 6px 12px; justify-content: center;
  font-size: 11px; color: var(--muted); margin-bottom: 12px; }
.dr-sec.done { color: #7fe08a; }
/* The daily-bonus banner above the loot list — it explains the numbers rather than being one
   of them, which is why it is not a `.dr-row`. */
.dr-bonus { display: flex; align-items: center; justify-content: center; gap: 6px;
  margin: 0 0 10px; font-size: 12px; font-weight: 700; color: var(--accent, #ffd447); }
.dr-bonus-ico { width: 20px; height: 20px; image-rendering: pixelated; flex: none; }
.dr-loot { list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-direction: column; gap: 4px; text-align: left; }
.dr-row { display: flex; align-items: center; gap: 8px; padding: 5px 8px;
  border: 2px solid var(--edge); background: rgba(0, 0, 0, 0.22); }
.dr-row .dr-icon { width: 28px; text-align: center; font-size: 18px; }
.dr-row .dr-sprite { width: 28px; height: 28px; image-rendering: pixelated; }
.dr-name { flex: 1; font-weight: 700; }
.dr-qty { font-weight: 800; font-variant-numeric: tabular-nums; color: #ffd447; }
/* A TM is the rare drop and the reason to go deep — it gets to look like one. */
.dr-row.tm { border-color: #b98cff; background: rgba(90, 60, 130, 0.35); }
.dr-row.tm .dr-qty { color: #d9c2ff; }
.dr-none { font-size: 12px; margin: 0 0 14px; }
.dr-collect { justify-content: center; width: 100%; }

/* ---------------- World boss session report ----------------
   Reuses the delve summary's grammar (.dr-title/.dr-stats/.dr-sec/.dr-loot/.dr-name/.dr-qty/
   .dr-sprite/.dr-none/.dr-collect) — it is the same kind of panel, a PvE instance's end-of-run
   payout. What it adds is a ROSTER: one row per raider, so it is wider and it scrolls. */
.boss-report { max-width: 560px; text-align: center; }
.br-list { max-height: 46vh; overflow-y: auto; }
.br-row { display: flex; flex-direction: column; gap: 4px; padding: 6px 8px;
  border: 2px solid var(--edge); background: rgba(0, 0, 0, 0.22); }
/* Your own row is the one you look for first in a table of thirty names. */
.br-row.mine { border-color: #f4d23c; background: rgba(110, 90, 20, 0.28); }
.br-head { display: flex; align-items: center; gap: 8px; }
.br-share { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
/* The share bar is the whole reason a damage table reads at a glance rather than being arithmetic
   the player has to do. Its width is the share the server already computed. */
.br-bar { height: 4px; background: rgba(0, 0, 0, 0.4); border: 1px solid var(--edge); }
.br-bar i { display: block; height: 100%; background: #e05d5d; }
.br-row.mine .br-bar i { background: #f4d23c; }
.br-moves { font-size: 11px; }
.br-moves summary { cursor: pointer; color: var(--muted); }
.br-move { display: flex; align-items: center; gap: 8px; padding: 2px 0 2px 12px; }
.br-move-name { flex: 1; }
.br-loot { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 11px; }
.br-item { display: inline-flex; align-items: center; gap: 4px; }
.br-item .dr-sprite { width: 20px; height: 20px; }


/* ---------------- 📊 Damage meter (HUD panel) ----------------
   Sits under .party-hud in the left rail and borrows its chrome deliberately: same width, same
   border language, so the two read as one column rather than as two unrelated widgets.

   The ROWS reuse .br-* from the world boss report above — same question, same table, and the
   report was already the one place this game draws a damage bar. Only the panel chrome and the
   tab strip are new; a second bar style would be two vocabularies for one idea.

   ⚠️ Only ever in the DOM while the panel is open (`:if={@meter_open}`), so there is no
   collapsed state to style and no hidden subtree to keep in step. */
.meter-hud { width: 300px; max-width: 100%;
  background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }
/* ONE line: glyph, the live total, and the two buttons. The panel's name is not rendered — it
   is a fixed string, and on a 300px rail it cost a whole line to repeat what the 📊 rail button
   already said. It lives on the region's `aria-label` instead. */
.meter-head { padding: 7px 10px; border-bottom: 2px solid var(--border);
  display: flex; align-items: center; gap: 6px; }
.meter-glyph { flex: 0 0 auto; font-size: 11px; line-height: 1; }
/* ⚠️ Tabular numerals: this is the header now and it repaints once a second — a proportional
   font makes the whole line jitter as digits change width. Ellipsis, because pt-BR and es are
   both longer than English here and the buttons must not be pushed off the panel. */
.meter-total { flex: 1; min-width: 0; font-size: 11px; color: var(--text);
  font-variant-numeric: tabular-nums;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meter-btn { border: 2px solid var(--edge); background: var(--panel-2); color: var(--text);
  cursor: pointer; padding: 0 6px; font-size: 12px; line-height: 20px; flex: 0 0 auto; }
/* "last fight": the table outlived the fight it counted and nothing in it will move again.
   ⚠️ `flex: 0 0 auto` — `.meter-total` is the line that ellipsises, and a chip allowed to shrink
   would be the thing that clipped instead of the numbers it is qualifying. The tab strip's 7px
   pixel font, because it is the same kind of label and this header is already the widest line
   in the panel. */
.meter-frozen { flex: 0 0 auto; font-family: var(--pixel); font-size: 7px; line-height: 1.4;
  color: var(--muted); border: 2px solid var(--border); padding: 2px 3px; white-space: nowrap; }
.meter-empty { color: var(--muted); font-size: 12px; margin: 0; padding: 10px 12px; }

.mtr-tabs { display: flex; border-bottom: 2px solid var(--border); }
.mtr-tab { flex: 1; border: none; border-right: 2px solid var(--border); background: none;
  color: var(--muted); cursor: pointer; padding: 6px 2px;
  font-family: var(--pixel); font-size: 7px; line-height: 1.4; }
.mtr-tab:last-child { border-right: none; }
.mtr-tab.is-on { color: var(--primary-2); background: rgba(255, 255, 255, 0.06); }
/* ⚠️ FIVE tabs in a raid, four everywhere else — the Threat tab is boss-only, so this strip has
   two widths and the four-tab one is the common case. `.mtr-tab`'s `flex: 1` already divides
   whatever is there, which is why nothing here sets a basis: a `flex-basis` tuned so five wrap
   to 3+2 wraps FOUR to 3+1, i.e. it would break every non-raid panel to tidy the raid one.
   At 300px a fifth is 60px, which the 7px pixel font clears for every label in all three
   locales; the padding comes in so the tightest ("Recebido", "Curación") keeps its side gap,
   and a label that ever did overflow wraps inside its own button rather than clipping. */
.mtr-tab { padding-left: 1px; padding-right: 1px; }
/* Who the boss is actually swinging at. Deliberately NOT the top row's marker — the pull margin
   is exactly what decouples "highest threat" from "currently being hit". */
.mtr-tgt { color: var(--danger); font-size: 8px; margin-left: 3px; }

/* Scrolls rather than growing: a farm field is up to eight wild species plus the lead, and a
   raid is thirty raiders. The rail must not become taller than the viewport. */
/* One combatant per ~18px line, WoW-Details style, with the share bar as the row's own
   background rather than a strip beneath it — that is the whole density gain.

   ⚠️ `.mtr-*`, NOT `.br-*`. Those are shared with the world-boss report modal and the delve
   loot list; restyling them here would silently change three other screens.

   ⚠️ `max-height` is multiplied by --ui-inv, per the token's own rule: a vh inside the zoomed
   HUD subtree is otherwise scaled twice. (It was a bare 38vh until this pass.) */
.mtr-list { max-height: calc(38vh * var(--ui-inv)); overflow-y: auto; padding: 4px;
  display: flex; flex-direction: column; gap: 2px; margin: 0; list-style: none; }
.mtr-item { border-left: 3px solid transparent; }
/* Your own row is the one you look for first. ⚠️ The BAR can no longer carry it — that colour
   is the Pokémon's type now — so identity moved to the edge and the background. Two channels,
   plus the name, which is what the house rule at the quality palette asks for. */
.mtr-item.mine { border-left-color: #f4d23c; background: rgba(110, 90, 20, 0.22); }

.mtr-row { position: relative; overflow: hidden; width: 100%;
  display: flex; align-items: center; gap: 6px; padding: 2px 6px;
  border: none; background: rgba(0, 0, 0, 0.28); color: var(--text);
  font-size: 11px; line-height: 15px; text-align: left; cursor: pointer;
  font-variant-numeric: tabular-nums; }
.mtr-row:hover { background: rgba(255, 255, 255, 0.06); }

/* ⚠️ NO `background` here, and that omission is load-bearing. The fill is coloured by the
   `.type-*` class beside it (`.type-fire { background: #ff9d55 }`, ~2,200 lines above); both
   are single-class selectors, so a colour declared here would win on source order and paint
   every row identically — with nothing erroring. Same specificity trap the Pokédex LCD block
   documents from the other direction. */
.mtr-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; opacity: 0.5; }
/* A row with no type — `:env`, i.e. damage nobody can be named for. */
.mtr-notype { background: var(--muted); }

/* Lifted above the fill, and shadowed: the text sits over an arbitrary type colour, so this is
   the same legibility device the hotbar cooldown overlay uses. */
.mtr-rank, .mtr-name, .mtr-num { position: relative; z-index: 1; text-shadow: 0 1px 2px #000; }
.mtr-rank { flex: 0 0 auto; color: var(--muted); }
.mtr-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mtr-trainer { color: var(--muted); font-size: 0.9em; }
.mtr-num { flex: 0 0 auto; font-weight: 800; }
.mtr-sub { font-weight: normal; color: rgba(234, 242, 255, 0.75); margin-left: 3px; }

/* The breakdown, borrowing the farm-history expander's grammar one panel over. */
.mtr-body { border-top: 1px solid var(--border); background: rgba(0, 0, 0, 0.35);
  padding: 5px 8px 6px; font-size: 10px; }
.mtr-head { color: var(--primary-2); font-family: var(--pixel); font-size: 7px;
  margin: 4px 0 2px; }
.mtr-head:first-child { margin-top: 0; }
.mtr-line { display: flex; align-items: center; gap: 8px; padding: 1px 0;
  font-variant-numeric: tabular-nums; }
.mtr-line-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
.mtr-line-num { flex: 0 0 auto; color: #ffd447; }
.mtr-stats { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px;
  color: var(--muted); font-variant-numeric: tabular-nums; }

/* Effect-uptime chips. ⚠️ Same 12px pixelated icon and the same `EFF_FILE` keys the nameplate
   and the party rail use, because a player should learn that vocabulary once. */
.meter-fx { display: flex; flex-wrap: wrap; gap: 3px; }
.meter-fx-chip { display: inline-flex; align-items: center; gap: 3px;
  padding: 1px 4px; background: rgba(255, 255, 255, 0.06); border: 1px solid var(--border);
  font-size: 10px; color: var(--text); }
.meter-fx-chip img { width: 12px; height: 12px; image-rendering: pixelated; }
.meter-fx-chip b { color: var(--muted); font-weight: normal; font-variant-numeric: tabular-nums; }

/* The frozen table folded into the delve's and the duel's existing result modals. Narrower and
   capped at eight rows: it is a summary inside somebody else's modal, not the panel. */
.meter-report { margin: 10px 0 4px; text-align: left; }
.meter-report-head { display: flex; align-items: baseline; gap: 8px; margin: 0 0 6px;
  font-family: var(--pixel); font-size: 8px; color: var(--primary-2); }
.meter-report .mtr-list { max-height: calc(26vh * var(--ui-inv)); padding: 0; }

/* The rail button reads as pressed while its panel is open — it is a toggle, unlike every other
   button up there, and without this nothing on screen says which state it is in. */
.hud-menu-btn.is-on { background: var(--panel-2); box-shadow: inset 0 0 0 2px var(--primary-2); }

/* Phones: same degrade-in-place the party rail does — keep the table, drop the chrome. */
@media (max-width: 640px) {
  .meter-hud { width: auto; max-width: calc(62vw * var(--ui-inv)); }
  /* ⚠️ `.meter-total` used to be hidden here — it was a subtitle beside the panel's name. It is
     the header's ONLY text now, so hiding it leaves an empty bar with two buttons in it. */
  .mtr-list { max-height: calc(30vh * var(--ui-inv)); }
}

/* ---------------- WoW-style target frame ---------------- */
/* ⚠️ It no longer anchors itself: it is an item of `.hud-center`, which owns the band and the
   order. It used to be `position: fixed; top: 118px`, 22px under a `.buffs-hud` that wraps. */
.target-frame {
  display: flex; gap: 8px; align-items: center; padding: 6px 10px; min-width: 210px; max-width: 320px;
  background: rgba(6, 12, 22, .85); border: 2px solid #2b3b55; border-radius: 6px;
  pointer-events: auto; /* right-click opens the target's context menu */ }
/* display:flex above beats the UA's [hidden]{display:none} — restore it (no target → no frame) */
.target-frame[hidden] { display: none; }
.target-frame.enemy { border-color: #ce4069; }
.target-frame.npc { border-color: #ffd447; }
.target-frame.peer { border-color: #5aa9ff; }
.tf-portrait { width: 40px; height: 40px; image-rendering: pixelated; flex: 0 0 auto; }
.tf-main { flex: 1; min-width: 0; }
.tf-top { display: flex; gap: 6px; align-items: baseline; margin-bottom: 3px; }
.tf-name { font-weight: 800; font-size: 13px; color: #eaf2ff; text-transform: capitalize;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tf-lvl { font-size: 11px; color: #8fa3c4; flex: 0 0 auto; }
.tf-status { font-size: 12px; flex: 0 0 auto; }
.tf-hpbar { height: 7px; background: #000; border-radius: 3px; overflow: hidden; }
.tf-hpfill { display: block; height: 100%; width: 100%; background: #35c46a; transition: width .15s; }
.tf-hpfill.mid { background: #f5c542; }
.tf-hpfill.low { background: #e5533c; }
.tf-hpnum { font-size: 10px; color: #8fa3c4; margin-top: 2px; font-variant-numeric: tabular-nums; }
/* Target-of-target — "who is my target hitting". A row of its own rather than a suffix on
   `.tf-name`, because it changes several times a fight and a name that grows and shrinks
   reflows the whole frame under the cursor that is hovering it. */
.tf-tot { display: flex; gap: 4px; align-items: baseline; margin-top: 2px;
  font-size: 10px; color: #8fa3c4; overflow: hidden; }
/* display:flex above beats the UA's [hidden]{display:none} — the same restore `.target-frame`
   needs. Forgetting it leaves an empty row permanently visible under every target. */
.tf-tot[hidden] { display: none; }
.tf-tot::before { content: "\2192"; color: #5a6b86; flex: 0 0 auto; }
.tf-tot-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  text-transform: capitalize; }
/* 🔴 It is coming for YOU. This row exists for this one state and everything else it can say
   is context. The same red as the raid's ground marker (#ff4d4d), so the two cues read as one
   warning rather than as two unrelated things that happen to be red. */
.tf-tot.is-you .tf-tot-name, .tf-tot.is-you::before { color: #ff4d4d; font-weight: 800; }
/* Your Substitute is eating it — good news, and deliberately not the alarm colour. */
.tf-tot.is-decoy .tf-tot-name { color: #ffd447; }
/* The frame's border follows the same faction vocabulary the nameplates now use. */
.target-frame.ally { border-color: #59c135; }
@media (max-width: 720px) { .target-frame { min-width: 170px; } }
/* Phones drop the frame entirely — under `.team-hud` it costs a band of screen a phone does
   not have. The target is still shown by the ring under its sprite, and a long-press on the
   canvas opens the same context menu the frame's right-click does. Gated like the mobile
   reflow (`body.touchctl` + small), plus a short-height arm so a phone in LANDSCAPE, which is
   wider than 820px, is caught too. The element stays in the DOM and `updateTargetFrame` keeps
   filling it, so this rule is the whole feature. */
@media (max-width: 820px), (max-height: 500px) {
  body.touchctl .target-frame { display: none; }
}

/* ---------------- right-click context menu ---------------- */
/* z-index 210: above .game-modal-backdrop (100) and the dragging override (200) */
.ctx-menu { position: fixed; z-index: 210; min-width: 140px; background: var(--bg);
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 4px; }
.ctx-item { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; background: transparent; border: 0;
  color: var(--text); font-family: var(--pixel); font-size: 9px; padding: 8px 10px; cursor: pointer; }
.ctx-item img { width: 20px; height: 20px; flex: 0 0 auto; image-rendering: pixelated; }
.ctx-item:hover { background: var(--panel-2); }
.ctx-item.disabled { color: var(--muted); opacity: 0.55; cursor: default; }
.ctx-item.disabled:hover { background: transparent; }
.ctx-card { max-width: 300px; padding: 10px 12px; font-size: 12px; }
.ctx-card b { display: block; margin-bottom: 2px; font-size: 13px; }
.ctx-card .ctx-role { display: block; margin-bottom: 6px; font-size: 8px; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--accent, #d9a441); font-family: var(--pixel); }
.ctx-card p { margin: 0; color: var(--muted, #8fa3c4); line-height: 1.5; }

/* ---------------- inspect modal (right-click → Inspect on a player) ---------------- */
.inspect-ranks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.inspect-team { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.inspect-mon-head { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.inspect-mon-head img { width: 48px; height: 48px; image-rendering: pixelated; }
/* Trainer lore. `pre-wrap` is the ONLY thing turning the stored \n into a visible break --
   the server never generates <br> and the template never calls raw/1, so this rule is load
   bearing rather than cosmetic. `overflow-wrap: anywhere` stops a 1000-character unbroken
   "word" widening the modal, and the height cap keeps a long bio from pushing the team out
   of view; between them the box contains whatever a player types, which is the point. */
.inspect-lore { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 220px; overflow-y: auto; line-height: 1.55; font-size: 13px; color: var(--text); }
@media (max-width: 720px) { .inspect-ranks { grid-template-columns: 1fr; } }
/* Inspect tabs reuse the standard .tabs/.tab chrome; only the spacing is local. */
.inspect-tabs { margin: 12px 0 14px; }
/* Lifetime trainer-fact tiles on the Overview tab. Each tile hides itself when its key
   is absent (ambience bots, older game_service), so the grid packs whatever survives. */
.inspect-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.inspect-fact { border: 2px solid var(--border); background: var(--panel); padding: 8px 10px; font-size: 13px; }
.inspect-fact .lbl { display: block; color: var(--muted); font-size: 11px; font-weight: 600; margin-bottom: 3px; }
.inspect-fact b { font-variant-numeric: tabular-nums; }
.inspect-fact-medals .medal-tally { margin-right: 4px; }

/* "connected elsewhere" kick screen — z 300: above every modal/drag layer */
.kicked-overlay { position: fixed; inset: 0; z-index: 300; background: rgba(6, 10, 16, 0.88);
  display: flex; align-items: center; justify-content: center; }
.kicked-box { max-width: 420px; text-align: center; background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 28px 32px; }
.kicked-box h2 { margin: 0 0 10px; color: #ffd447; }
.kicked-box p { margin: 0 0 18px; color: #bfd2e8; line-height: 1.5; }
.kicked-box button { font: inherit; font-weight: 700; cursor: pointer; padding: 10px 22px;
  background: #ffd447; color: #1a1400; border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.25); }
.kicked-box button:hover { filter: brightness(1.08); }

/* Exit-instance confirmation. Same box chrome as .kicked-box, but z-index 320: it sits
   above the HUD door that opened it AND above the full-screen farm/arena overlay. */
.exit-ask-overlay { position: fixed; inset: 0; z-index: 320; background: rgba(6, 10, 16, 0.78);
  display: flex; align-items: center; justify-content: center; }
.exit-ask-box { max-width: 420px; text-align: center; background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 24px 28px; }
.exit-ask-box h2 { margin: 0 0 10px; color: #ffd447; }
.exit-ask-box p { margin: 0 0 18px; color: #bfd2e8; line-height: 1.5; }
.exit-ask-actions { display: flex; gap: 12px; justify-content: center; }
.exit-ask-actions button { font: inherit; font-weight: 700; cursor: pointer; padding: 10px 22px;
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.25); }
.exit-ask-actions button:hover { filter: brightness(1.08); }
/* Staying is the safe answer, so it gets the neutral treatment and leaving gets the
   danger red — the reverse of "confirm is the bright button" muscle memory, on purpose. */
.exit-ask-no { background: #24304a; color: #dce6f5; }
.exit-ask-yes { background: #ce4069; color: #fff2f6; }
/* ...and the sleep prompt's confirm is calm indigo rather than danger red: it is the one
   "leave the screen" action that KEEPS the hunt running. */
.sleep-ask-yes { background: #3b4b86; color: #e8eeff; }

/* ---------------- 💤 sleep mode ----------------
   Full-viewport cinematic over the camp key art, same construction as the creation
   wizard (.wizard-bg / .wizard-scrim) but it fades in over the running game instead of
   replacing a route. z-index 340 clears .exit-ask-overlay (320), which already clears
   the HUD door and the farm overlay. */
.sleep-screen { position: fixed; inset: 0; z-index: 340; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  background: #05070d; animation: sleep-veil 0.45s ease-out both; }
/* the art and the panel arrive AFTER the black has landed — that ordering is the whole
   "fade to black, then you're asleep" beat */
.sleep-bg { position: absolute; inset: 0; background-size: cover; background-position: center;
  image-rendering: pixelated; background-image: url("/assets/game/ui/sleep/camp.png");
  animation: sleep-reveal 1.1s ease-out 0.45s both; }
.sleep-scrim { position: absolute; inset: 0;
  background: radial-gradient(ellipse at 50% 55%, rgba(5, 7, 13, 0.15) 0%, rgba(5, 7, 13, 0.82) 100%);
  animation: sleep-reveal 1.1s ease-out 0.45s both; }
.sleep-panel { position: relative; z-index: 1; width: min(520px, calc(100% - 32px));
  text-align: center; background: rgba(10, 16, 28, 0.92); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 24px 28px;
  animation: sleep-rise 0.7s ease-out 0.9s both; }
.sleep-panel h2 { margin: 0 0 14px; color: #ffd447; }
.sleep-panel p { margin: 0 0 10px; color: #cfdcee; line-height: 1.55; }
.sleep-fine { font-size: 12px; color: #8ea3bd !important; margin-top: 14px !important; }
.sleep-wake { margin-top: 18px; }

/* Three Z's drifting up off the sleeping creature, staggered so they trail. Positioned
   over its head, which the art puts right of centre — and kept in the top band, the one
   part of the frame `cover` never crops away at any aspect ratio. */
.sleep-zs { position: absolute; left: 58%; top: 7%; z-index: 1; pointer-events: none;
  animation: sleep-reveal 1.1s ease-out 0.9s both; }
.sleep-z { position: absolute; font-weight: 700; color: #cfe3ff;
  text-shadow: 2px 2px 0 #0b1424; animation: sleep-float 3.6s ease-in-out infinite; }
.sleep-z:nth-child(1) { left: 0; font-size: 18px; animation-delay: 0s; }
.sleep-z:nth-child(2) { left: 18px; font-size: 24px; animation-delay: 0.7s; }
.sleep-z:nth-child(3) { left: 42px; font-size: 32px; animation-delay: 1.4s; }

@keyframes sleep-veil { from { opacity: 0; } to { opacity: 1; } }
@keyframes sleep-reveal { from { opacity: 0; } to { opacity: 1; } }
@keyframes sleep-rise {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}
@keyframes sleep-float {
  0% { opacity: 0; transform: translate(0, 10px) scale(0.85); }
  25% { opacity: 1; }
  100% { opacity: 0; transform: translate(26px, -46px) scale(1.1); }
}
/* A screen whose entire job is "you are going to sleep" is exactly the wrong place to
   insist on motion. Cut straight to the composed frame instead. */
@media (prefers-reduced-motion: reduce) {
  .sleep-screen, .sleep-bg, .sleep-scrim, .sleep-panel, .sleep-zs { animation: none; }
  .sleep-z { animation: none; opacity: 0.9; }
}

/* ---------------- Team Rocket Casino ---------------- */
.casino .panel { margin-bottom: 12px; }
.casino-reels { display: flex; gap: 10px; justify-content: center; margin: 14px 0; }
.casino-reel { width: 84px; height: 84px; display: flex; align-items: center; justify-content: center;
  font-size: 44px; background: var(--panel-2, #131c2b); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }
.casino-reel.spin { animation: casino-spin 0.25s linear infinite; filter: blur(1px); }
@keyframes casino-spin {
  0% { transform: translateY(0); }
  50% { transform: translateY(-4px); }
  100% { transform: translateY(0); }
}
.casino-win-banner { text-align: center; margin: 10px 0; padding: 8px; font-weight: 700; color: #ffd447;
  background: rgba(255, 212, 71, 0.08); border: 2px solid rgba(255, 212, 71, 0.4);
  text-shadow: 0 0 8px rgba(255, 212, 71, 0.45); }
.casino-win-banner.lose { color: #ce4069; background: rgba(206, 64, 105, 0.08);
  border-color: rgba(206, 64, 105, 0.4); text-shadow: none; }
/* The player's own blackjack record under the rules line. Muted when they are down — never red:
   the point of showing it is that "the NPC always wins" is not true, and a red line would say it is. */
.bj-record { font-size: 12px; margin: -4px 0 10px; color: var(--muted); }
.bj-record.up { color: var(--green); }
.bj-table { background: var(--panel-2, #131c2b); border: 2px solid var(--border); padding: 12px; margin-bottom: 8px; }
.bj-card { display: inline-flex; align-items: center; justify-content: center; min-width: 42px; height: 58px;
  padding: 0 6px; font-size: 18px; font-weight: 700; color: #1a2233; background: #e9edf5;
  border: 2px solid var(--edge); box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35); }
.bj-card.red { color: #c0392b; }
.bj-card.back { background: #35155e; color: #7e57c2; font-size: 30px; }
.dice-face { width: 64px; height: 64px; display: flex; align-items: center; justify-content: center;
  font-size: 52px; line-height: 1; background: var(--panel-2, #131c2b); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border); }
.dice-face.shake { animation: dice-shake 0.15s linear infinite; }
@keyframes dice-shake {
  0% { transform: rotate(-6deg); }
  50% { transform: rotate(6deg); }
  100% { transform: rotate(-6deg); }
}

/* ---- Blackjack table (CasinoLive `blackjack_tab/1`) ----
   A 224x136 canvas the `BlackjackTable` hook paints pixel by pixel, shown at exactly 2x (a whole
   multiple, or nearest-neighbour makes uneven pixel rows). */
.bj-stage { display: flex; justify-content: center; margin: 4px 0 8px; }
.bj-canvas { width: 448px; height: 272px; image-rendering: pixelated; image-rendering: crisp-edges; }
.bj-actions { display: flex; gap: 8px; justify-content: center; margin-top: 4px; }
@media (max-width: 500px) { .bj-canvas { width: 224px; height: 136px; } }

/* ---- Dice table (CasinoLive `dice_tab/1`) ----
   A 176x104 canvas the `DiceTable` hook paints pixel by pixel, shown at exactly 2x (a whole
   multiple, or nearest-neighbour makes uneven pixel rows). */
.dice-stage { display: flex; justify-content: center; margin: 6px 0 10px; }
.dice-table { width: 352px; height: 208px; image-rendering: pixelated; image-rendering: crisp-edges; }
@media (max-width: 420px) { .dice-table { width: 176px; height: 104px; } }

/* ---- Slot machine (CasinoLive `slots_tab/1`) ----
   A 160x112 canvas the `SlotMachine` hook paints pixel by pixel, shown at exactly 2x. Keep the
   display size a WHOLE multiple of the canvas, or nearest-neighbour makes uneven pixel rows. */
.slots-stage { display: flex; justify-content: center; margin: 6px 0 10px; }
.slot-machine { width: 320px; height: 224px; image-rendering: pixelated; image-rendering: crisp-edges; }
.slot-sym { width: 16px; height: 16px; image-rendering: pixelated; image-rendering: crisp-edges;
  vertical-align: middle; }
.slots-pay { display: inline-flex; align-items: center; gap: 2px; }
@media (max-width: 400px) { .slot-machine { width: 160px; height: 112px; } }

/* ---- Roulette (CasinoLive `roulette_tab/1`) ----
   The wheel is a 112px canvas the `RouletteWheel` hook paints pixel by pixel; it is scaled up here
   with `pixelated`, never smoothed, so it stays pixel art. 224px is exactly 2x — keep the display
   size a WHOLE multiple of 112, or nearest-neighbour scaling makes uneven pixel rows. */
.roulette-stage { position: relative; display: flex; justify-content: center; margin: 6px 0 12px; }
.roulette-wheel { width: 224px; height: 224px; image-rendering: pixelated; image-rendering: crisp-edges; }
.roulette-plate { position: absolute; right: calc(50% - 168px); top: 8px; min-width: 56px; padding: 6px 10px;
  text-align: center; font-family: var(--pixel); font-size: 20px; color: #fff;
  border: 3px solid #d4b55b; box-shadow: 0 2px 0 rgba(0, 0, 0, 0.45); }
.roulette-plate.red { background: #a62322; }
.roulette-plate.black { background: #221918; }
.roulette-plate.green { background: #207244; }
.roulette-outside { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin-bottom: 6px; }
.roulette-dozens { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 8px; }
.roulette-outside .btn, .roulette-dozens .btn { padding: 8px 6px; text-align: center; }
.roulette-outside .btn.rl-red:not(.primary) { border-color: #a62322; }
.roulette-outside .btn.rl-black:not(.primary) { border-color: #4a3c3a; }
/* Zero on the left spanning all three rows, then the 12x3 number board — the real table. */
.roulette-grid { display: grid; grid-template-columns: 34px repeat(12, 1fr); grid-auto-rows: 30px; gap: 3px; }
.roulette-grid .zero { grid-row: span 3; }
.rl-num { border: 2px solid #b49143; color: #fff; font-family: var(--pixel); font-size: 10px; cursor: pointer;
  padding: 0; }
.rl-num.red { background: #a62322; }
.rl-num.black { background: #221918; }
.rl-num.green { background: #207244; }
.rl-num.on { outline: 3px solid #ffd447; outline-offset: -1px; }
.rl-num:disabled { opacity: 0.55; cursor: default; }
@media (max-width: 560px) {
  .roulette-outside { grid-template-columns: repeat(3, 1fr); }
  .roulette-grid { grid-template-columns: 26px repeat(12, 1fr); grid-auto-rows: 26px; gap: 2px; }
  .rl-num { font-size: 8px; }
  .roulette-plate { right: 8px; }
}

/* ============ In-game handheld Pokédex (embedded only) ============ */
/* Shaped like the original handheld: the screen across the FULL width, and a control deck
   below it — D-pad on the left, A/B on the right, SELECT/START angled at the centre, speaker
   grille in the bottom-right corner.

   ⚠️ The controls used to FLANK the screen (`"dpad screen abxy"`), which cost the display
   ~220px of width — a D-pad, an A/B cluster and two gaps — on a modal capped at 860px, i.e.
   about a quarter of it, to draw chrome. The dex is a list beside a detail pane, so width is
   the one dimension it actually needs; vertical space it has (the screen is a fixed 460px
   inside a 92vh modal). */
.gbc {
  --lcd-bg: #14261a; --lcd-bg2: #10201a; --lcd: #d7f7c8; --lcd-dim: #7fb488;
  --lcd-line: #2f5238; --gold: #f5d442; --lcd-sel: #22371f;
  /* body greys and the two accents the deck labels are drawn in */
  --gb-face: #e4e4dc; --gb-face2: #d3d3ca; --gb-ink: #55565f; --gb-slot: rgba(0,0,0,.3);
  display: grid; grid-template-columns: 1fr; grid-template-areas: "screen" "deck";
  gap: 12px; max-width: 960px; margin: 0 auto; padding: 20px 20px 14px;
  /* ⚠️ One light body, not the old two-tone with a red lower half. That gradient was a diagonal
     split sized for controls sitting beside the screen; with a deck underneath it painted the
     deck red, which the device it is dressed as does not do. */
  background: linear-gradient(180deg, var(--gb-face) 0%, #dcdcd3 58%, var(--gb-face2) 100%);
  border: 4px solid var(--edge);
  /* The one asymmetric corner is the handheld's signature: bottom-right, around the speaker. */
  border-radius: 20px 20px 40px 20px;
  box-shadow: inset 0 0 0 3px #efefe9, inset 0 -12px 26px rgba(0,0,0,.16), var(--shadow);
  font-family: var(--pixel);
}

/* ---- the control deck ----
   TWO rows, which is what makes this read as the real layout rather than three clusters in a
   line: the D-pad and A/B share the upper row, SELECT/START and the speaker the lower one.

   ⚠️ FOUR equal columns, and the spans are the whole reason. The pills must sit centred on the
   body's midline, and neither obvious grid gets that: two columns can only put them at the
   midpoint with `justify-self: end`, which leaves the whole pair to the LEFT of centre, and
   `1fr auto 1fr` with the pills in the middle track sizes that track to the pills — so on a
   phone the upper row needed 92 + 120 + 94px and the clusters were pushed off the body. With
   four equal tracks the D-pad spans 1-2, A/B spans 3-4 and the pills span 2-3, which is
   symmetric about the midline by construction; no track is sized by the pills alone, so the
   deck's floor stays ~240px and it fits a 320px screen. */
.gbc-deck { grid-area: deck; display: grid; grid-template-columns: repeat(4, 1fr);
  grid-template-areas: "dpad dpad abxy abxy" ".  startsel startsel speaker";
  align-items: center; row-gap: 2px; column-gap: 10px; }

/* D-pad — centred in its half of the deck, so it sits ON the deck rather than against the
   body's left edge. */
.gbc-dpad { grid-area: dpad; position: relative; width: 92px; height: 92px;
  align-self: center; justify-self: center; }
/* ⚠️ The CROSS is the two bars; the four buttons are TRANSPARENT hit areas over it. They used to
   carry their own fill and border, which drew the D-pad as four separate keys with a seam down
   every arm — the device's is one moulded piece. Hover/active tint the arm instead, so the
   affordance survives losing the outline. */
.gbc-d.hbar, .gbc-d.vbar { position: absolute; background: #2a2b2f; border: 2px solid var(--edge); }
.gbc-d.hbar { top: 33px; left: 4px; right: 4px; height: 26px; }
.gbc-d.vbar { left: 33px; top: 4px; bottom: 4px; width: 26px; }
.gbc-d.up, .gbc-d.down { position: absolute; left: 33px; width: 26px; height: 32px; }
.gbc-d.up { top: 4px; } .gbc-d.down { bottom: 4px; }
.gbc-d.up, .gbc-d.down, .gbc-d.left, .gbc-d.right { background: transparent; border: 0;
  color: #b9bdc4; cursor: pointer; font-size: 9px; line-height: 1;
  display: flex; align-items: center; justify-content: center; z-index: 2; }
.gbc-d.up:hover, .gbc-d.down:hover, .gbc-d.left:hover, .gbc-d.right:hover {
  background: rgba(255,255,255,.12); color: #fff; }
.gbc-d.up:active, .gbc-d.down:active, .gbc-d.left:active, .gbc-d.right:active {
  background: rgba(0,0,0,.35); }

/* A/B — B left and low, A right and high, the device's diagonal. DOM order is B,A so the
   diagonal is source order too; it was `row-reverse` while the letters lived inside the
   buttons, which no longer holds now each button carries its own label. */
.gbc-abxy { grid-area: abxy; display: flex; gap: 14px; align-items: center;
  align-self: center; justify-self: center; }
/* ⚠️ The letter is printed on the BODY under the button, not inside it — the buttons on the
   device are blank. So the transform that raises A belongs to the WRAPPER: on the button it
   would leave A's label behind at B's height. */
.gbc-key { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.gbc-key.a { transform: translateY(-12px); }
.gbc-key b { font-size: 7px; font-weight: 400; color: var(--gb-ink); letter-spacing: 1px; }
.gbc-btn { width: 40px; height: 40px; border-radius: 50%; border: 3px solid var(--edge);
  background: radial-gradient(circle at 35% 30%, #d2477e, #8d2049);
  box-shadow: 0 3px 0 rgba(0,0,0,.4); }

/* SELECT / START — angled pills with their labels angled under them, centred on the body's
   midline (they span the deck's middle two tracks; see the note above for why that is what
   centres them). START rides a few px lower than SELECT, as it does on the device. */
.gbc-startsel { grid-area: startsel; display: flex; gap: 14px; align-items: center;
  justify-self: center; align-self: center; min-width: 0; }
.gbc-ss { display: flex; flex-direction: column; align-items: center; gap: 2px;
  transform: rotate(-22deg); }
.gbc-ss + .gbc-ss { transform: rotate(-22deg) translateY(7px); }
.gbc-ss b { font-size: 6.5px; font-weight: 400; color: var(--gb-ink); letter-spacing: .6px; }
.gbc-pill { width: 34px; height: 10px; border-radius: 6px; background: #9a9ea6;
  border: 2px solid var(--edge); box-shadow: 0 2px 0 rgba(0,0,0,.28); }

/* Speaker — six slots raked to the right, in the corner the big border radius curves around.
   The rotation is a transform, so it does not enter layout: the cell is sized by the upright
   44x30 box and the rotated corners spill harmlessly into the body's own padding. */
.gbc-speaker { grid-area: speaker; justify-self: end; align-self: center;
  display: flex; gap: 5px; width: 52px; height: 34px; margin: 2px 4px 4px 0;
  transform: rotate(-26deg); }
.gbc-speaker i { flex: 1; border-radius: 2px; background: var(--gb-slot);
  box-shadow: inset -1px 0 0 rgba(255,255,255,.35); }

/* Green-LCD screen */
/* The bezel — a step darker than the body, not the near-black it used to be: on the device the
   screen surround is mid-grey and the LCD reads as a lit panel inside it. `.pdx` keeps its own
   2px `#0a140d` border, which is what separates the green from the grey.
   ⚠️ `min-width: 0`: this spans the body's full width and holds a percentage grid, so without it
   its content can push the grid wider than the body it is drawn inside. */
.gbc-screen { grid-area: screen; min-width: 0; display: flex; flex-direction: column;
  background: #82848e; border: 4px solid var(--edge); border-radius: 10px;
  box-shadow: inset 0 0 0 3px #6d6f79, inset 0 0 22px rgba(0,0,0,.35); padding: 8px 10px 10px; }

/* The silkscreen strip above the LCD: power LED, the two accent rules and a caption.
   ⚠️ The caption is deliberately OUR words, not the hardware's own printed line — the shape is
   the reference here, not the manufacturer's markings. Canonical English, like the rest of the
   device's markings and like game data; it is not an i18n key. */
.gbc-bezel { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  gap: 8px; padding: 0 2px 7px; }
.gbc-bezel > span:first-child { display: flex; align-items: center; gap: 6px; }
/* ⚠️ `flex-end`, or the right rule sits at the LEFT of its cell and floats mid-bezel: the stripe
   is `flex: 1` under a `max-width`, so it never fills the track and the default `flex-start`
   parks it against the caption. The left cell needs no equivalent — its rule is already pinned
   between the LED and the caption. */
.gbc-bezel > span:last-child { display: flex; justify-content: flex-end; }
.gbc-led { width: 7px; height: 7px; border-radius: 50%; flex: none;
  background: radial-gradient(circle at 35% 30%, #ff6b6b, #8e1f26);
  box-shadow: 0 0 4px rgba(255,80,80,.5), inset 0 0 0 1px rgba(0,0,0,.45); }
/* Two rules in one element rather than two nodes — the gradient's hard stops ARE the pair. */
.gbc-stripe { flex: 1; height: 7px; max-width: 110px;
  background: linear-gradient(180deg, #3b2f7a 0 45%, transparent 45% 55%, #a3286d 55% 100%); }
.gbc-bezel-txt { font-size: 7px; letter-spacing: .8px; color: #5a2440; white-space: nowrap; }
/* ⚠️ The cap SUBTRACTS the chrome; it is not a `vh` fraction, and the difference is the whole
   point. Moving the controls under the screen added the deck's two rows, the bezel silkscreen
   and two gaps — **measured** at 232px inside `.gbc`, plus `.game-modal`'s own 50px of vertical
   padding, hence 290. A fraction cannot express that: the chrome is a fixed height, so any `vh`
   guess is either wasteful on a tall window or overflows `.game-modal`'s 92vh on a short one —
   and overflowing means the modal scrolls, i.e. a handheld whose D-pad you have to scroll to
   reach. Measured after any change to the deck or the bezel; `max()` keeps a very short window
   from computing a useless sliver (it scrolls there instead). On any viewport with room the
   fixed 460px binds and none of this applies. Width is what the reorientation bought; this is
   what it cost. */
.pdx { background: var(--lcd-bg); border: 2px solid #0a140d; color: var(--lcd);
  display: flex; flex-direction: column; height: 460px;
  max-height: max(220px, calc(92vh * var(--ui-inv) - 290px));
  image-rendering: pixelated; font-size: 11px; }
.pdx-head { display: flex; align-items: center; gap: 8px; padding: 7px 10px;
  border-bottom: 2px solid var(--lcd-line); color: var(--gold); }
.pdx-ball { width: 13px; height: 13px; border-radius: 50%;
  background: linear-gradient(#e34b4b 0 50%, #eef2ec 50% 100%); border: 1.5px solid #0a140d; box-shadow: inset 0 0 0 1px #0a140d; }
.pdx-title { font-size: 12px; letter-spacing: 1px; flex: 1; }
.pdx-head-icons { color: var(--lcd-dim); font-size: 10px; letter-spacing: 3px; }
.pdx-tools { display: flex; gap: 5px; padding: 7px 10px; border-bottom: 2px solid var(--lcd-line); }
.pdx-tab { font-family: var(--pixel); font-size: 8px; color: var(--lcd-dim);
  background: transparent; border: 2px solid var(--lcd-line); padding: 4px 6px; cursor: pointer; }
.pdx-tab.on { color: var(--lcd-bg); background: var(--gold); border-color: var(--gold); }
.pdx-search { flex: 1; min-width: 0; font-family: var(--pixel); font-size: 8px;
  color: var(--lcd); background: var(--lcd-bg2); border: 2px solid var(--lcd-line); padding: 4px 6px; }
.pdx-search::placeholder { color: var(--lcd-dim); }

/* ⚠️ Not `44% 56%` any more. A percentage list grows with the screen, and the extra width the
   bottom-deck layout freed up is width the DETAIL pane wants — the list is a 30px sprite, a
   number and a name and is done at ~220px. `minmax` rather than a flat cap so the list still
   yields on a narrow screen instead of squeezing the detail to nothing. */
.pdx-body { flex: 1; display: grid; grid-template-columns: minmax(160px, 30%) minmax(0, 1fr);
  min-height: 0; }
.pdx-list { overflow-y: auto; border-right: 2px solid var(--lcd-line); padding: 8px 6px; }
/* The Pokédex is a Game Boy screen with its own palette, so it opts OUT of the global navy
   scrollbar — scoped to `.gbc` rather than `.pdx-list` so the detail pane (which also
   scrolls) stays green too, instead of being the one navy bar on a green display. */
.gbc { scrollbar-color: var(--lcd-line) transparent; }
.gbc ::-webkit-scrollbar-thumb { background: var(--lcd-line); }
.gbc ::-webkit-scrollbar-thumb:hover { background: var(--lcd-dim); }
/* A GRID, not a flex row, so the dex number sits ABOVE the name and the name gets the whole
   remaining width instead of sharing it — which is what stops a longer species name ellipsizing
   in a column the deck-layout change already narrowed. The sprite spans both lines.
   ⚠️ Done in CSS with `grid-template-areas` rather than by wrapping the two spans in a column:
   the number and the name are the row's only text and nothing else keys off their nesting, so
   a wrapper would be markup carrying no information the layout does not already state.
   ⚠️ Row height is unchanged — the stacked text is ~19px against the sprite's 30px, so the
   sprite still sets it and the list holds the same number of visible entries. */
.pdx-row { display: grid; grid-template-columns: 30px minmax(0, 1fr);
  grid-template-areas: "spr no" "spr name";
  align-items: center; column-gap: 8px; row-gap: 1px; width: 100%; text-align: left;
  font-family: var(--pixel); font-size: 10px; color: var(--lcd);
  background: var(--lcd-bg2); border: 2px solid transparent; padding: 6px 7px; margin-bottom: 6px; cursor: pointer; }
.pdx-row:hover { border-color: var(--lcd-line); }
.pdx-row.sel { border-color: var(--gold); background: var(--lcd-sel); box-shadow: inset 0 0 0 1px var(--gold); }
.pdx-row-spr { grid-area: spr; align-self: center; width: 30px; height: 30px;
  image-rendering: pixelated; }
/* 7px, a step down from the name it now sits over: the number labels the row, it is not the row. */
.pdx-row-no { grid-area: no; color: var(--lcd-dim); font-size: 7px; }
/* ⚠️ `overflow: hidden` is what zeroes this item's automatic minimum size, which is what lets
   the `minmax(0, 1fr)` track shrink and the ellipsis fire at all. It was already here for the
   flex layout and does the same job in grid — do not drop it as redundant with the track. */
.pdx-row-name { grid-area: name; text-transform: uppercase; letter-spacing: .5px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pdx-empty { color: var(--lcd-dim); text-align: center; padding: 20px; font-size: 9px; }

.pdx-detail { display: flex; flex-direction: column; padding: 10px; gap: 10px; overflow-y: auto; }
.pdx-card { display: grid; grid-template-columns: 108px 1fr; gap: 10px; }
.pdx-sprite { background: #2b4a2f; border: 3px solid var(--gold); box-shadow: inset 0 0 0 2px #0a140d;
  aspect-ratio: 1; display: grid; place-items: center; }
.pdx-sprite img { width: 88px; height: 88px; image-rendering: pixelated; }
.pdx-meta { display: flex; flex-direction: column; gap: 3px; font-size: 10px; min-width: 0; }
/* Per-species stats, one chip each. ⚠️ `nowrap` on the chip is the point: `.pdx-meta` is the
   `1fr` half of a 108px grid with `min-width: 0`, so it is genuinely narrow, and without this
   the text breaks INSIDE a chip — "⚔️ 563 (✨" on one line and "1)" on the next. The container
   already wraps between chips, which is the only break that ever reads correctly here. */
/* One stat per line: icon then number, stacked. Was a wrapping flex ROW, which crammed all four
   into a 10px strip and broke mid-number the moment the pane got tight — `.pdx-meta` is the 1fr
   half of a 108px grid with `min-width: 0`, so it is genuinely narrow. Stacking removes the
   squeeze entirely; the `nowrap` below is what keeps each line atomic. */
.pdx-mine { display: flex; flex-direction: column; gap: 2px; font-size: 10px; margin-top: 5px; }
/* ⚠️ NOT `.pdx-stat` — that is already the base-stat bar row further down this file, and it is
   declared LATER, so a same-name rule here loses its `display` to it. Same single-class
   source-order trap `.mon.gtip` / `.dcell.gtip` document above. */
.pdx-mine-row { display: flex; align-items: center; gap: 3px; white-space: nowrap; }

/* The Pokédex stat icons. ⚠️ 16px, 1:1 with the source art — these are drawn at 16x16 on
   purpose (see tools/sprites/make_dex_icons.py) and any other size resamples pixel art.
   `pixelated` is belt-and-braces for a browser that ignores the 1:1. */
.dex-ico { width: 16px; height: 16px; image-rendering: pixelated; flex: none;
  vertical-align: -3px; }
/* The shiny subset rides inside the parentheses, so it must not break away from its number. */
.dex-shiny { white-space: nowrap; display: inline-flex; align-items: center; gap: 1px; }
/* The site modal's rows are a plain <dd>, not a flex chip — line them up the same way. */
.kv dd .dex-ico { margin-right: 3px; }
.kv dd { display: flex; align-items: center; gap: 2px; }

/* ================= Pokédex sections — ONE markup, two skins ==================
   Everything in this block is the SITE look. The handheld re-skins the identical DOM in the
   `.pdx .dex-*` block further down: `.pdx` + `.dex-x` is (0,2,0) against these (0,1,0), so the
   LCD wins on specificity regardless of source order. Never fork the HEEx. */

.dex-sub { margin: 14px 0 6px; font-size: 12px; color: var(--muted);
  text-transform: uppercase; letter-spacing: .05em; }
.dex-hint { font-size: 12px; margin: 0 0 8px; }
/* ⚠️ NOT `.kv` — line 2597 above makes every `.kv dd` a nowrap flex row for the dex stat icons,
   and a seven-entry egg-group list would overflow it rather than wrap. */
.dex-kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; font-size: 14.5px; }
.dex-kv dt { color: var(--muted); }
.dex-kv dd { margin: 0; text-transform: capitalize; }

/* --- move rows. A level-up row and a TM row are the SAME grid; only the key cell differs. --- */
.dex-move-row { display: grid; grid-template-columns: 78px 18px minmax(0,1fr) 62px 34px;
  align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); }
.dex-move-row:last-child { border-bottom: none; }
.dex-move-key { display: flex; align-items: center; gap: 5px; }
.dex-move-lv { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
.dex-tm { font-weight: 800; font-size: 12px; font-variant-numeric: tabular-nums; }
.dex-move-class { color: var(--muted); font-size: 12px; text-transform: capitalize; }
.dex-move-pow { color: var(--muted); font-size: 13px; text-align: right;
  font-variant-numeric: tabular-nums; }
/* ⚠️ LOAD-BEARING. `.move-badge` is `opacity: .5; filter: grayscale(.4)` by default (line 454)
   and ONLY `.moves-wrap .move-badge` (line 760) ever undid it — so a bare <.move_badge> dropped
   into the dex renders half-faded and reads as *disabled*. Reset here rather than borrowing
   `.moves-col moves-wrap`, which also imposes a 200px width cap this grid does not want. */
.dex-move-row .move-badge { opacity: 1; filter: none; min-width: 0; }

/* --- TM tier pill. Same class names as the website's (its app.css:341-347) so a player moving
   between the wiki and the game reads one vocabulary. ⚠️ Deliberately NOT `.tier-*`, which in
   this file already names a species' rarity (legendary/mythical). --- */
.tm-tier { vertical-align: middle; padding: 2px 6px; font-size: 10px; }
.tm-t1 { color: var(--muted); }
.tm-t2 { color: var(--link); }
.tm-t3 { color: var(--accent); }

/* --- defensive type matchups --- */
.dex-matchup-row { display: grid; grid-template-columns: 56px 1fr; align-items: center;
  gap: 10px; padding: 5px 0; }
.dex-matchup-types { display: flex; flex-wrap: wrap; gap: 4px; }
.dex-mult { font-weight: 800; font-size: 13px; text-align: right;
  font-variant-numeric: tabular-nums; }
/* The colour answers "is this good for me" — the attacking types listed beside ×4 are the ones
   to BRING, so they read as the useful row rather than the dangerous one. */
.dex-mult.m-x4   { color: #ff6b6b; }
.dex-mult.m-x2   { color: #e0913f; }
.dex-mult.m-half { color: #4fc1ff; }
.dex-mult.m-qtr  { color: #46c2a6; }
.dex-mult.m-x0   { color: var(--muted); }

/* --- catch chances / drops / spawns: three variants of one strip --- */
.dex-catch-row { display: grid; grid-template-columns: 1fr 60px 78px 68px;
  align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border);
  font-variant-numeric: tabular-nums; }
.dex-catch-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase;
  letter-spacing: .04em; }
.dex-catch-row > span:not(.dex-catch-ball) { text-align: right; }
.dex-catch-ball { display: flex; align-items: center; gap: 6px; min-width: 0; }
.dex-catch-ico { width: 20px; height: 20px; image-rendering: pixelated; flex: none; }

.dex-drop-row { display: grid; grid-template-columns: 20px minmax(0,1fr) 52px 56px;
  align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border);
  font-variant-numeric: tabular-nums; }
/* ⚠️ The ROW is the tooltip trigger, so it carries `gtip` — and `.gtip { display: inline-flex }`
   is a single-class rule, so the display has to be pinned explicitly or source order decides it.
   Same trap as `.mon.gtip` / `.dcell.gtip` above; this is its fifth appearance. */
.dex-drop-row.gtip { display: grid; }
.dex-drop-ico { width: 20px; height: 20px; image-rendering: pixelated; }
.dex-drop-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dex-drop-qty, .dex-drop-pct { text-align: right; color: var(--muted); font-size: 13px; }

.dex-zone-row { display: grid; grid-template-columns: minmax(0,1fr) 90px 56px;
  align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border);
  font-variant-numeric: tabular-nums; }
.dex-zone-row > span:not(:first-child) { text-align: right; color: var(--muted); font-size: 13px; }

.pdx-no { color: var(--lcd-dim); font-size: 9px; }
.pdx-name { text-transform: uppercase; font-size: 11px; color: var(--lcd);
  background: var(--lcd-sel); border: 1px solid var(--gold); padding: 3px 6px; letter-spacing: .5px; }
.pdx-type { display: flex; align-items: center; gap: 5px; margin: 2px 0; }
.pdx-lbl { color: var(--lcd-dim); font-size: 8px; }
.pdx-genus { color: var(--lcd-dim); font-size: 9px; }
.pdx-dim { font-size: 9px; letter-spacing: .3px; }

.pdx-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; border-top: 2px solid var(--lcd-line); padding-top: 10px; }
.pdx-stats { display: flex; flex-direction: column; gap: 6px; }
.pdx-stat { display: grid; grid-template-columns: 30px 1fr; align-items: center; gap: 6px; }
.pdx-stat-lbl { font-size: 8px; color: var(--lcd-dim); }
.pdx-statbar { position: relative; height: 9px; background: rgba(0,0,0,.35); border: 1px solid #0a140d; overflow: hidden; }
.pdx-statbar > i { display: block; height: 100%; background: var(--c); }
.pdx-statbar::after { content: ""; position: absolute; inset: 0; pointer-events: none;
  background: repeating-linear-gradient(90deg, transparent 0 7px, var(--lcd-bg) 7px 9px); }
.pdx-flavor { font-size: 9px; line-height: 1.7; color: var(--lcd); margin: 0; }

/* Evolution line + learnset — full-width sections below .pdx-lower in the detail pane
   (the pane already scrolls, so a 9-move learnset just extends it). */
.pdx-evo, .pdx-moves { display: flex; flex-direction: column; gap: 6px;
  border-top: 2px solid var(--lcd-line); padding-top: 8px; }
.pdx-evo-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* A stage is a COLUMN: a branch (Eevee) stacks its children so the arrow reads once. */
.pdx-evo-stage { display: flex; flex-direction: column; gap: 6px; }
.pdx-evo-mon { display: flex; align-items: center; gap: 5px; font-size: 9px; }
.pdx-evo-mon img { width: 26px; height: 26px; image-rendering: pixelated; flex: none; }
.pdx-evo-mon.cur .pdx-evo-name { color: var(--gold); }
.pdx-evo-name { text-transform: uppercase; letter-spacing: .4px; }
.pdx-evo-req, .pdx-evo-arrow { color: var(--lcd-dim); font-size: 8px; white-space: nowrap; }
.pdx-move-row { display: flex; align-items: center; gap: 6px; font-size: 9px; }
.pdx-move-lv { color: var(--lcd-dim); font-size: 8px; width: 34px; flex: none; }
.pdx-move-name { text-transform: uppercase; letter-spacing: .4px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pdx-move-tm { font-size: 8px; color: var(--lcd-dim); line-height: 1.6; text-transform: uppercase; }

/* ============ LCD re-skin of the shared `.dex-*` markup, plus the page selector ============
   `.pdx` + `.dex-x` is (0,2,0) and beats every site rule in the shared block regardless of
   source order, so nothing here depends on where it sits in the file. */

/* The detail pane scrolls, so a page strip INSIDE it would scroll away. `.pdx-right` is the new
   grid child: a fixed strip above a scrolling pane. */
.pdx-right { display: flex; flex-direction: column; min-height: 0; }
.pdx-pages { display: flex; gap: 4px; padding: 6px 8px; flex: none;
  border-bottom: 2px solid var(--lcd-line); }
/* ⚠️ `.pdx-page`, NOT `.pdx-tab` — the ALL/OWN/MISS filters live in `.pdx-tools` and are a
   different question; one shared class would tie the two rows' look together forever. Same
   reasoning as `.pdx-mine-row` not being `.pdx-stat`. */
.pdx-page { flex: 1; font-family: var(--pixel); font-size: 7px; color: var(--lcd-dim);
  background: transparent; border: 2px solid var(--lcd-line); padding: 4px 2px; cursor: pointer; }
.pdx-page.on { color: var(--lcd-bg); background: var(--lcd); border-color: var(--lcd); }
.pdx-page:hover:not(.on) { color: var(--lcd); border-color: var(--lcd-dim); }

/* Generic full-width block in the detail pane. Replaces what would otherwise have made the
   `.pdx-evo, .pdx-moves` pair rule a list of eight selectors. */
.pdx-sec { display: flex; flex-direction: column; gap: 6px;
  border-top: 2px solid var(--lcd-line); padding-top: 8px; }
.pdx-sec:first-child { border-top: none; padding-top: 0; }
.pdx-none { color: var(--lcd-dim); font-size: 8px; }

.pdx .dex-hint { color: var(--lcd-dim); font-size: 7px; line-height: 1.6;
  text-transform: uppercase; margin: 0 0 4px; }
.pdx .dex-sub { color: var(--gold); font-size: 8px; margin: 6px 0 3px; }
.pdx .dex-kv { grid-template-columns: auto 1fr; gap: 2px 8px; font-size: 8px; }
.pdx .dex-kv dt { color: var(--lcd-dim); text-transform: uppercase; }
.pdx .dex-kv dd { color: var(--lcd); }

/* FOUR tracks, not five: `.dex-move-class` is display:none'd below and a `none` grid item is
   removed from flow, so the remaining four fill these exactly. ⚠️ Change one without the other
   and the LCD grid has a dead column. */
.pdx .dex-move-row { grid-template-columns: 52px 14px minmax(0,1fr) 28px;
  gap: 4px; font-size: 9px; padding: 3px 0; border-bottom-color: var(--lcd-line); }
/* The one thing the ~316px LCD pane drops. It stays in the DOM for copy and a11y, and it is in
   the tooltip — which every move now has, TMs included, which is the point of the change. */
.pdx .dex-move-class { display: none; }
.pdx .dex-move-lv, .pdx .dex-move-pow { color: var(--lcd-dim); font-size: 8px; }
.pdx .dex-tm { color: var(--lcd); font-size: 8px; }
.pdx .dex-move-key { gap: 3px; }
/* Strips the type-coloured chip: `.type-fire { background: … }` is (0,1,0) and this is (0,3,0),
   so the LCD keeps its own palette. The type is still stated — by the separate <.type_badge> in
   column 2. ⚠️ `display` is deliberately NOT touched here: `.move-badge.gtip { display: flex }`
   already pins it, and re-declaring it would be a second answer to one question. */
.pdx .dex-move-row .move-badge { background: var(--lcd-bg2); color: var(--lcd);
  border: 1px solid var(--lcd-line); font-size: 9px; padding: 2px 4px;
  text-transform: uppercase; letter-spacing: .3px; }
.pdx .dex-move-row .mb-ico { width: 12px; height: 12px; margin-right: 3px; border: 0; }
.pdx .dex-move-row .mb-name { display: block; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pdx .tm-tier { font-size: 7px; padding: 1px 3px; border-width: 1px; letter-spacing: 0; }

.pdx .dex-matchup-row { grid-template-columns: 34px 1fr; gap: 5px; padding: 2px 0; }
.pdx .dex-matchup-types { gap: 2px; }
.pdx .dex-mult { font-size: 8px; }

.pdx .dex-catch-row { grid-template-columns: 1fr 30px 34px 38px; gap: 4px;
  font-size: 8px; padding: 3px 0; border-bottom-color: var(--lcd-line); }
.pdx .dex-catch-row.head { color: var(--lcd-dim); font-size: 7px; }
.pdx .dex-catch-ico { width: 14px; height: 14px; }

.pdx .dex-drop-row { grid-template-columns: 14px minmax(0,1fr) 30px 34px; gap: 4px;
  font-size: 8px; padding: 3px 0; border-bottom-color: var(--lcd-line); }
.pdx .dex-drop-ico { width: 14px; height: 14px; }
.pdx .dex-drop-qty, .pdx .dex-drop-pct { color: var(--lcd-dim); font-size: 8px; }

.pdx .dex-zone-row { grid-template-columns: minmax(0,1fr) 52px 34px; gap: 4px;
  font-size: 8px; padding: 3px 0; border-bottom-color: var(--lcd-line); }
.pdx .dex-zone-row > span:not(:first-child) { color: var(--lcd-dim); font-size: 8px; }

/* ◀ ▶ on the D-pad, mirroring the existing .up/.down — geometric shapes, the same idiom as the
   ▲▼ already there (and not emoji, per the repo art rule). Fill, border, colour and both states
   are shared with .up/.down up in the cross block above; only the geometry is here. */
.gbc-d.left, .gbc-d.right { position: absolute; top: 33px; width: 32px; height: 26px; }
.gbc-d.left { left: 4px; }
.gbc-d.right { right: 4px; }

.pdx-foot { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 7px 10px; border-top: 2px solid var(--lcd-line); font-size: 8px; color: var(--lcd-dim); }
.pdx-foot b { color: var(--green); }
.pdx-foot-count { margin-left: auto; color: var(--gold); }

@media (max-width: 720px) {
  /* The deck keeps its two rows — see `.gbc-deck`'s note on why two equal columns fit a 320px
     screen. Stacking the clusters vertically (what this used to do) spent the height the screen
     needs on chrome the player does not touch. */
  .gbc { gap: 10px; padding: 12px 10px 10px; }
  .gbc-deck { column-gap: 6px; }
  .gbc-startsel { gap: 10px; }
  .pdx { height: auto; max-height: calc(82vh * var(--ui-inv)); }
  .pdx-body { grid-template-columns: 1fr; overflow-y: auto; }
  .pdx-list { border-right: none; border-bottom: 2px solid var(--lcd-line); max-height: 220px; }
  .pdx-lower { grid-template-columns: 1fr; }
  .pdx-right { min-height: 0; }
  .pdx-pages { padding: 5px 6px; }
}

/* ICON BUTTONS — one contract, so nobody has to report the next one.
   A `<button>` that holds one icon centres it HERE, never from the icon's side.

   🔴 The root cause is Chrome's UA sheet: a `<button>` gets `padding: 1px 6px`, and with
   `* { box-sizing: border-box }` that eats 12px of a small square's content box. On touch
   `--hud-btn` is 38px, so 38 - 4 (border) - 12 = a 22px track for a 32px `.menu-ico` — the
   icon overflowed to the right and EVERY rail button sat 5px off centre on a phone (1px on a
   46px desktop button, which is why it read as "mobile only"). `.ha-btn` and the collapsed
   friends/chat pucks were each fixed for this one at a time; this is the shared answer.

   ⚠️ `padding: 0` is the fix; the grid only centres what fits. 38 - 4 = 34 >= 32, so the icon
   lands with an integer 1px each side — never scale the art to fit instead, a fractional
   downscale of pixel art is the smudge `make_menu_icon.py` measures against.
   ⚠️ `:where()` is ZERO specificity on purpose: every hide/show rule on these buttons (the
   `.hud-corner-toggle` none/grid pair, the `.collapsed` folds) wins without a source-order
   race, and author rules beat the UA sheet regardless, so the reset still applies.
   ⚠️ `.ico-btn` is the opt-in for any NEW icon-only button. `icon_button_centering_test.exs`
   finds icon-only buttons in the templates by itself and fails one that carries no contract
   class — see `.claude/rules/client/icon-buttons.md`. */
:where(.ico-btn, .hud-menu-btn) { padding: 0; display: grid; place-items: center; }
/* A framed `.btn` used as an icon button keeps its frame and gets EQUAL padding all round —
   `.btn`'s own `padding: 13px 18px` outranks the zero-specificity rule above, so this pairing
   needs its own line rather than an inline style per call site. */
.btn.ico-btn { padding: 4px; justify-content: center; line-height: 0; }
.menu-ico { width: 32px; height: 32px; image-rendering: pixelated; display: block; }

/* The three-pane depot needs the full content width, and its columns scroll themselves —
   so the modal's own overflow has to go, or you get a scrollbar inside a scrollbar.

   Wider than `--maxw` (the site container) on purpose: this is the one modal that is three
   panes side by side, and the third of them renders a whole Pokémon card. It shrinks to fit
   below that anyway — `.game-modal` is `width: 100%` and this is only a ceiling. */
.game-modal.depot-modal { max-width: 1320px; overflow: hidden; padding: 18px; }
@media (max-width: 820px) { .game-modal.depot-modal { overflow-y: auto; } }

/* The trade window: two offer columns side by side, each holding a box grid — at 860px the
   columns squeeze the auto-fill cells down to their 44px floor and the pickers stack tall.
   Wider means more cells per row and a shorter window. A ceiling only, same as depot-modal:
   `.game-modal` is `width: 100%` and it shrinks to fit below this.
   ⚠️ Two-class rule — it outranks bare `.game-modal`, so it is spelled out in the phone
   full-bleed block below, exactly like `.game-modal.depot-modal`. */
.game-modal.trade-modal { max-width: 1240px; }

/* The Grand Exchange: a 140px category rail beside a seven-column market table. At 860px the
   table's own `.table-wrap` overflowed and the panel grew a horizontal scrollbar — which is
   exactly the thing the list was rebuilt to avoid, since a price you have to scroll sideways to
   read is a price you did not see. A ceiling only, same as the two above: `.game-modal` is
   `width: 100%` and this shrinks to fit below it.
   ⚠️ Two-class rule — it outranks bare `.game-modal`, so it is spelled out in the phone
   full-bleed block below, exactly like `.game-modal.depot-modal` and `.game-modal.trade-modal`.
   Miss that and the Exchange is the one modal that stays a 1240px box on a phone. */
.game-modal.exchange-modal { max-width: 1240px; }

/* The crafting window: a 300px recipe list beside a recipe detail pane. At 860px the detail
   pane's reagent rows wrap onto two lines each and the list's level pills push off their names,
   which is the at-a-glance reading the two-pane layout exists to give. A ceiling only, same as
   the three above: `.game-modal` is `width: 100%` and shrinks to fit below it.
   ⚠️ Two-class rule — it outranks bare `.game-modal`, so it is spelled out in the phone
   full-bleed block below, exactly like the three above. Miss that and this is the one modal
   that stays a 1080px box on a phone. */
.game-modal.professions-modal { max-width: 1080px; }

/* ---------------- Depot: Party & Boxes (three panes) ----------------
   Party rail | box grid | live detail, in the shape of the games' box UI.

   Height is FIXED rather than flowed: this renders inside GameLive's modal behind two
   untyped wrappers (.game-modal-body has no rule at all, .embedded-frame only sets
   min-width), so a percentage chain has nothing to resolve against. Same trick .pdx uses.
   `min-height: 0` on the grid is what actually lets the columns scroll — without it a
   grid item's auto min-size floors at its content and the panes grow instead. */
/* Wider and shorter than it was (was 340px detail / 680px tall). The detail pane is the one
   that was starved — it renders a full Pokémon card in a column narrow enough that the tabs
   had to be shrunk to 8px and the IV hexagon capped — while the height was tall enough to
   push the modal off a laptop screen. The box grid scrolls, so height costs rows and nothing
   else; width costs the detail pane everything. */
.depot-shell { display: grid; grid-template-columns: 210px minmax(0, 1fr) 440px;
  height: min(560px, calc(72vh * var(--ui-inv))); min-height: 0;
  border: 2px solid var(--border); background: var(--bg-2); }

.depot-col-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px;
  border-bottom: 2px solid var(--border); font-family: var(--pixel); font-size: 8px;
  color: var(--link); position: sticky; top: 0; background: var(--bg-2); z-index: 1; }
/* 💾 and ❤️ sit together at the end of the rail head — the `margin-left:auto` that used to
   be on .depot-heal moved to this wrapper, or the two buttons push each other apart. */
.depot-head-actions { margin-left: auto; display: flex; gap: 4px; }
.depot-heal, .depot-save { border: 2px solid var(--edge); background: var(--panel-2);
  color: var(--text); font-size: 13px; line-height: 1; padding: 4px 7px; cursor: pointer; }
.depot-heal:hover:not([disabled]), .depot-save:hover:not([disabled]) { background: var(--panel); }
.depot-heal[disabled], .depot-save[disabled] { opacity: 0.4; cursor: not-allowed; }

/* inline "name this team" prompt, opened by 💾 and rendered right under the rail head */
.depot-save-form { display: flex; gap: 4px; align-items: center; padding: 8px 8px 0; }
.depot-save-form input[type="text"] { flex: 1; min-width: 0; padding: 6px 8px; font-size: 12px;
  color: var(--text); background: var(--bg); border: 2px solid var(--border); }
.depot-save-form input[type="text"]:focus { outline: none; border-color: var(--primary);
  box-shadow: 2px 2px 0 var(--edge); }
.depot-save-form .btn { padding: 6px 9px; }

/* ---- left: party rail ---- */
/* Column, not a plain scroller: the head pins at the top, the slots scroll, and on the
   Saved Teams tab the name+save/activate/delete strip pins at the bottom. */
.depot-party { display: flex; flex-direction: column; overflow: hidden; min-height: 0;
  border-right: 2px solid var(--border); }
.depot-team-slots { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 8px; }
.depot-party.drop-hover { background: rgba(247, 148, 30, 0.09); }
/* `position: relative` is here solely so the pending dot can anchor to the row — same
   reasoning as `.tab` up in the dot block. Nothing else in here is absolutely positioned. */
.dparty-slot { position: relative; display: flex; align-items: center; gap: 8px; width: 100%;
  margin-top: 8px;
  padding: 6px; text-align: left; cursor: pointer; color: var(--text);
  background: var(--panel); border: 3px solid var(--q, var(--border)); }
.dparty-slot:hover { background: var(--panel-2); }
.dparty-slot.sel { box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); }
.dparty-slot img { width: 44px; height: 44px; image-rendering: pixelated; flex: none; }
.dparty-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
.dparty-name { font-family: var(--pixel); font-size: 8px; line-height: 1.4;
  text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dparty-lv { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.dparty-hp { height: 5px; }
.dparty-slot.empty { display: flex; align-items: center; justify-content: center;
  min-height: 58px; border: 2px dashed var(--border); background: none; opacity: 0.35;
  cursor: default; font-size: 11px; color: var(--muted); }
/* the IV half of the slot's "Lv N · IV N%" line — accent, so it reads apart from the
   muted level at a glance, and matches the accent IV in the box cell's caption */
.dparty-iv { color: var(--accent); }

/* ---- Saved Teams: chip row + the draft's action strip ---- */
.team-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin: 0 0 12px; }
.team-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
  cursor: pointer; font-family: var(--pixel); font-size: 8px; line-height: 1.5;
  color: var(--muted); background: var(--panel); border: 2px solid var(--border); }
.team-chip:hover:not([disabled]) { color: var(--text); background: var(--panel-2); }
.team-chip.on { color: var(--text); border-color: var(--primary); box-shadow: inset 0 0 0 2px var(--edge); }
/* ★ Current Team is pinned, not one of the list: it is the live party, so it can be neither
   created nor deleted. The separator is what stops it reading as "saved team number zero". */
.team-chip.current { margin-right: 10px; border-color: var(--edge); }
.team-chip.current::after {
  content: ""; position: absolute; right: -6px; top: 4px; bottom: 4px;
  border-right: 2px solid var(--border);
}
.team-chip.current { position: relative; }
/* ⚔ on a SAVED chip = "this loadout is your party right now". ★ belongs to the pinned chip
   alone — one glyph meaning two things in one row is what this screen removed. */
.team-chip.active { color: #ffd447; }
.team-chip.warn { border-color: #5c3a1e; }
.team-chip[disabled] { opacity: 0.4; cursor: not-allowed; }
.team-chip .tc-n { font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; }
.tc-blurb { font-size: 12px; }

.depot-team-actions { flex: none; display: flex; gap: 4px; flex-wrap: wrap; align-items: center;
  padding: 8px; border-top: 2px solid var(--border); background: var(--bg-2); }
.depot-team-actions input[type="text"] { flex: 1 1 100%; min-width: 0; padding: 7px 9px;
  font-size: 13px; color: var(--text); background: var(--bg); border: 2px solid var(--border); }
.depot-team-actions input[type="text"]:focus { outline: none; border-color: var(--primary);
  box-shadow: 2px 2px 0 var(--edge); }
.depot-team-actions .btn { padding: 7px 10px; font-size: 12px; }
.tc-dirty { flex: 1 1 100%; font-size: 11px; color: #ffb347; }

/* ---- centre: the box ---- */
.depot-box { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.depot-box-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px;
  border-bottom: 2px solid var(--border); }
.depot-step { width: 28px; height: 28px; flex: none; cursor: pointer; line-height: 1;
  font-size: 15px; color: var(--text); background: var(--panel-2); border: 2px solid var(--edge); }
.depot-step:hover:not([disabled]) { color: var(--accent); }
.depot-step[disabled] { opacity: 0.3; cursor: not-allowed; }
.depot-box-name { font-family: var(--pixel); font-size: 9px; color: var(--accent);
  text-shadow: 2px 2px 0 var(--edge); }
.depot-count { margin-left: auto; font-size: 11px; }

/* ⚠️ `grid-auto-rows: min-content` is load-bearing, not tidying. The implicit rows are
   otherwise `auto`, and an `auto` track here is sized from the space the grid HAS rather than
   from what the cells need — while `.dcell`'s height comes from `aspect-ratio: 1` against a
   `1fr` column, i.e. from the grid's WIDTH. Past ~1200px the square outgrows its row and the
   cells spill into the row below: measured 21px of overlap at 1280 and 128px at 1920, on a
   full box page. Because the track follows available height, anything that takes height out of
   this grid makes it worse — the mass-evolve toolbar cost 64px and is what finally made it
   obvious. `min-content` sizes the row from the cells themselves, so it no longer depends on
   the viewport at all. Pinned by `depot_mass_evolve_test.exs`. */
/* ⚠️ `scrollbar-gutter: stable` for the same family of reasons: the columns are `1fr`, so a
   scrollbar appearing takes its width straight out of all six cells at once. That is a real
   layout jump on every classic-scrollbar platform (Windows/WSL), and it fires exactly when
   the box fills up or the toolbar wraps. Reserving the gutter keeps the cell width a function
   of the viewport alone. */
.depot-grid { flex: 1; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 10px;
  display: grid; grid-template-columns: repeat(6, 1fr); grid-auto-rows: min-content;
  gap: 6px; align-content: start; }
.depot-grid.drop-hover { background: rgba(247, 148, 30, 0.09); }

/* The cell: avatar, level, IV — nothing else. The border IS the quality tier. */
.dcell { position: relative; aspect-ratio: 1; min-height: 44px; padding: 0; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  background: rgba(14, 21, 32, 0.9); border: 3px solid var(--q, var(--border)); }
.dcell:hover { background: var(--panel-2); }
.dcell.sel { box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); }
.dcell img { width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated;
  pointer-events: none; }
/* One caption strip instead of two corner badges, so a cell and a rail slot read the same
   "60 · 53%". It overlays the sprite, hence its own gradient scrim. White for the level and
   accent for the IV — exactly the colour coding the corner badges used.

   ⚠️ The label words ("Lv", "IV") are hidden UNCONDITIONALLY, never at a width breakpoint.
   They used to drop out below a 68px container query, and `.dcell` sizes off a bare `1fr`,
   so its width lands wherever six columns of the current viewport put it — which parks a
   normal window within a pixel or two of that threshold. The caption then flipped between
   "60 · 53%" and "Lv 60 · IV 53%" on anything that nudged the column width by a hair (a
   scrollbar appearing, a toolbar row wrapping), and the longer caption is also the one that
   crowds the cell. Reported exactly that way. Deleting the breakpoint is what makes the flip
   impossible rather than merely unlikely — the numbers alone were always the legible half,
   and the full reading is one hover away in `mon_tip/1`. */
.dcell { container-type: inline-size; }
.dcell-meta { position: absolute; left: 0; right: 0; bottom: 0; padding: 2px 1px 3px;
  font-size: 9px; font-weight: 800; line-height: 1; color: #fff; text-align: center;
  white-space: nowrap; overflow: hidden; font-variant-numeric: tabular-nums;
  pointer-events: none; text-shadow: 0 1px 2px #000, 0 0 3px #000;
  background: linear-gradient(transparent, rgba(6, 10, 16, 0.85) 55%); }
.dcell-iv { color: var(--accent); }
.dcell-iv::before { content: " · "; color: #fff; }
.dcell-meta .lbl { display: none; }
/* The one remaining step, measured against the worst caption a cell can now hold,
   "100 · 100%": the type tightens and the separator loses its spaces. Without it a level-100
   100% mon loses its "%" in the 820-950px band, where the cell bottoms out at 44px. */
@container (max-width: 58px) {
  .dcell-meta { font-size: 8px; letter-spacing: -0.2px; }
  .dcell-iv::before { content: "·"; }
}
.dcell-shiny { position: absolute; top: 1px; right: 2px; font-size: 10px; pointer-events: none; }
/* Mass-evolve multi-selection. A full-cell tint + accent border, never a corner badge: every
   corner of `.dcell` is taken (top-left `notif-dot`, top-right `.dcell-shiny`, bottom
   `.dcell-meta`), which is the same constraint that kept the protection 🛡 off this cell.
   It also has to stay distinguishable from `.sel`, the single-selection ring that says
   "this is the mon in the detail pane" — hence border+tint here against that inset ring, so
   a cell can legibly be both at once. */
.dcell.sel-multi { border-color: var(--accent); background: rgba(255, 212, 71, 0.16); }
.dcell.sel-multi:hover { background: rgba(255, 212, 71, 0.26); }
.dcell.sel-multi::after { content: "✓"; position: absolute; left: 2px; bottom: 12px;
  font-size: 11px; font-weight: 900; color: var(--accent); pointer-events: none;
  text-shadow: 0 1px 2px #000, 0 0 3px #000; }
.depot-selcount { font-size: 12px; font-weight: 800; color: var(--accent);
  font-variant-numeric: tabular-nums; }

/* The box head's tri-state selection checkbox, and the ☰ actions menu beside it.

   ⚠️ A BUTTON, not an `<input type="checkbox">`. `indeterminate` is a DOM property with no
   HTML attribute and no CSS selector, so a native checkbox cannot express the "some of this
   page" state from a server render at all — it would need a JS hook to set `.indeterminate`
   on every patch. The glyph box is `.ha-sell-box`'s shape (farm_live's VIP auto-sell already
   renders a checkbox-looking button for its own reasons), so this introduces no new vocabulary. */
.depot-selcheck { display: inline-flex; align-items: center; gap: 5px; flex: none;
  background: none; border: 0; padding: 0; color: inherit; font: inherit; cursor: pointer;
  white-space: nowrap; }
.depot-selbox { width: 14px; height: 14px; flex: none; display: inline-flex; align-items: center;
  justify-content: center; border: 2px solid var(--edge); background: var(--panel-2);
  font-size: 10px; font-weight: 900; line-height: 1; }
.depot-selcheck.on .depot-selbox { border-color: var(--accent); color: var(--accent); }
.depot-selcheck:hover .depot-selbox { border-color: var(--accent); }
/* nothing selected: the count reads "0", so it must not shout in accent like a live figure */
.depot-selcheck:not(.on) .depot-selcount { color: var(--muted); }

.depot-menu { position: relative; display: inline-flex; flex: none; }
/* Mirrors `.ctx-menu`'s chrome but NOT its z-210: this one renders inside the depot modal and
   only has to clear the grid beneath it, so it stays in the modal's own stacking context. The
   rows are `.ctx-item` / `.ctx-item.disabled`, which are standalone selectors — reused whole,
   greyed state included. */
.depot-menu-pop { position: absolute; top: calc(100% + 4px); left: 0; z-index: 12;
  min-width: 178px; background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 4px; }

/* ⚠️ 821-1100px is the ONLY band where this row runs out of room, and it is easy to miss:
   the shell is still three columns there (210px rail + 1fr box + 440px detail), so at 950px
   the box column is ~300px — and `‹ Caixa 1/129 › ☑ 4 selecionados ☰ 3869 guardados` measured
   42px past its right edge, pushing the stored count outside the box. Below 821px the shell
   collapses to one column and the room comes back, which is why this needs a min-width too.
   Dropping the words keeps the tick box, the ☰ and the count that was already there; the
   number is still one hover away on the title. Measured, not guessed — headless Chrome at
   900/950/1000px. */
@media (max-width: 1100px) and (min-width: 821px) {
  .depot-selcount { display: none; }
  /* Below ~860px the box column is under 210px and even the tick box + ☰ do not fit beside
     the pager and the count. The head already wrapped to two lines here BEFORE these controls
     existed (measured: 53px at 900, 61px at 830 on a clean tree), and it already overflowed
     outright at 825px — so wrapping is the behaviour this row was heading for anyway, and it
     is strictly better than pushing "3869 guardados" outside the box. */
  .depot-box-head { flex-wrap: wrap; }
}

/* the stone tally in the mass-evolve confirm modal */
.evo-uses { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; font-size: 12px; }

/* ---- ⚖ the depot's comparison table ----
   One column per selected Pokémon (2-6, capped in play_live.ex), so this modal is the one
   place in the app that is deliberately wider than `.modal`'s 520px: six 92px columns plus
   the label rail is ~640px, and below that the wrapper scrolls sideways rather than the
   numbers wrapping. */
.cmp-modal { max-width: 720px; }
/* ⚠️ The scroller is the WRAPPER, not the table — `overflow-x` on the table would take the
   sticky label column with it, and on `.modal` it would clip the quality tag's hover card. */
.cmp-scroll { overflow-x: auto; }
.cmp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.cmp-table th, .cmp-table td { padding: 5px 8px; border-bottom: 1px solid var(--border); }
.cmp-table tbody tr:last-child th, .cmp-table tbody tr:last-child td { border-bottom: none; }
/* The row labels stay put while the columns scroll under them — a stat name is the only thing
   that makes a scrolled-to column readable at all. `--panel` matches the modal so the cells
   passing beneath are covered rather than showing through. */
.cmp-table tbody th[scope="row"], .cmp-corner {
  position: sticky; left: 0; z-index: 1; background: var(--panel);
  text-align: left; color: var(--muted); font-weight: 700; white-space: nowrap;
}
.cmp-table thead th { vertical-align: bottom; text-align: center; min-width: 92px;
  border-bottom: 2px solid var(--border); }
.cmp-corner { min-width: 74px; border-bottom: 2px solid var(--border); }
.cmp-sprite { display: block; margin: 0 auto 2px; width: 56px; height: 56px; image-rendering: pixelated; }
.cmp-name { display: block; font-weight: 800; text-transform: capitalize; line-height: 1.2; }
.cmp-sub { display: block; color: var(--muted); font-size: 10px; text-transform: capitalize; font-weight: 400; }
.cmp-num { text-align: center; font-variant-numeric: tabular-nums; }
.cmp-num b { font-size: 14px; font-weight: 800; }
/* the roll under the final number, and the EV cap — secondary by size and colour, so the big
   figure stays the one the eye lands on */
.cmp-num i { display: block; color: var(--muted); font-style: normal; font-size: 10px; }
.cmp-table td i { font-style: normal; }
/* the row that sums the six statlines: a divider above it, since it is a different KIND of
   number from the rows it totals */
.cmp-sum th[scope="row"], .cmp-sum td { border-top: 2px solid var(--border); }
/* The winner of a row. A tinted cell rather than a glyph: a ★ would fight the ▲▼ the nature
   already puts on these numbers, and a row where every column ties is left unmarked entirely
   (see `compare_best/1`) so the tint always means something. */
.cmp-best { background: color-mix(in srgb, var(--accent) 16%, transparent); }
/* ⚠️ Scoped to `.cmp-num`, never a bare `.cmp-best b`. The quality row's winner is a
   `<.quality_tag>`, and that component's hover BODY carries a `<b>` of its own — a loose
   descendant rule would tint the tier word inside the tooltip on one column only. */
.cmp-num.cmp-best b { color: var(--accent); }
.cmp-nature { text-align: center; color: var(--muted); font-size: 11px; }
.cmp-legend { margin: 10px 0 0; font-size: 11px; }
/* One phone-width step: the sprites and the label rail are what the columns can spare. */
@media (max-width: 560px) {
  .cmp-sprite { width: 40px; height: 40px; }
  .cmp-table thead th { min-width: 76px; }
  .cmp-corner { min-width: 58px; }
  .cmp-table th, .cmp-table td { padding: 4px 5px; }
}

.dcell.empty { border: 2px dashed var(--border); background: none; opacity: 0.22; cursor: default; }
/* spans the whole grid rather than landing in one cell */
.depot-empty { grid-column: 1 / -1; text-align: center; font-size: 12px; padding: 24px 8px; margin: 0; }

.depot-tools { display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
  padding: 8px 10px; border-top: 2px solid var(--border); background: var(--bg-2); }
.depot-tools input[type="text"] { flex: 1; min-width: 110px; padding: 7px 10px;
  background: var(--bg); color: var(--text); border: 2px solid var(--border); font-size: 13px; }
.depot-tools input[type="text"]:focus { outline: none; border-color: var(--primary);
  box-shadow: 2px 2px 0 var(--edge); }
.depot-tools .game-select { font-size: 12px; padding: 6px 26px 6px 8px; }
.depot-shiny { display: inline-flex; gap: 4px; align-items: center; cursor: pointer; }
/* the shared MonFilters bar fills the tools strip; its search input keeps the bar's own
   framed-box look, not the strip's legacy input chrome above */
.depot-tools .mf-bar { flex: 1; min-width: 0; }
.depot-tools .mf-search input[type="text"] { background: transparent; border: 0; padding: 2px 4px; }
.depot-tools .mf-search input[type="text"]:focus { box-shadow: none; }

/* ---- right: the selected Pokémon ---- */
.depot-detail { overflow-y: auto; min-height: 0; border-left: 2px solid var(--border);
  padding: 12px; }
.depot-detail .modal-cols { grid-template-columns: 1fr; gap: 12px; }
.depot-detail .modal-sprite { width: 100px; }
/* The card's tabs are sized for a 720px modal. At 340px they wrapped into the underline and
   had to be shrunk to 8px; at 440px they fit at the 9px the rest of this pane uses. */
.depot-detail .psum-tab { padding: 7px 10px; font-size: 9px; }
.depot-detail .stat-hex { max-width: 340px; }
.depot-blank { display: grid; place-items: center; height: 100%; text-align: center;
  font-size: 12px; padding: 0 16px; }

/* Cross-pane drag. Deliberately NOT `dragging-item`: that class raises the hotbars above
   the modal backdrop, which is right for the bag and wrong here. */
body.dragging-mon .dcell, body.dragging-mon .dparty-slot { cursor: grabbing; }
/* a party slot lit as a drop target: dropping here swaps the two, so mark the SLOT
   rather than the pane — the distinction is the whole difference between "join the
   party" and "take this one's place in the order". */
.dparty-slot.drop-hover { box-shadow: inset 0 0 0 2px var(--accent), var(--shadow); }

@media (max-width: 820px) {
  /* Same collapse as .pdx: one column, the whole shell scrolls, the rail turns into a
     horizontal strip with a bottom border instead of a right one. */
  .depot-shell { grid-template-columns: 1fr; height: auto; max-height: calc(82vh * var(--ui-inv)); overflow-y: auto; }
  /* The rail stays a column here, but its SLOTS become the horizontal strip — the head and
     (on Saved Teams) the action bar have to keep their own rows. */
  .depot-party { overflow: visible; border-right: none;
    border-bottom: 2px solid var(--border); }
  .depot-team-slots { display: flex; gap: 8px; overflow-x: auto; overflow-y: visible; padding: 8px; }
  .depot-party .depot-col-head { position: static; flex: none; border-bottom: none; }
  .dparty-slot { width: auto; flex: none; margin-top: 0; }
  .dparty-main { display: none; }
  .dparty-slot.empty { min-width: 44px; min-height: 44px; }
  .depot-grid { grid-template-columns: repeat(5, 1fr); overflow-y: visible; }
  .depot-detail { border-left: none; border-top: 2px solid var(--border); }
  .depot-team-actions input[type="text"] { flex: 1 1 100%; }
  .tc-blurb { flex: 1 1 100%; }
}

/* ---------------- Professor Oak: lab intro (create screen) ---------------- */
.oak-intro { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 22px;
  padding: 16px; border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  background: var(--panel); }
.oak-portrait { flex: 0 0 auto; width: 96px; height: 96px; image-rendering: pixelated;
  border: 2px solid var(--edge); background: var(--bg-2); }
.oak-speech { flex: 1; min-width: 0; }
.oak-speech b { display: block; font-family: var(--pixel); font-size: 11px; color: var(--primary-2);
  text-shadow: 2px 2px 0 var(--edge); margin-bottom: 8px; }
.oak-speech p { margin: 0 0 8px; color: var(--text); line-height: 1.6; font-size: 14.5px; }
.oak-speech p:last-child { margin-bottom: 0; }
@media (max-width: 560px) { .oak-intro { flex-direction: column; align-items: center; text-align: center; } }

/* ---------------- reusable in-world dialogue box ---------------- */
.dialogue-box { position: fixed; left: 50%;
  bottom: calc(24px + var(--hud-safe-b) * var(--ui-inv)); transform: translateX(-50%);
  z-index: 220; width: min(680px, calc((100vw - 32px) * var(--ui-inv))); display: flex; gap: 14px; align-items: flex-start;
  padding: 16px 18px; background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); pointer-events: auto; cursor: pointer; }
/* the explicit display:flex above overrides the UA [hidden] rule — re-hide it */
.dialogue-box[hidden] { display: none; }
.dialogue-portrait { flex: 0 0 auto; width: 72px; height: 72px; image-rendering: pixelated;
  border: 2px solid var(--edge); background: var(--bg-2); }
.dialogue-body { flex: 1; min-width: 0; }
.dialogue-speaker { display: block; font-family: var(--pixel); font-size: 10px; color: var(--primary-2);
  text-shadow: 2px 2px 0 var(--edge); margin-bottom: 8px; }
.dialogue-text { margin: 0; color: var(--text); line-height: 1.6; font-size: 15px; min-height: 3em; }
/* optional per-page lesson illustration (showDialogue page objects) */
.dialogue-art { display: block; height: 96px; max-width: 100%; image-rendering: pixelated;
  margin: 0 auto 10px; border: 2px solid var(--edge); box-shadow: 2px 2px 0 var(--edge); }
.dialogue-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; flex-wrap: wrap; }
.dialogue-next, .dialogue-choice { font-family: var(--pixel); font-size: 9px; cursor: pointer;
  color: var(--edge); background: var(--primary); border: 2px solid var(--edge);
  box-shadow: 2px 2px 0 var(--edge); padding: 8px 12px; }
/* inline-flex, not the default, because a choice may carry the NPC's role icon ahead of its
   label (the passthrough button — "Curar minha equipe", "Ver a loja"). A <button> centres its
   content by default, so without the explicit align-items an icon taller than the 9px pixel
   font would sit on the text baseline. .dialogue-actions already wraps, so a longer per-NPC
   label pushes the row to a second line instead of overflowing the box. */
.dialogue-next, .dialogue-choice { display: inline-flex; align-items: center; gap: 6px; }
/* 24px is the exact 2:1 ratio off the 48px source art — see NPC_ICON_PX in app.js for why an
   integer ratio matters under nearest-neighbour. `pixelated` is the house idiom (.sk-ico,
   .type-ico); `flex: 0 0 auto` keeps the icon square when the label wraps. */
.dlg-choice-ico { width: 24px; height: 24px; image-rendering: pixelated; flex: 0 0 auto; }
.dialogue-next:hover, .dialogue-choice:hover { background: var(--primary-2); }
/* keyboard selection — this is what Enter / the interact key will pick */
.dialogue-next.selected, .dialogue-choice.selected { background: var(--primary-2);
  transform: translate(1px, 1px); box-shadow: 1px 1px 0 var(--edge), inset 0 0 0 2px var(--bg); }

/* ---------------- Professor Oak: guided-tutorial checklist HUD ----------------
   A flex item of .hud-rail.right, collapsible via the CollapsePanel hook. The
   collapse is a class flip on the wrapper (like .chat-panel) rather than a HEEx
   :if, because the state lives in localStorage, not on the server. */
.tutorial-hud { width: 232px; max-width: 100%;
  background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }
.tutorial-head { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none;
  padding: 10px 12px; border-bottom: 2px solid var(--border); }
.tutorial-hud.collapsed { width: auto; }
.tutorial-hud.collapsed .tutorial-head { border-bottom: none; }
.tutorial-hud.collapsed .tutorial-steps { display: none; }
.tutorial-title { flex: 1; font-family: var(--pixel); font-size: 9px; color: var(--primary-2);
  text-shadow: 2px 2px 0 var(--edge); }
.tutorial-steps { list-style: none; margin: 0; padding: 10px 12px 12px;
  display: flex; flex-direction: column; gap: 7px; }
.tutorial-steps li { display: flex; gap: 8px; align-items: baseline; font-size: 12.5px; color: var(--muted); }
.tutorial-steps li.current { color: var(--text); }
.tutorial-steps li.done { color: var(--muted); text-decoration: line-through; opacity: 0.75; }
.tutorial-check { flex: 0 0 auto; color: var(--accent); font-size: 12px; }
.tutorial-steps li.current .tutorial-check { color: var(--primary-2); }
.tutorial-label { line-height: 1.4; }
@media (max-width: 640px) { .tutorial-hud { display: none; } }

/* ---------------- tracked-quest HUD ----------------
   Reuses the tutorial checklist's shape, with one deliberate difference: it does NOT
   vanish on phones. `.tutorial-hud` is `display: none` under 640px (just above), which
   left mobile players with no visible objective at all — a real bug this replaces rather
   than inherits. Below 640px this collapses to a single current-objective chip: the quest
   name and finished steps are hidden, and only the first unfinished line remains. */
.quest-hud { width: 232px; max-width: 100%;
  background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }
.quest-hud-head { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none;
  padding: 10px 12px; border-bottom: 2px solid var(--border); }
.quest-hud.collapsed { width: auto; }
.quest-hud.collapsed .quest-hud-head { border-bottom: none; }
.quest-hud.collapsed .quest-hud-quest { display: none; }
/* The 📜, split out of the translated title so the phone rules below can drop the word and
   keep the glyph — the same split `.chat-ico` / `.chat-label` makes in the chat head. It shows
   at every width: on a wide screen it is the first half of what used to be one string. */
   ⚠️ 9px, matching the pixel-font title it was cut out of. The glyph used to sit INSIDE that
   span and inherit its size, so anything else here silently resizes the desktop head — a
   change nobody asked for. The phone rules below scale it up on purpose. */
.quest-hud-ico { flex: 0 0 auto; font-size: 9px; line-height: 1; }
.quest-hud-title { flex: 1; font-family: var(--pixel); font-size: 9px; color: var(--primary-2);
  text-shadow: 2px 2px 0 var(--edge); }
/* A <button> since it opens the log focused on this quest, so it has to shed the UA button
   styling and go back to looking like the panel row it was. `display: block` is what the
   two `display: none` rules above and below override — both outrank it (higher specificity
   for `.collapsed`, later in the file for the phone chip), so neither needs restating. */
.quest-hud-quest { display: block; width: 100%; text-align: left; font: inherit; color: inherit;
  background: none; border: none; border-bottom: 2px solid var(--border); cursor: pointer;
  padding: 9px 12px 11px; }
.quest-hud-quest:last-child { border-bottom: none; }
.quest-hud-quest:hover { background: var(--panel); }
.quest-hud-quest:hover .quest-hud-name { color: var(--accent); }
.quest-hud-name { font-size: 12px; color: var(--text); margin-bottom: 6px; }
.quest-hud-steps { list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: 6px; }
.quest-hud-steps li { display: flex; gap: 8px; align-items: baseline; font-size: 12.5px; color: var(--muted); }
.quest-hud-steps li.done { text-decoration: line-through; opacity: 0.7; }
.quest-hud-check { flex: 0 0 auto; color: var(--accent); font-size: 12px; }
.quest-hud-label { flex: 1; line-height: 1.4; }
.quest-hud-count { flex: 0 0 auto; font-variant-numeric: tabular-nums; color: var(--primary-2); }
.quest-hud-turnin { margin-top: 6px; font-size: 11.5px; color: var(--accent); }
/* Phones. Two separate jobs in here: thin the CONTENT down to the current objective (which
   this block always did), and let the whole panel fold to an icon (which it could not).

   ⚠️ `.quest-hud-head` is no longer hidden, and that is a fix rather than a preference. The
   head is the only `[data-collapse-toggle]` CollapsePanel binds, and the open/closed flag is
   remembered per device in localStorage — so hiding it here meant a player who folded the log
   on a wide window and then narrowed it saw an empty bordered box with nothing to click. It is
   the same failure the ☰ rail's comment warns about, reached from the other direction.

   44px square, matching the collapsed chat puck and the .hud-menu-btn rail across from it.
   640px rather than the chat's 720px: this is the breakpoint the panel's own content rules
   already use, and splitting one component across two breakpoints reads as a bug. */
@media (max-width: 640px) {
  .quest-hud { width: auto; max-width: calc(62vw * var(--ui-inv)); }
  .quest-hud-head { padding: 5px 7px; border-bottom: none; gap: 6px; }
  .quest-hud-title { display: none; }                     /* the glyph carries the panel here */
  .quest-hud-ico { font-size: 15px; }                     /* ...so it stops being 9px chrome */
  .quest-hud-name { display: none; }
  .quest-hud-quest { padding: 6px 9px; border-bottom: none; }
  .quest-hud-quest ~ .quest-hud-quest { display: none; }  /* one tracked quest is enough */
  .quest-hud-steps li.done { display: none; }
  /* ...and only its current step. ⚠️ `:not(.done)` is load-bearing — a plain `li ~ li` hides
     every step after the FIRST one whether or not that one survived, so a quest whose opening
     objective was already ticked rendered a chip with nothing in it at all. That was the whole
     mobile HUD: head hidden, name hidden, every step hidden, an empty bordered box. */
  .quest-hud-steps li:not(.done) ~ li { display: none; }
  /* Kept, unlike the name: it renders only when the quest is complete, which is exactly when
     every step above has been hidden. It is what fills the chip instead of leaving it blank. */
  .quest-hud-turnin { margin-top: 0; }
  /* Collapsed, the head IS the panel. The caret goes with the word — at 44px the glyph alone
     says "quests" and a second character next to it only makes both harder to hit. */
  .quest-hud.collapsed { width: 44px; min-width: 44px; height: 44px; }
  .quest-hud.collapsed .quest-hud-head { padding: 0; height: 100%; justify-content: center; gap: 0; }
  .quest-hud.collapsed [data-collapse-caret] { display: none; }
  .quest-hud.collapsed .quest-hud-ico { font-size: 18px; }
}

/* ---------------- party frame ----------------
   Your party members and the HP of whatever each one currently has out front. Shaped after
   .quest-hud above (same rail chrome, same CollapsePanel fold) rather than after .team-hud,
   because this is a stacked list in a 340px rail, not a horizontal strip of cards.

   ⚠️ The bar reuses `.team-bar.hp` + `.fill.{ok,mid,low,dead}` from the team strip on purpose:
   that is the in-game HUD's health language, and a party member's HP should read exactly like
   your own. `.bar`/`.hp-ok` is the *menu* health language (depot, bag, mon cards) and would
   look like a different game one panel over.

   ⚠️ No z-index and no transform here — `.hud-rail > *` already supplies the stacking, and
   adding either would trap fixed-position descendants. See the rail block near the top. */
.party-hud { width: 232px; max-width: 100%;
  background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }
/* The head is toggle + action buttons. The buttons are SIBLINGS of [data-collapse-toggle]
   (a button inside it would fold the panel on every press — the .fr-toggle lesson), so the
   head is the flex row and the toggle takes the elastic half. */
.party-hud-head { padding: 10px 12px; border-bottom: 2px solid var(--border);
  display: flex; align-items: center; gap: 6px; }
.party-hud-toggle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
/* .fr-btn's sizing — the rail's own button language, one panel down. */
.party-hud-btn { border: 2px solid var(--edge); background: var(--panel-2); color: var(--text);
  cursor: pointer; padding: 0 6px; font-size: 12px; line-height: 20px; flex: 0 0 auto; }
.party-hud-btn.danger { color: #ff8b8b; }
/* No z-index and no transform (the block's own rule above). */
.party-crown { flex: 0 0 auto; font-size: 11px; }
/* ✅ Ready check. Three states and three colours, because "said no" and "never answered" are
   different facts about a teammate and the leader acts differently on each. Tabular width so a
   roster of ten does not jitter as marks land one by one, and `flex: 0 0 auto` so the mark
   appearing never squeezes the name beside it. */
.party-ready { flex: 0 0 auto; width: 11px; text-align: center; font-size: 11px;
  font-variant-numeric: tabular-nums; }
.party-ready.rd-yes { color: #7fc96b; }
.party-ready.rd-no { color: #ff7a7a; }
.party-ready.rd-wait { color: var(--muted); }
/* The head tally — the one place a player sees their OWN answer, since the rows list only the
   other members. Dimmed once the window has shut: the number stops being live before it stops
   being on screen, and it should not keep reading as a question still open. */
.party-hud-ready { flex: 0 0 auto; font-size: 11px; color: #7fc96b;
  font-variant-numeric: tabular-nums; }
.party-hud-ready.closed { color: var(--muted); }
.party-empty { color: var(--muted); font-size: 12px; margin: 0; padding: 8px 12px; }
.party-hud-title { flex: 1; font-family: var(--pixel); font-size: 9px; color: var(--primary-2);
  text-shadow: 2px 2px 0 var(--edge); }
.party-hud-count { font-variant-numeric: tabular-nums; font-size: 11px; color: var(--muted); }
.party-hud.collapsed { width: auto; }
.party-hud.collapsed .party-hud-head { border-bottom: none; }
.party-hud.collapsed .party-hud-body { display: none; }
/* A full party is 9 rows here — the reader is not listed — and the cap moved from 5 to 10 on
   2026-09-07, which is long enough to run off the bottom of the rail. `.hud-rail` is a fixed
   flex column with no scroll of its own, so an over-long panel spills past the viewport rather
   than scrolling. Capped and scrolled, exactly like `.fr-list` one panel down.
   ⚠️ Fixed px rather than a vh, for `.fr-list`'s reason: the two share a rail, and one panel
   measured in vh beside one measured in px makes their combined height unpredictable. */
.party-hud-body { max-height: 260px; overflow-y: auto; }
/* cursor: the row is now a PartyMenu click/right-click target. */
.party-row { padding: 8px 12px 10px; border-bottom: 2px solid var(--border);
  display: flex; flex-direction: column; gap: 5px; cursor: pointer; }
.party-row:last-child { border-bottom: none; }
.party-row-top { display: flex; align-items: baseline; gap: 8px; }
.party-name { flex: 0 1 auto; font-size: 12px; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The mon can change under a swap, so it gets the elastic half of the row: a long nickname
   truncates rather than pushing the trainer's name out of the panel. */
.party-mon { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--muted); text-align: right;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Status / stat-stage / screen chips on a teammate's row, filled by JS from the `party:hp`
   push (app.js `renderPartyFx`). Empty far more often than not, so it collapses to nothing
   rather than reserving a line: `:empty { display: none }` and no min-height. Without that
   every row in a party standing in town would carry 16px of blank space.

   ⚠️ Colours mirror the canvas chips' rings (EFF_BUFF_RING / EFF_DEBUFF_RING in app.js) so a
   buff reads the same on the rail as it does over the Pokémon's head. Keep them in step. */
.party-fx { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; overflow: hidden; }
.party-fx:empty { display: none; }
.pfx { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 2px;
  height: 16px; padding: 0 3px; border: 1px solid transparent;
  background: rgba(255, 255, 255, 0.06); }
.pfx img { width: 12px; height: 12px; image-rendering: pixelated; }
.pfx-t { font-family: var(--pixel); font-size: 7px; line-height: 1; font-variant-numeric: tabular-nums; }
.pfx.good { border-color: #7fc96b; color: #7fc96b; }
.pfx.bad { border-color: #ff7a7a; color: #ff7a7a; }
/* The overflow counter is not itself an effect — no border, so it does not read as a fifth
   chip whose type you cannot make out. */
.pfx.more { border-color: transparent; background: none; color: var(--muted);
  font-family: var(--pixel); font-size: 7px; }

/* Phones: keep the rows, drop the header — the same degrade-in-place .quest-hud does, and
   deliberately NOT `display: none`, which is the bug the quest HUD's comment calls out. */
@media (max-width: 640px) {
  .party-hud { width: auto; max-width: calc(62vw * var(--ui-inv)); }
  .party-hud-head { display: none; }
  .party-row { padding: 6px 9px 8px; }
}

/* ---------------- quest log panel ---------------- */
.quest-list { display: flex; flex-direction: column; gap: 12px; }
.quest-card { background: var(--panel); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border); padding: 12px 14px; }
.quest-card.st-complete { box-shadow: inset 0 0 0 2px var(--accent); }
/* the quest the player clicked in the HUD — also sorted to the top of its tab. Must stay
   AFTER .st-complete: same specificity, and a complete quest is the likeliest one clicked. */
.quest-card.focused { border-color: var(--primary); box-shadow: inset 0 0 0 2px var(--primary); }
.quest-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.quest-ico { font-size: 16px; }
.quest-name { flex: 1; font-size: 14px; color: var(--text); }
.quest-state.st-available { color: var(--primary-2); }
.quest-state.st-complete { color: var(--accent); }
.quest-state.st-locked { opacity: 0.7; }
.quest-summary { margin: 8px 0 10px; font-size: 12.5px; color: var(--muted); line-height: 1.5; }
.quest-objectives { display: flex; flex-direction: column; gap: 6px; }
.quest-obj { display: flex; gap: 8px; align-items: baseline; font-size: 12.5px; color: var(--muted); }
.quest-obj.done { text-decoration: line-through; opacity: 0.7; }
.quest-check { flex: 0 0 auto; color: var(--accent); }
.quest-obj-label { flex: 1; }
.quest-obj-count { flex: 0 0 auto; font-variant-numeric: tabular-nums; color: var(--primary-2); }
.quest-rewards { margin-top: 10px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.quest-rewards-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.quest-foot { margin-top: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.quest-actions { margin-left: auto; display: flex; gap: 6px; }

/* ---------------- contextual help ("?" button + slide-over panel) ---------------- */
/* the "?" mirrors .modal-close and sits directly BELOW it in a control column */
.modal-help {
  width: 32px; height: 32px; border: 2px solid var(--edge); background: var(--panel-2);
  color: var(--muted); font-size: 15px; font-weight: 700; cursor: pointer; line-height: 1;
}
.modal-help:hover { color: var(--accent); }
/* close (top) + help (below) stacked vertically */
.modal-controls { display: flex; flex-direction: column; gap: 8px; }
.modal-controls > .modal-close,
.modal-controls > .modal-help { position: static; top: auto; right: auto; margin: 0; }
/* shared game modal: the shell holds the modal box + its control column side by side
   (click-away is on the shell, so clicking the controls never closes the modal) */
/* ⚠️ `position: relative` + left/top, NOT a transform. A transformed ancestor becomes the
   containing block for every `position: fixed` descendant AND opens a new stacking context —
   and several things that render inside a modal are fixed and deliberately layered above it
   (`.ctx-menu` z-210, `.farm-pop-backdrop` z-300, `.farm-set-backdrop` z-310). Under a
   transform those would be clipped to the shell and stuck below the backdrop. Relative offsets
   move the box just as well and change none of that. `.bag-shell` is the exception and keeps a
   transform, because it already had one for centering. */
.game-modal-shell { display: flex; align-items: flex-start;
  position: relative; left: var(--drag-x, 0px); top: var(--drag-y, 0px); }

/* The drag handle, and the only part of a modal that starts a drag. Sits above the ✕ in the
   control column so the two are never confused by a hurried click. */
.modal-drag {
  width: 32px; height: 32px; border: 2px solid var(--edge); background: var(--panel-2);
  color: var(--muted); font-size: 15px; line-height: 1; cursor: grab;
  /* Without this a drag selects the text underneath and, on touch, pans the page instead. */
  user-select: none; -webkit-user-select: none; touch-action: none;
}
.modal-drag:hover { color: var(--text); }
/* `grabbing` is set on <body> rather than the handle so the cursor holds even when the pointer
   outruns the modal mid-drag — pointer capture keeps the events, not the hover. */
body.dragging-modal, body.dragging-modal .modal-drag { cursor: grabbing; }
/* A drag must not leave text selected across the panel it just passed over. */
body.dragging-modal { user-select: none; -webkit-user-select: none; }
/* Nothing to drag to on a phone: the modal is full-bleed there, so the handle would only be a
   button that fights the panel's own scrolling. The hook bails at the same width. */
@media (max-width: 640px) { .modal-drag { display: none; } }
.game-modal-shell > .modal-controls { align-self: flex-start; margin-left: 10px; }
/* `.menu-ico-glyph` lived here — an OS-font glyph sized to the `.menu-ico` box, for the eight
   rail buttons that had no art. All eight have art now (`quests`, `calendar`, `party`, `guild`,
   `meter`, `news`, `help`, and `menu` for the collapse toggle), so the class has no users and is
   gone. See the repo art rule: an emoji is not pixel art. */

.help-overlay {
  position: fixed; inset: 0; z-index: 150; background: rgba(0,0,0,0.55);
  display: flex; align-items: stretch; justify-content: flex-end; animation: fade-in 0.15s ease;
}
.help-panel {
  position: relative; width: 100%; max-width: 420px; height: 100%; overflow-y: auto;
  background: var(--panel); border-left: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  padding: 24px; animation: help-slide 0.2s ease;
}
@keyframes help-slide { from { transform: translateX(100%); } to { transform: none; } }
.help-panel h2 { font-family: var(--pixel); font-size: 13px; line-height: 1.5; color: var(--primary-2); text-shadow: 2px 2px 0 var(--edge); }
.help-section { border-top: 2px solid var(--border); margin-top: 16px; padding-top: 14px; }
.help-section h4 { margin: 0 0 8px; font-family: var(--pixel); font-size: 10px; text-transform: uppercase; color: var(--link); }
.help-section p { margin: 0 0 10px; line-height: 1.6; color: var(--text); font-size: 14px; }
.help-section p:last-child { margin-bottom: 0; }

/* ---------------- move (skill) icons ---------------- */
.move-ico { image-rendering: pixelated; }
/* inline icon in a move badge/tooltip name */
.mb-ico { width: 18px; height: 18px; image-rendering: pixelated; vertical-align: middle;
  margin-right: 5px; border: 1px solid var(--edge); }
/* fills the skill hotslot; paints over the fallback first-letter, under the cooldown/keybind */
.hotslot .hotslot-ico { position: absolute; inset: 0; width: 100%; height: 100%;
  image-rendering: pixelated; }

/* ================= immersive farm picker (shadow-Alakazam teleport map) ================= */
.farm-pick { display: flex; flex-direction: column; gap: 12px; }

/* Alakazam speech host */
.farm-alakazam { display: flex; gap: 14px; align-items: center; padding: 12px 14px;
  background: linear-gradient(180deg, var(--panel), var(--panel-2));
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }
.farm-alakazam-portrait { flex: 0 0 auto; width: 72px; height: 72px; image-rendering: pixelated;
  border: 2px solid var(--edge); background: radial-gradient(circle at 50% 40%, #4b2e73 0%, #1c1030 80%);
  box-shadow: 0 0 10px #7d3cff88; }
.farm-alakazam-speech { flex: 1; min-width: 0; }
.farm-alakazam-speech b { display: block; font-family: var(--pixel); font-size: 10px;
  color: #c9a6ff; text-shadow: 2px 2px 0 var(--edge); margin-bottom: 6px; }
.farm-alakazam-speech p { margin: 0; color: var(--text); line-height: 1.55; font-size: 14px; }
.farm-alakazam-mini { padding: 8px 10px; gap: 10px; }
.farm-alakazam-mini .farm-alakazam-portrait { width: 48px; height: 48px; }
/* ⚙ automation, on the picker's Alakazam header. Same `.ha-btn` skin as the Hunt Analyzer's
   gear because it opens the same modal, but sized to a label: the analyzer's is one of three
   icon buttons in a row that explain each other, this one would be a lone glyph. The gear
   keeps .ha-btn's 13px so it still reads as the same control; only the word is pixel-font,
   matching the region tabs immediately below it. `align-self` so it pins to the top of a
   speech box whose height is the portrait's, not the button's. */
.farm-alakazam-cog { flex: 0 0 auto; align-self: flex-start; width: auto; height: auto;
  padding: 5px 9px; display: inline-flex; align-items: center; gap: 6px; }
.farm-alakazam-cog span { font-family: var(--pixel); font-size: 9px; }
/* On a phone the header is portrait + speech + this, and the pt-BR label is the widest of the
   three translations — it squeezes the Alakazam's line to a column of single words. The gear
   alone survives, which is exactly what the Hunt Analyzer ships anyway. */
@media (max-width: 560px) { .farm-alakazam-cog span { display: none; } }

/* region (generation) tabs */
.farm-region-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.farm-region { font-family: var(--pixel); font-size: 9px; cursor: pointer; color: var(--text);
  background: var(--panel-2); border: 2px solid var(--border); padding: 6px 9px;
  display: inline-flex; align-items: center; gap: 5px; }
.farm-region:hover { border-color: var(--edge); }
.farm-region.active { background: var(--primary); color: var(--edge); border-color: var(--edge); }
/* locked regions read as unplayable by dimming alone — the tab carries no "soon" badge;
   clicking one still explains itself via the map scrim + the Alakazam line. */
.farm-region.locked { opacity: 0.6; }

/* filters: name search + level window + type chips. All three narrow the same set. */
.farm-filter { position: relative; display: flex; flex-direction: column; gap: 6px; }
.farm-filter-form { display: flex; flex-direction: column; gap: 6px; }
.farm-filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.farm-filter-label { font-family: var(--pixel); font-size: 8px; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.04em; min-width: 46px; }

/* Two range inputs stacked on one track. Both span the full width so either thumb can
   reach either end; the inputs themselves are click-through and only the thumbs take
   pointer events, or the upper input would swallow every grab at the lower thumb. */
.farm-range { position: relative; flex: 1; min-width: 150px; height: 22px; }
.farm-range-track { position: absolute; left: 0; right: 0; top: 9px; height: 4px;
  background: var(--bg-2); border: 1px solid var(--edge); }
.farm-range input[type="range"] { position: absolute; left: 0; top: 0; width: 100%; height: 22px;
  margin: 0; background: transparent; appearance: none; -webkit-appearance: none;
  pointer-events: none; }
.farm-range input[type="range"]::-webkit-slider-thumb { pointer-events: auto; cursor: pointer;
  appearance: none; -webkit-appearance: none; width: 12px; height: 16px; border-radius: 0;
  background: #ffd23f; border: 2px solid var(--edge); box-shadow: 0 1px 0 var(--edge); }
.farm-range input[type="range"]::-moz-range-thumb { pointer-events: auto; cursor: pointer;
  width: 10px; height: 14px; border-radius: 0;
  background: #ffd23f; border: 2px solid var(--edge); }
.farm-range input[type="range"]::-moz-range-track { background: transparent; border: 0; }
.farm-range-out { font-family: var(--pixel); font-size: 8px; color: #ffe9a8; white-space: nowrap; }

.farm-type-chips { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; }
/* A tree-shaped chip field (the Bag's item categories): each root starts a row, its descendants
   follow it smaller — "Medicine  Potions  Status heals …". See FilterBar.Domain.chip_depth/2. */
.farm-type-chips .mf-break { flex-basis: 100%; height: 0; }
.mf-chip.mf-chip-sub { font-size: .88em; opacity: .9; }
.mf-chip.mf-chip-sub.on { opacity: 1; }
.farm-type-chip { display: grid; place-items: center; width: 26px; height: 26px; padding: 0;
  cursor: pointer; background: var(--bg-2); border: 2px solid var(--border);
  opacity: 0.5; transition: opacity .1s, border-color .1s; }
.farm-type-chip:hover { opacity: 0.85; }
.farm-type-chip.on { opacity: 1; border-color: #ffd23f; background: var(--panel); }

.farm-filter-count { display: flex; align-items: center; gap: 10px; font-size: 12.5px;
  color: var(--muted); }
.farm-filter-none { color: #ff9d55; }
.farm-filter-reset { cursor: pointer; background: transparent; border: 0; padding: 0;
  color: var(--muted); font-size: 12.5px; text-decoration: underline; }
.farm-filter-reset:hover { color: var(--text); }
.farm-filter-box { display: flex; align-items: center; gap: 8px; background: var(--bg-2);
  border: 2px solid var(--border); padding: 6px 10px; }
.farm-filter-box:focus-within { border-color: var(--edge); }
.farm-filter-ico { opacity: 0.7; }
.farm-filter-box input { flex: 1; background: transparent; border: 0; outline: 0;
  color: var(--text); font-size: 14px; min-width: 0; }
.farm-filter-clear { cursor: pointer; background: transparent; border: 0; color: var(--muted);
  font-size: 14px; padding: 2px 4px; }
.farm-suggest { position: absolute; z-index: 6; left: 0; right: 0; top: calc(100% + 4px);
  max-height: 240px; overflow-y: auto; background: var(--panel); border: 2px solid var(--edge);
  box-shadow: var(--shadow); display: flex; flex-direction: column; }
.farm-suggest-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px;
  background: transparent; border: 0; cursor: pointer; color: var(--text); font-size: 13px;
  text-align: left; text-transform: capitalize; }
.farm-suggest-item:hover { background: var(--panel-2); }
.farm-suggest-item img { width: 30px; height: 30px; image-rendering: pixelated; }
.farm-suggest-empty { padding: 8px 10px; color: var(--muted); font-size: 13px; }

/* the shared Pokémon filter/sort bar (PlayServiceWeb.MonFilters) — depot / collector /
   auction. Collapsed: search + two buttons; the panels open in a CENTRED DIALOG.

   ⚠️ This block used to read "the panel expands inline (never a popover — it would fight
   #global-tip and the modal stack)". That was reversed deliberately on request: expanding
   inline reflowed the host, and in the Depot the bar sits under the grid, so opening Filter
   pushed the whole box around — the same class of complaint as the mass-evolve strip.

   Neither original hazard survived the change, and it is worth knowing why rather than
   rediscovering it:
     · #global-tip — the chips carry a native `title=`, not `.gtip`, so nothing here ever
       enters the tooltip layer. And #global-tip is z-500, above this dialog, which is the
       right order anyway.
     · the modal stack — this reuses `.modal-backdrop` / `.modal`, exactly what the Depot's
       own mass-evolve confirm already renders from inside `.depot-modal`. `.game-modal-shell`
       is `position: relative` + left/top and NOT a transform, precisely so `position: fixed`
       descendants escape rather than being trapped (see its own comment). So no portal is
       needed; a `<.portal>` would in fact BREAK this, by carrying the shiny checkbox and the
       two range islands out of the `<form>` that serializes them.

   Chips reuse the farm picker's .farm-type-chip / .farm-range styling above so the two
   filter UIs read as one system. */
.mf-bar { display: flex; flex-direction: column; gap: 6px; }
.mf-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.mf-search { flex: 1; min-width: 140px; }
.mf-btn { display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  background: var(--bg-2); border: 2px solid var(--border); color: var(--text);
  font-family: var(--pixel); font-size: 8px; padding: 6px 10px; white-space: nowrap; }
.mf-btn:hover { border-color: var(--edge); }
.mf-btn.on { border-color: #ffd23f; background: var(--panel); }
.mf-btn.active { color: #ffe9a8; }
.mf-count { display: inline-grid; place-items: center; min-width: 16px; height: 16px;
  padding: 0 3px; background: #ffd23f; color: #201a10; border-radius: 8px;
  font-size: 8px; font-family: var(--pixel); }
/* Inside the dialog the panel is the body, so it drops the framed-box chrome it needed as an
   inline section — the dialog already supplies the border and background. */
.mf-panel { display: flex; flex-direction: column; gap: 10px; padding: 0;
  background: none; border: 0; }
/* ⚠️ z-index 101, one above `.modal-backdrop`'s 100. The Collector and the Auction render
   this bar inside `.game-modal-backdrop`, which is also 100 — equal z-index falls back to
   DOM order, and the bar is an ancestor's descendant, so it wins there by accident rather
   than by rule. On the Depot it also has to clear the mass-evolve confirm should both ever
   be reachable. One explicit number beats three accidents. */
.mf-backdrop { z-index: 101; }
.mf-modal { width: min(680px, 100%); max-width: 680px;
  max-height: calc(86vh * var(--ui-inv)); overflow-y: auto; }
.mf-modal-title { margin: 0 0 14px; font-family: var(--pixel); font-size: 11px;
  color: var(--primary-2); text-shadow: 2px 2px 0 var(--edge); }
.mf-field { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.mf-chip { display: inline-grid; place-items: center; min-height: 22px; padding: 2px 8px;
  background: var(--bg-2); border: 2px solid var(--border); cursor: pointer;
  color: var(--muted); font-size: 12px; opacity: 0.6; }
.mf-chip:hover { opacity: 0.85; }
.mf-chip.on { opacity: 1; color: var(--text); border-color: #ffd23f; background: var(--panel); }
.mf-shiny { display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  font-size: 13px; color: var(--text); }
.mf-sorts { flex-direction: row; flex-wrap: wrap; align-items: center; }
.mf-sort-opt { display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  background: var(--bg-2); border: 2px solid var(--border); color: var(--muted);
  font-size: 13px; padding: 5px 10px; }
.mf-sort-opt:hover { border-color: var(--edge); }
.mf-sort-opt.on { color: var(--text); border-color: #ffd23f; background: var(--panel); }
.mf-sort-dir { color: #ffe9a8; }
.mf-sort-hint { flex-basis: 100%; color: var(--muted); font-size: 11.5px; }

/* the world map + pins.
   `.farm-map` is the fixed viewport (the FarmMapZoom hook listens here); `.farm-map-canvas`
   is the pan/zoom layer the hook transforms; `.farm-map-tiles` holds the tile pyramid the
   hook mounts and prunes. Keeping the transform off a node that carries a server-rendered
   attribute is deliberate — a LiveView patch would wipe it.

   The aspect ratio is the SOURCE IMAGE's, and that is load-bearing, not cosmetic: the pin
   layer below is sized to z*100% of this viewport, which only lands pins on the right map
   points while the unscaled map exactly fills it. Change the artwork, change this.

   Unlike the zone tilesets this art is a painted illustration, not palette pixel art, so
   there is deliberately no `image-rendering: pixelated` — nearest-neighbour on a downscaled
   painting looks broken, not crisp.

   No `will-change: transform` here on purpose: promoting the canvas to a composited layer
   makes the browser rasterize it once and bitmap-scale it, which blurs the map and (before
   pins moved out) blurred every pin. Repainting per frame is cheap at this size. */
.farm-map { position: relative; width: 100%; max-width: 900px; margin: 2px auto 0;
  aspect-ratio: 5248 / 3264; overflow: hidden; touch-action: none; cursor: grab;
  background: #6b8794;
  border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }
.farm-map.panning { cursor: grabbing; }
.farm-map-canvas { position: absolute; inset: 0; transform-origin: 0 0; }
.farm-map-tiles { position: absolute; inset: 0; }
/* Tiles are laid out in percent of the canvas, so the canvas's scale(z) moves them for free.
   `display:block` matters: an inline <img> would reserve baseline space and shift the grid.

   `pointer-events: none` + the drag guards are load-bearing, not hygiene: an <img> is a
   native drag source, so without them a pan gesture peeled a tile off the map instead of
   moving the camera. Nothing needs to hit-test a tile either — pins live in their own
   layer, and the pan listener is on `.farm-map`. */
.farm-tile { position: absolute; display: block; pointer-events: none;
  -webkit-user-drag: none; user-select: none; }

/* Pin layer: translated with the map but NEVER scaled — the hook sizes it to z*100% so a
   `left: 41%` pin still lands on the same map point. Pins therefore render at native
   resolution and need no counter-scale (which is what made their text blurry). */
.farm-pin-layer { position: absolute; left: 0; top: 0; width: 100%; height: 100%;
  transform-origin: 0 0; pointer-events: none; }

/* zoom controls — outside the canvas so they never scale or pan away */
.farm-zoom-ctl { position: absolute; top: 8px; right: 8px; z-index: 5;
  display: flex; flex-direction: column; gap: 4px; }
.farm-zoom-ctl button { width: 26px; height: 26px; display: grid; place-items: center;
  cursor: pointer; font-family: var(--pixel); font-size: 11px; line-height: 1;
  color: var(--text); background: #000b; border: 2px solid var(--edge); padding: 0; }
.farm-zoom-ctl button:hover { background: var(--panel); }
.farm-zoom-ctl button:disabled { opacity: 0.4; cursor: default; }

/* Pins sit in the unscaled .farm-pin-layer, so a plain centring translate is all they need:
   constant on-screen size falls out for free, and nothing is ever resampled. */
.farm-pin { position: absolute; cursor: pointer; pointer-events: auto;
  transform: translate(-50%, -50%);
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  background: transparent; border: 0; padding: 0; z-index: 2; }
/* ⚠️ The light ring is the whole reason a pin is findable. The map under it is a painted
   atlas that is desaturated (nothing above ~23% sat) and relentlessly mid-tone (90% of its
   pixels sit in luminance 61–160 of 255) — so there is no dark end and no light end to hide
   against — and Kanto is its busiest patch, dense with dark ink linework AND small red
   rooftops and route dots at exactly pin scale. A dark disc with a near-black border reads
   as one more painted thing.

   The stack is the "sandwich" docs/image-style.md specifies: dark inner border (the existing
   2px --edge) → bright contrasting middle (the ring) → dark outer edge → soft drop. Same
   answer the Phaser nameplate and NPC_ICON_OUTLINE reached for the same problem, both of
   which say so in app.js: small, mid-tone, over arbitrary terrain.

   box-shadow rather than a wider border or an extra element: no layout cost, the 30px hit
   target is unchanged, and it is the idiom .farm-pin.match already uses below.

   ⚠️ The ring is NOT gold. #ffd23f is the filter-match state — a gold ring at rest would
   erase the only signal that says "this one survived your filter". */
.farm-pin-ico { width: 30px; height: 30px; display: grid; place-items: center; font-size: 16px;
  background: var(--panel); border: 2px solid var(--edge); border-radius: 50%;
  box-shadow: 0 0 0 2px var(--text), 0 0 0 3px #050b16cc, 0 2px 0 var(--edge), 0 4px 8px #000a;
  transition: transform .08s; }
.farm-pin:hover .farm-pin-ico, .farm-pin:focus .farm-pin-ico { transform: scale(1.18); }
.farm-pin-label { font-family: var(--pixel); font-size: 7px; line-height: 1.2; color: var(--text);
  background: #000a; border: 1px solid var(--edge); padding: 2px 4px; white-space: nowrap;
  opacity: 0; transition: opacity .1s; pointer-events: none; }
.farm-pin:hover .farm-pin-label, .farm-pin:focus .farm-pin-label,
.farm-pin.match .farm-pin-label { opacity: 1; }
/* Idle, this counts the farms the cluster is standing in for — the map says how much is
   hidden under each pin before you commit to flying in. Filtering swaps it to how many of
   them survived, in gold. */
.farm-pin-badge { position: absolute; top: -6px; right: -6px; min-width: 16px; height: 16px;
  padding: 0 3px; display: grid; place-items: center; font-family: var(--pixel); font-size: 8px;
  color: var(--text); background: #0b1220; border: 1px solid var(--edge); border-radius: 8px; }
.farm-pin-badge.is-match { color: var(--edge); background: #ffd23f; }
.farm-pin-lv { font-family: var(--pixel); font-size: 7px; line-height: 1; color: #ffe9a8;
  background: #000c; border: 1px solid var(--edge); padding: 2px 4px; white-space: nowrap;
  pointer-events: none; }
.farm-pin.dimmed { opacity: 0.35; filter: grayscale(0.7); }
/* A zone the trainer has not grown into: faded and darkened, so the ladder reads as a gradient
   across the map at a glance rather than as 43 identical pins.
   ⚠️ Still a SEPARATE class from `.dimmed`, which means "filtered out" — a transient result of
   what the player typed, where this is a standing property of the world. They now look similar
   enough that the difference is carried by the ORDER of the two cascade rules below and by the
   dashed icon border, so do not collapse them. Pointer events stay on: the detail pane is where
   the required level is stated.
   The filter lives here rather than in the cascade because `filter` is not what the zoom
   threshold switches — only `opacity` is — so this half applies at every zoom level. */
.farm-pin.locked { filter: grayscale(0.8) brightness(0.62); }
.farm-pin.locked .farm-pin-ico { border-style: dashed; }
.farm-pin.locked .farm-pin-label, .farm-pin.locked .farm-pin-lv { color: var(--muted); }
.farm-pin.match { z-index: 3; }
.farm-pin.match .farm-pin-ico { border-color: #ffd23f;
  box-shadow: 0 0 0 2px #ffd23f, 0 0 12px #ffd23fcc; animation: farmPinPulse 1.1s ease-in-out infinite; }
@keyframes farmPinPulse { 0%,100% { box-shadow: 0 0 0 2px #ffd23f, 0 0 8px #ffd23f99; }
  50% { box-shadow: 0 0 0 3px #ffd23f, 0 0 16px #ffd23fee; } }
/* Cluster pins vs. per-farm pins. Both sets are always in the DOM; the hook flips
   data-zoomed past the threshold and CSS swaps which set is live — no server round-trip.
   Sub-pin names stay visible because they only ever show while zoomed in. */
.farm-pin.is-sub { opacity: 0; pointer-events: none; transition: opacity .12s; }
.farm-pin.is-sub .farm-pin-label { opacity: 1; }
.farm-pin.is-cluster { transition: opacity .12s; }
/* A group with nothing reachable in it — the zoomed-OUT half of the level gate, so the ladder
   reads as a gradient before any individual farm pin exists to read it from. Same 0.55 and the
   same `.farm-pin.locked` darkening as a farm pin, because it is the same fact at a coarser
   grain.
   ⚠️ Specificity, twice, and neither is incidental:
   • `.farm-map[data-zoomed="1"] .farm-pin.is-cluster` (0,4,0) still beats this (0,3,0), so a
     locked cluster is hidden past the expand threshold exactly like every other cluster —
     this must never resurrect a cluster pin over the farm pins that replaced it.
   • `.farm-pin.dimmed` alone is only (0,2,0), so this would out-rank it and a filtered-out
     locked cluster would sit BRIGHTER than the reachable clusters the filter rejected. Hence
     the `.is-cluster.dimmed` line below: equal specificity, declared later, so dimmed wins.
     Same rule and same reason as the sub-pin pair further down. */
.farm-pin.is-cluster.locked { opacity: 0.55; }
.farm-pin.is-cluster.dimmed { opacity: 0.35; }

/* The world boss entrance. Map-anchored like every other pin (it lives in the one
   `.farm-pin-layer`), but ⚠️ explicitly opted OUT of the zoom fade below — a server-wide
   weekly event has to stay on screen at every zoom level, unlike a cluster pin that exists
   only to stand in for the farms underneath it. */
/* The zone a tracked quest is sending the player to. Wears the same gold pulse as a search
   match — one "look here" language on this map, not two — but opts out of the zoom fade for
   the same reason the boss pin does: a player who zooms out to get their bearings must not
   lose the one pin they were told to find. It is the ONLY sub-pin that survives the swap,
   so it cannot turn the zoomed-out view back into the 43-pin soup the clusters replaced. */
.farm-pin.is-sub.quest { opacity: 1 !important; pointer-events: auto !important; z-index: 4; }
.farm-pin.quest .farm-pin-ico { border-color: #ffd23f;
  box-shadow: 0 0 0 2px #ffd23f, 0 0 12px #ffd23fcc; animation: farmPinPulse 1.1s ease-in-out infinite; }
.farm-pin.quest .farm-pin-label { opacity: 1; color: #ffd23f; }
/* ⚠️ Beats `.dimmed`'s 0.35 — a search that excludes the quest zone should grey it like any
   other miss, but it must never become invisible while it is still the goal. */
.farm-map[data-zoomed="1"] .farm-pin.is-sub.quest.dimmed { opacity: 0.7 !important; }

.farm-pin.is-boss { opacity: 1 !important; pointer-events: auto !important; z-index: 3; }
.farm-pin.is-boss .farm-pin-ico { filter: drop-shadow(0 0 6px #f4d23c); }
.farm-pin.is-boss .farm-pin-label { opacity: 1; color: #f4d23c; }
.farm-map[data-zoomed="1"] .farm-pin.is-sub { opacity: 1; pointer-events: auto; }
/* ⚠️ `.farm-pin.is-sub` ships `opacity: 0; pointer-events: none` and is switched on only by the
   rule above, so a locked pin has to be written into THIS cascade or it inherits the hidden base
   and is simply absent past the expand threshold.
   ⚠️ **This rule sits ABOVE `.dimmed` on purpose.** The two selectors have identical specificity,
   so the later one wins whenever a pin is both — and a pin that is out of reach AND filtered out
   of the search has to read as *filtered*, which is the thing the player just did. Swap the two
   lines and a locked pin would stay brighter than the reachable ones the filter rejected, which
   is backwards.
   0.55 against `.dimmed`'s 0.35: faded, but still clearly present, because unlike a filtered pin
   this one is a real destination the player is working toward. */
.farm-map[data-zoomed="1"] .farm-pin.is-sub.locked { opacity: 0.55; pointer-events: auto; }
.farm-map[data-zoomed="1"] .farm-pin.is-sub.dimmed { opacity: 0.35; }
.farm-map[data-zoomed="1"] .farm-pin.is-cluster { opacity: 0; pointer-events: none; }
/* Cluster names at rest, once the camera is actually looking at a region.
   ⚠️ Keyed to `data-labeled`, NOT `data-zoomed` — that one is the EXPAND threshold, which
   hides cluster pins entirely, so a label hung off it could never appear. `data-labeled`
   crosses just below the region framing zoom, which is where the picker opens: names are
   on by default, and switch off only when you zoom out toward the whole world, where all
   16 Kanto clusters crowd into ~12% of the viewport and the labels would pile up. */
.farm-map[data-labeled="1"] .farm-pin.is-cluster .farm-pin-label { opacity: 1; }

/* GM world-map debug overlay (GM Tools → "Debug world map"). Markup only exists for GMs;
   these rules only bite once the hook puts `.dbg` on `.farm-map`.

   The layer is panned and sized like `.farm-pin-layer` and for the same reason: a box
   authored in world percent has to land on the artwork, and its label has to stay readable
   at 8x without being resampled. `pointer-events: none` so the ruler and the pins keep
   receiving the gestures — the boxes are output, not controls. */
.farm-dbg-layer { display: none; position: absolute; left: 0; top: 0; width: 100%; height: 100%;
  transform-origin: 0 0; pointer-events: none; z-index: 4; }
.farm-map.dbg .farm-dbg-layer { display: block; }
.farm-map.dbg { cursor: crosshair; }
/* Cluster pins become draggable handles while debugging; sub-pins are derived, not authored,
   so they stay untouched. */
.farm-map.dbg .farm-pin.is-cluster { cursor: move; }

.farm-dbg-box { position: absolute; border: 2px dashed #46e0ff; background: #46e0ff14;
  box-shadow: 0 0 0 1px #0008; }
.farm-dbg-box > b { position: absolute; left: 0; top: -17px; white-space: nowrap;
  font-family: var(--pixel); font-size: 8px; line-height: 1; color: #0b1220;
  background: #46e0ff; padding: 3px 4px; }
.farm-dbg-box.is-new { border-color: #ffd23f; background: #ffd23f1c; }
.farm-dbg-box.is-new > b { background: #ffd23f; top: auto; bottom: -17px; }

.farm-dbg-panel { display: none; }
.farm-dbg-panel.on { display: block; width: 100%; max-width: 900px; margin: 6px auto 0;
  padding: 8px 10px; background: #0b1220; border: 2px solid var(--edge); font-size: 12px; }
.farm-dbg-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline;
  font-family: var(--pixel); font-size: 9px; color: #46e0ff; }
.farm-dbg-head .muted { font-family: inherit; font-size: 8px; }
.farm-dbg-live { margin: 6px 0; font-family: ui-monospace, Menlo, Consolas, monospace;
  font-size: 11.5px; color: var(--text); min-height: 14px; }
.farm-dbg-code { display: flex; align-items: flex-start; gap: 8px; margin-top: 5px;
  padding: 5px 6px; background: #00000055; border-left: 3px solid #46e0ff; }
.farm-dbg-code.is-new { border-left-color: #ffd23f; }
.farm-dbg-code code { flex: 1; font-family: ui-monospace, Menlo, Consolas, monospace;
  font-size: 11.5px; color: #cfe8ff; white-space: pre-wrap; word-break: break-word; }
.farm-dbg-copy { flex: none; cursor: pointer; font-family: var(--pixel); font-size: 8px;
  color: var(--text); background: var(--panel); border: 2px solid var(--edge); padding: 4px 6px; }
.farm-dbg-copy:hover { background: var(--panel-2); }

.farm-hint { text-align: center; font-size: 12.5px; margin: 2px 0 0; }

/* locked-region browse scrim (non-Kanto maps are viewable but not playable yet) */
.farm-map.locked { filter: saturate(0.8) brightness(0.92); }
/* ⚠️ z-index is load-bearing, not decoration. `.farm-pin` is z-index 2 and `.farm-pin.match`
   is 3, so despite this scrim rendering AFTER the pins in source order it used to paint
   UNDER them: on a fainted team the map dimmed while the pins stayed bright and still looked
   clickable. 4 puts it over both pin states but under `.farm-zoom-ctl` (5), so a locked
   region can still be panned and zoomed while you browse it. */
.farm-map-scrim { position: absolute; inset: 0; z-index: 4; display: grid; place-items: center;
  background: linear-gradient(180deg, #0003, #0009); pointer-events: none; }
.farm-map-soon { font-family: var(--pixel); font-size: 11px; color: #fff; letter-spacing: 0.5px;
  background: #000b; border: 2px solid var(--edge); box-shadow: var(--shadow); padding: 9px 14px; }

/* popover (cluster list / zone detail) + coming-soon */
.farm-pop-backdrop { position: fixed; inset: 0; z-index: 300; background: #000a;
  display: flex; align-items: center; justify-content: center; padding: 16px; }
.farm-pop { position: relative; width: min(440px, 100%); max-height: calc(88vh * var(--ui-inv)); overflow-y: auto;
  background: var(--panel); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 18px 18px 20px; }
.farm-pop-close { position: absolute; top: 8px; right: 8px; cursor: pointer; font-size: 16px;
  background: transparent; border: 0; color: var(--muted); }
.farm-pop h3 { margin: 0 0 8px; font-family: var(--pixel); font-size: 13px; color: var(--primary-2);
  text-shadow: 2px 2px 0 var(--edge); }
.farm-soon { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.farm-soon b { font-family: var(--pixel); font-size: 11px; color: #c9a6ff; text-shadow: 2px 2px 0 var(--edge); }
.farm-soon p { margin: 0; color: var(--text); font-size: 14px; line-height: 1.5; }

/* cluster sub-list */
.farm-cluster-list { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.farm-zrow { display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
  padding: 10px 12px; cursor: pointer; text-align: left; color: var(--text);
  background: var(--panel-2); border: 2px solid var(--border); }
.farm-zrow:hover { border-color: var(--edge); }
.farm-zrow.match { border-color: #ffd23f; box-shadow: 0 0 0 1px #ffd23f88; }
/* Shown and marked, never hidden — the list already hides non-matches, and hiding a locked zone
   removes the only visible reason to level up. */
/* The phone picker's version of the faded pin. ⚠️ Much gentler than the map's 0.55: a row is
   read as TEXT, and this list already HIDES non-matches rather than dimming them (the map dims
   to keep its shape legible — a list has no shape to keep), so there is no fade here for a
   locked row to be confused with. Fading a row as far as a pin would just make it hard to read
   the level it is telling the player to reach. */
.farm-zrow.locked { opacity: 0.8; }
.farm-zrow.locked .farm-zrow-name { color: var(--muted); }
.farm-zrow.locked .farm-zrow-ico { filter: grayscale(0.8) brightness(0.75); }
.farm-zrow-lock, .farm-detail-lock { color: var(--muted); border-style: dashed; }
/* The quest's zone on the phone picker, which has no map to aim. Same gold as the pin, plus
   the pulse — it is hoisted to the top of the list server-side, so this only has to say
   "this is the one", not "hunt for it". */
.farm-zrow.quest { border-color: #ffd23f; box-shadow: 0 0 0 1px #ffd23f88, 0 0 10px #ffd23f55;
  animation: farmPinPulse 1.1s ease-in-out infinite; }
.farm-zrow-name { font-weight: 700; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
/* ⚠️ The zone name is the flexible half of this row — the badge must not be squashed when a
   long name wraps. Same rule and same reason as the .type-ico in the skill tooltip. */
.farm-zrow-name .type-ico { flex: 0 0 auto; }
.farm-zrow-flag { color: #ffd23f; }
/* The sky over a zone, on a row that has no room to say anything about it. Icon only — the
   native `title` carries the sentence, and the detail pane is one tap away. The verdict tints
   the BORDER rather than the art: the icons are 48px pixel sprites and a CSS filter over them
   would fight `image-rendering: pixelated`. */
.farm-zrow-wx { display: inline-flex; align-items: center; flex: 0 0 auto; padding: 1px;
  border-bottom: 2px solid transparent; }
/* ⚠️ Green follows the NERFED side, not the boosted one, and that inversion is deliberate:
   since 2026-08-14 a weather-boosted wild hits harder AND resists the ball, so the sky
   favouring this zone's spawns is bad news for the player. The colour answers "is this good
   for me", which is how a player reads it — not "is this type amped". */
.farm-zrow-wx.is-boost { border-bottom-color: var(--danger); }
.farm-zrow-wx.is-nerf { border-bottom-color: var(--green); }
.farm-zrow-wx.is-mixed { border-bottom-color: var(--muted); }

/* ---- the phone picker ------------------------------------------------------
   Rendered INSTEAD of the world map when the socket reports `compact` (see
   compactPicker() in app.js). Reuses .farm-zrow, so a row looks the same here as it does in
   the cluster popover the map opens.

   No media query: the server decided. A width rule here would fight that decision — the map
   is simply not in the DOM on this path, so there is nothing to swap back to. */
.farm-list { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
/* Rows are the whole tap target, so they get the full width and a comfortable height rather
   than the popover's tighter padding. 44px is the usual floor for a thumb. */
.farm-list .farm-zrow { width: 100%; min-height: 44px; flex-direction: row; align-items: center;
  justify-content: space-between; gap: 10px; }
.farm-list .farm-zrow .chip { flex: none; }
/* The row is `space-between` with three children now, which would strand the weather icon in
   the middle of the gap. Pushing it right groups it with the level chip and leaves the name as
   the one thing on the left, which is the scan order this list was built for. (The cluster
   popover's .farm-zrow is a column and wants no such rule — the icon gets its own line there.) */
.farm-list .farm-zrow .farm-zrow-wx { margin-left: auto; }
.farm-zrow-ico { font-size: 16px; line-height: 1; }
/* The world boss row — the phone's replacement for `.farm-pin.is-boss`, which lives inside the
   map layer this path does not render. Same gold as the pin and the same glow on the glyph, so
   a player who has seen the map on a desktop recognises the row, and one who never has still
   reads it as the odd row out among 43 farms. No pulse: `.farm-zrow.quest` owns that, and two
   animated rows at the top of one list compete instead of pointing. */
.farm-list .farm-zrow.is-boss { border-color: #f4d23c; box-shadow: 0 0 0 1px #f4d23c55; }
.farm-zrow.is-boss .farm-zrow-name { color: #f4d23c; }
.farm-zrow.is-boss .farm-zrow-ico { filter: drop-shadow(0 0 6px #f4d23c); }
.farm-zrow-boss { color: #f4d23c; border-color: #f4d23c88; }
.farm-list-empty { margin: 14px 2px; }

/* ---- the world boss brief ------------------------------------------------------------------
   The pane the boss entrance opens instead of the lair. Deliberately the same `.farm-pop` box as
   the zone detail rather than a `.game-modal` of its own: a player reaches it by clicking a pin on
   the same map, one pin over from 43 farms that all answer with that box, and answering the
   hardest destination in a different shape would read as a different feature.

   Gold (#f4d23c) is already this map's boss accent — `.farm-pin.is-boss` and `.farm-zrow-boss`
   above — so nothing new is introduced here; the brief simply carries it inward. */
.farm-boss-brief { display: flex; flex-direction: column; gap: 10px; }

.farm-boss-head { display: flex; align-items: center; gap: 12px; }
/* The bird at the size the Depot draws a Pokémon, `pixelated` like every other still on this
   screen — a smoothed 96px sprite is the one thing here that would not read as pixel art. */
.farm-boss-sprite { width: 72px; height: 72px; flex: none; image-rendering: pixelated;
  filter: drop-shadow(0 0 8px #f4d23c55); }
.farm-boss-id { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
/* `.farm-pop h3` already sets the pixel face and the bottom margin the zone name uses; here the
   name sits in a flex column, so that margin would open a gap under it instead. */
.farm-boss-id h3 { margin: 0; color: #f4d23c; }
.farm-boss-types, .farm-boss-weak { display: flex; align-items: center; gap: 5px; }
.farm-boss-weak .muted { font-size: 12px; }

.farm-boss-chips { display: flex; flex-wrap: wrap; gap: 5px; }
/* ⚠️ Muted for "claimed", not red. Having already had your drop is not an error and not a lock —
   the fight is open, and somebody reading this chip has usually come back on purpose to help. The
   unclaimed chip carries the map's boss gold, because that one IS the reason to go. */
.farm-boss-claimed { color: var(--muted); border-color: var(--border); }
.farm-boss-unclaimed { color: #f4d23c; border-color: #f4d23c88; }

/* A section heading, matching the hover card's `.farm-card-sect` rather than inventing a third
   one — the two panes are read one after the other and should sound the same. */
.farm-boss-sect { font-family: var(--pixel); font-size: 8px; line-height: 1.4;
  color: var(--muted); letter-spacing: 0.5px; }

/* ⚠️ Three prose sentences, so they are `<li>`s with a readable measure and NOT the tight
   `.farm-drop-row` grid the tabs use. A mechanic explained in a table row is a mechanic nobody
   reads. `list-style: none` plus a hand-drawn marker keeps the bullet inside the 440px pane
   instead of hanging off its padding. */
.farm-boss-mechs { list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: 7px; }
.farm-boss-mechs li { position: relative; padding-left: 12px;
  font-size: 12px; line-height: 1.5; color: var(--text); }
.farm-boss-mechs li::before { content: ""; position: absolute; left: 0; top: 7px;
  width: 4px; height: 4px; background: #f4d23c; }

.farm-boss-rewards { display: flex; flex-direction: column; gap: 7px; }
.farm-boss-reward { display: flex; align-items: center; gap: 8px; }
/* The pool is a strip of sprites, because "one of these five" is a fact about a SET — listing
   five named rows would read as five drops. The sentence beside it says how many you get. */
.farm-boss-pool { display: flex; align-items: center; gap: 3px; flex: none; }
.farm-boss-pool .dr-row { display: flex; align-items: center; }
.farm-boss-pool .dr-sprite { width: 26px; height: 26px; image-rendering: pixelated; }
.farm-boss-reward-note { font-size: 12px; line-height: 1.4; color: var(--muted); min-width: 0; }

/* zone detail + teleport */
.farm-detail { display: flex; flex-direction: column; gap: 10px; }
.farm-detail-back { align-self: flex-start; cursor: pointer; background: transparent; border: 0;
  color: var(--muted); font-size: 12px; padding: 0; }
.farm-detail-counter { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.farm-detail-mons .muted { font-size: 12px; }

/* ---- the zone detail's four tabs ----------------------------------------------------------
   Deliberately the SAME control as `.prof-subtab` in the crafting panel — same underline, same
   pixel face — so it is learned once. It is not literally that class because the two live in
   boxes of different widths: this strip carries up to four labels inside a 440px pane, so its
   padding is tighter and it is allowed to wrap.

   ⚠️ `border: 0` is what `button` needs to stop the UA drawing a box round each label, which
   would read as four separate buttons rather than one strip with a current item — the note on
   `.prof-sub` says the same thing and it is just as load-bearing here. */
.farm-detail-tabs { display: flex; flex-wrap: wrap; gap: 2px; border-bottom: 2px solid var(--border); }
.farm-dtab { border: 0; border-bottom: 2px solid transparent; margin-bottom: -2px;
  background: transparent; color: var(--muted); cursor: pointer; padding: 7px 10px;
  font-family: var(--pixel); font-size: 8px; line-height: 1.4; }
.farm-dtab:hover { color: var(--text); background: var(--panel); }
.farm-dtab.on { color: var(--text); border-bottom-color: var(--primary); background: var(--panel); }

/* ⚠️ **The PANEL scrolls, not the modal.** A tier-7 zone lists 31 foraging rows and ~19 refining
   ones; letting those grow `.farm-pop` pushes the teleport button — the reason the pane exists —
   off the bottom, and on a phone `.farm-pop` is already full-bleed and scrolling, so the button
   would simply be gone until the player scrolled back up. Capped in `vh` rather than `px` so a
   short viewport gets a shorter list instead of an unreachable button. */
.farm-detail-pane { display: flex; flex-direction: column; gap: 4px;
  max-height: calc(42vh * var(--ui-inv)); overflow-y: auto; }

/* One drop row. Same three-part flex as `.farm-mon-line` above and for the same reasons: the
   name column flexes and ellipsises so a long item never pushes the figure off the pane, and the
   figure is `tabular-nums` so the column does not jitter between "0.9%" and "12.9%". */
.farm-drop-row { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.farm-drop-id { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.farm-drop-id img { width: 24px; height: 24px; flex: none; image-rendering: pixelated; }
.farm-drop-id b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.farm-drop-qty { font-style: normal; font-size: 11px; color: var(--muted); flex: none; }
.farm-drop-pct { width: 52px; flex: none; text-align: right; white-space: nowrap;
  font-variant-numeric: tabular-nums; }
.farm-drop-note { font-size: 11px; margin: 6px 2px 0; }
.farm-drop-gate { flex: none; font-size: 10px; color: var(--muted); border-style: dashed; }
/* A locked Refining row is faded, never hidden — it is the only visible reason to level the
   profession, the same call `.farm-zrow.locked` makes for a zone above the trainer's level. The
   art (an item icon) is greyed with it so the row reads as one dimmed thing rather than as a
   bright sprite beside grey text. */
.farm-drop-row.locked { opacity: 0.62; }
.farm-drop-row.locked img { filter: grayscale(0.7); }
/* The roam-here roster is a small TABLE now, not a sprite grid — see the comment on it in
   farm_live.ex. Columns are held by giving every `.farm-mon-cell` the same fixed width and
   letting `.farm-mon-id` take the rest, so the header and the rows line up with no grid
   template to keep in step with a ball count that is derived at runtime.

   `.farm-mon-id` needs `min-width: 0` or a long species name refuses to shrink and pushes the
   percentages off the pane on a narrow phone; the name ellipsises instead. `tabular-nums` is
   what stops the column jittering between "8.4%" and "18.4%". */
.farm-mon-row { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
.farm-mon-head, .farm-mon-line { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.farm-mon-id { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.farm-mon-id b { text-transform: capitalize; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
.farm-mon-lv { font-style: normal; font-size: 11px; color: var(--muted); flex: none; }
/* "weak species ×1.9" — why this row prices higher than its capture rate suggests. Same
   size and colour as the level chip so it reads as a fact about the spawn, not a warning. */
.farm-mon-weak { font-style: normal; font-size: 11px; color: var(--muted); flex: none; white-space: nowrap; }
.farm-mon-cell { width: 46px; flex: none; text-align: right; white-space: nowrap;
  font-variant-numeric: tabular-nums; }
/* The traffic light. Green means GOOD FOR THE PLAYER — the same thing green means on the
   weather lines, where the class names describe the sky and the colours describe you. The
   thresholds are in throws, not percentages; `FarmLive.catch_grade/1` owns them and the
   caption's legend is bound from the same constants. Colour is a SECOND encoding — the figure
   is always rendered beside it — so nothing is lost without it. */
.farm-mon-cell.is-good { color: var(--green); }
.farm-mon-cell.is-fair { color: var(--accent); }
.farm-mon-cell.is-poor { color: var(--danger); }
.farm-mon-head .farm-mon-cell { line-height: 0; }
.farm-mon-ball { width: 20px; height: 20px; image-rendering: pixelated; }
.farm-mon-note { margin: 6px 0 0; font-size: 11px; }
.farm-mon { width: 32px; height: 32px; flex: none; image-rendering: pixelated;
  border: 2px solid var(--border); background: var(--bg-2); }
.farm-mon.hot { border-color: #ffd23f; box-shadow: 0 0 8px #ffd23fbb; }
.farm-teleport, .farm-boss-enter { justify-content: center; width: 100%; }

/* ---- pin hover card ----
   The sources are server-rendered next to the map and never shown; FarmMapZoom copies one
   into #farm-card, which is fixed-position on <body> so it escapes `.farm-map`'s
   overflow:hidden. z-index sits below #global-tip (500) and above .farm-pop-backdrop (300).
   Note this needs its own display:none — `.gtip-body`'s rule is scoped to that class. */
.farm-card-src-layer { display: none; }
#farm-card { position: fixed; z-index: 480; width: 300px; padding: 9px 11px;
  background: #0e1520; border: 2px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  font-size: 12px; line-height: 1.4; pointer-events: none; }
.farm-card-h { margin: 0 0 5px; font-size: 14px; }
.farm-card-chips { display: flex; flex-wrap: wrap; gap: 4px; }
/* the label and its badge sit on one baseline inside the chip */
.farm-counter-chip { display: inline-flex; align-items: center; gap: 4px; }
.farm-card-zones .type-ico { flex: 0 0 auto; margin: 0 2px; }
.farm-card-sect { margin: 8px 0 4px; font-family: var(--pixel); font-size: 8px;
  color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.farm-card-mons { display: flex; flex-wrap: wrap; gap: 6px; }
.farm-card-mon { display: flex; flex-direction: column; align-items: center; width: 52px;
  gap: 1px; text-align: center; }
.farm-card-mon img { width: 38px; height: 38px; image-rendering: pixelated; border-radius: 50%;
  border: 2px solid var(--border); background: var(--bg-2); }
.farm-card-mon b { font-size: 9.5px; font-weight: 600; line-height: 1.1;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 52px; }
.farm-card-mon .farm-card-lv { font-size: 9px; font-style: normal; color: var(--muted); }
.farm-card-mons.is-tight .farm-card-mon { width: auto; }
.farm-card-drops { display: flex; flex-direction: column; gap: 2px; }
.farm-card-drop { display: flex; align-items: center; gap: 6px; }
.farm-card-drop img { width: 20px; height: 20px; image-rendering: pixelated; }
.farm-card-drop b { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
.farm-card-drop i { font-style: normal; font-size: 11px; color: var(--muted); white-space: nowrap; }
.farm-card-more { font-size: 11px; color: var(--muted); align-self: center; }
.farm-card-zones { display: flex; flex-direction: column; gap: 1px; margin-top: 7px; }
.farm-card-zones span { display: flex; justify-content: space-between; gap: 8px; font-size: 11.5px; }
.farm-card-zones i { font-style: normal; color: var(--muted); white-space: nowrap; }
.farm-card-foot { margin-top: 7px; font-family: var(--pixel); font-size: 7px; color: #ffe9a8; }

/* ---- the sky over a zone (hover card + detail pane) ----
   Modelled on .ha-wx, the Hunt Analyzer's weather pill, and quiet for the same reason: it is a
   label, not a control, so no border and no hover. This block answers "what is the sky doing to
   the wilds HERE", which is why it sits beside the counter-type advice and never inside it —
   that one is a permanent property of the spawn pool, this one is true for 90 minutes.

   ⚠️ The direction is carried by COLOUR and by the sentence, never by an arrow glyph: the repo
   art rule forbids introducing an emoji into a player-facing string (OS font, off-palette,
   off-grid), and ⬆/⬇ are exactly that. */
.farm-wx { display: flex; flex-direction: column; gap: 2px; margin-top: 7px; }
.farm-wx-head { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; }
/* 48px source art shown at 16, nearest-neighbour like the whole client. Same rule and same
   size as .ha-wx-ico, which renders these very files. */
.farm-wx-ico { width: 16px; height: 16px; image-rendering: pixelated; flex: 0 0 auto; }
.farm-wx-line { font-size: 11px; line-height: 1.35; }
/* `up`/`down` name the sky's effect on the TYPE; the colour names the effect on the PLAYER,
   and since 2026-08-14 those are opposites — see .farm-zrow-wx above. */
.farm-wx-line.up { color: var(--danger); }
.farm-wx-line.down { color: var(--green); }
/* The detail pane is the last screen before Teleport and has the width for it, so it reads a
   step larger than the 300px hover card. */
.farm-detail .farm-wx { margin-top: 0; gap: 3px; }
.farm-detail .farm-wx-head { font-size: 13px; }
.farm-detail .farm-wx-ico { width: 22px; height: 22px; }
.farm-detail .farm-wx-line { font-size: 12px; }

/* Type-tinted rings on the species circles — same hues as the .type-* chips. dark/steel/fairy
   aren't gen-1 and can't spawn today, but types_present is read from the DB at runtime. */
.tring-normal { border-color: #9099a1 !important; } .tring-fire { border-color: #ff9d55 !important; }
.tring-water { border-color: #4d90d5 !important; } .tring-electric { border-color: #f4d23c !important; }
.tring-grass { border-color: #63bc5a !important; } .tring-ice { border-color: #73cec0 !important; }
.tring-fighting { border-color: #ce4069 !important; } .tring-poison { border-color: #ab6ac8 !important; }
.tring-ground { border-color: #d97746 !important; } .tring-flying { border-color: #8fa8dd !important; }
.tring-psychic { border-color: #fa7179 !important; } .tring-bug { border-color: #90c12c !important; }
.tring-rock { border-color: #c7b78b !important; } .tring-ghost { border-color: #5269ad !important; }
.tring-dragon { border-color: #0b6dc3 !important; } .tring-dark { border-color: #5a5366 !important; }
.tring-steel { border-color: #5a8ea6 !important; } .tring-fairy { border-color: #ec8fe6 !important; }

@media (max-width: 560px) {
  .farm-map { max-width: 100%; }
  .farm-pin-ico { width: 26px; height: 26px; font-size: 14px; }
  #farm-card { width: min(300px, calc(100vw - 24px)); }
  .farm-filter-label { min-width: 0; }
}

/* A tap fires mouseover, so on touch the card would appear and stay. The hook also bails
   on non-mouse pointers; this is the belt to that pair of braces. */
@media (hover: none) { #farm-card { display: none !important; } }

/* ---------------- HUD buffs & debuffs ---------------- */
/* Centred under `.team-hud`, not stacked in a side rail — what is in force right now belongs
   next to the team it is in force on.
   ⚠️ It no longer anchors itself: it is an item of `.hud-center`, which owns the band and the
   order. It used to be `position: fixed; top: var(--hud-rail-top)`, i.e. the same pixel as
   `.farm-settling`, and once it wrapped to a second row it ran into `.target-frame` too.
   `width: 100%` so it wraps against the column rather than the widest chip.
   ⚠️ `pointer-events: none` on the strip and `auto` on the chips is still load-bearing, even
   though the column is `none` as well: this row is `width: 100%` over the world canvas, so
   without it a full-width invisible bar eats clicks meant for the game. The chips still need
   to receive hover, or their tooltip never fires. */
.buffs-hud { width: 100%;
  display: flex; flex-direction: row; justify-content: center; align-items: flex-start;
  gap: 6px; flex-wrap: wrap; pointer-events: none; }
.buffs-hud > .buff-chip { pointer-events: auto; }
.buff-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px 4px 4px;
  background: var(--bg); border: 2px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border);
  font-size: 12px; color: var(--text); }
.buff-ico { width: 26px; height: 26px; image-rendering: pixelated; border: 1px solid var(--edge); background: var(--bg-2); }
.buff-text { white-space: nowrap; }
@media (max-width: 640px) { .buffs-hud { display: none; } }

/* ---- Trainer skins (SS shop grid + creation picker) ---- */
.skin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); gap: 10px; }
.skin-card { display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 10px 8px; border: 2px solid var(--edge); border-radius: 10px; background: var(--bg-2);
  text-align: center; cursor: default; }
.skin-card.equipped { border-color: var(--accent, #ffd447); box-shadow: inset 0 0 0 1px var(--accent, #ffd447); }
/* Every trainer sheet is 9 cols x 8 rows of --sk-cell: col 0 of each row is the standing
   frame, cols 1-8 are the walk cycle, rows are S SE E NE N NW W SW. An emote sheet is the
   same grid with NO stand column, so all 9 cols are frames. Sizing off one custom property
   is what lets the 64px card and the 128px detail preview share one set of keyframes. */
.skin-card .skin-preview, .skin-show, .skin-emote-play { --sk-cell: 64px;
  width: var(--sk-cell); height: var(--sk-cell); image-rendering: pixelated;
  background-repeat: no-repeat; background-position: 0 0;
  background-size: calc(9 * var(--sk-cell)) calc(8 * var(--sk-cell)); }
.skin-card .skin-name { font-size: 13px; font-weight: 600; }
.skin-card .skin-emotes { display: flex; gap: 3px; min-height: 18px; font-size: 15px; line-height: 1; }
.skin-card .btn { width: 100%; justify-content: center; margin-top: 4px; font-size: 12px; }
/* rarity ribbon + can't-afford state (SS shop only; the profile grid shows owned looks,
   which are by definition affordable, and the creation picker is free) */
.skin-card { position: relative; }
.skin-rar { position: absolute; top: 4px; right: 6px; font-size: 9px; text-transform: uppercase;
  letter-spacing: 0.04em; font-weight: 800; color: var(--accent); }
.skin-card.locked { opacity: 0.62; filter: saturate(0.75); }
.skin-card.locked .btn.primary { background: var(--panel-2); color: var(--muted);
  box-shadow: inset 0 0 0 2px var(--border); }
/* The card's openable region. It is a button so it is keyboard-reachable, and it is a
   SIBLING of the buy/equip button rather than its ancestor — nested, a purchase click would
   also fire the card's preview event. */
.skin-open { display: flex; flex-direction: column; align-items: center; gap: 4px;
  width: 100%; padding: 0; border: 0; background: none; font: inherit; color: inherit;
  cursor: pointer; }
.skin-open:hover .skin-preview { transform: translateY(-2px); }

/* ---- skin detail view (walk in all 8 directions + the emotes) ---- */
.skin-detail { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.skin-detail .ss-back { align-self: flex-start; }
.skin-show-wrap { display: flex; align-items: center; justify-content: center; gap: 18px;
  flex-wrap: wrap; }
.skin-show { --sk-cell: 128px;
  animation: skin-walk 0.667s steps(8) infinite, skin-turn 5.333s steps(8) infinite; }
.skin-show-meta { display: flex; flex-direction: column; gap: 4px; text-align: left; }
.skin-show-name { font-size: 18px; font-weight: 700; }
.skin-rar-inline { position: static; align-self: flex-start; }
.skin-emote-row { width: 100%; }
.skin-emote-title { margin: 0 0 6px; font-size: 13px; }
.skin-emote-list { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; }
.skin-emote-item { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.skin-emote-play { animation: skin-emote 0.75s steps(9) infinite; }
.skin-detail .btn { min-width: 180px; justify-content: center; }
@media (max-width: 560px) {
  .skin-show { --sk-cell: 96px; }
}

/* creation picker: the card is a clickable radio label */
label.skin-pick { cursor: pointer; }
label.skin-pick input { position: absolute; opacity: 0; pointer-events: none; }
label.skin-pick:has(input:checked) { border-color: var(--accent, #ffd447);
  box-shadow: inset 0 0 0 1px var(--accent, #ffd447); }

/* ---- SS store (showcase + Buy Star Shards) ---------------------------------
   Reuses the shared design-system grid already in this file: .plans/.plan/.tag,
   .ss-amount/.ss-total/.ss-bonus, .vip-hint, .tabs/.tab. Only the pieces that did
   not exist yet live below. */
.ss-store-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.ss-store-head h1 { margin: 0; }
.ss-balance { margin-left: auto; display: flex; align-items: center; gap: 8px; }

/* The Gold badge had no Star Shard twin — same shape, shard art, shard purple. The badge
   and the buy button share ONE gradient declaration: they are meant to read as the same
   currency, and two hand-written copies of a gradient drift the moment either is tweaked. */
.ss-badge,
.ss-buy { background: linear-gradient(90deg, #2a2352, #6d5bd0); color: #f4f0ff;
  border: 2px solid var(--edge); box-shadow: var(--shadow); }
.ss-badge { font-weight: 800; padding: 8px 14px 8px 10px; font-size: 17px;
  font-variant-numeric: tabular-nums; display: inline-flex; align-items: center; gap: 6px; }
.ss-badge::before { content: ""; width: 24px; height: 24px; flex: none;
  background: url("/assets/game/ui/star-shards.png") center/contain no-repeat;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
.ss-badge span { font-size: 12px; font-weight: 700; opacity: 0.8; }

.ss-buy { font-family: var(--pixel); font-size: 9px; line-height: 1.5; padding: 10px 12px;
  text-transform: uppercase; cursor: pointer; flex: none; }
.ss-buy:hover { background: linear-gradient(90deg, #3a2f72, #8a76ee); }
.ss-buy:active { transform: translate(3px, 3px); box-shadow: none; }
/* inline in a text run (dashboard header, farm VIP lock) rather than beside a badge */
.ss-buy.tiny { font-size: 8px; padding: 5px 8px; box-shadow: 2px 2px 0 rgba(3, 7, 14, 0.55);
  vertical-align: 1px; margin-left: 6px; }
.ss-buy.tiny:active { transform: translate(2px, 2px); }

/* featured hero — the one limited offer, given the room to actually sell itself */
.ss-hero { display: flex; gap: 18px; align-items: center; flex-wrap: wrap;
  background: linear-gradient(100deg, var(--panel-2), var(--panel)); padding: 18px;
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--primary), var(--shadow);
  margin-bottom: 18px; }
/* A bundle that grants a banner is sold ON that banner. The inline style supplies the
   image; these rules position it and put the flat gradient back as a scrim so the copy
   stays readable over art we do not control (same problem as the profile header). */
.ss-hero.has-banner { position: relative; background-size: cover; background-position: center;
  background-repeat: no-repeat; image-rendering: pixelated; }
.ss-hero.has-banner::before {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(100deg, rgba(8,17,32,0.88) 0%, rgba(8,17,32,0.72) 45%, rgba(8,17,32,0.42) 100%);
}
.ss-hero.has-banner > * { position: relative; z-index: 1; }
.ss-hero.has-banner .ss-hero-grants,
.ss-hero.has-banner .ss-hero-body h2 { text-shadow: 0 2px 0 rgba(3,7,14,0.8); }

.ss-hero-art { width: 96px; height: 96px; flex: none; image-rendering: pixelated;
  background-repeat: no-repeat; background-position: 0 0; background-size: 864px 768px; }
.ss-hero-body { flex: 1; min-width: 220px; }
.ss-hero-body h2 { margin: 6px 0 4px; font-family: var(--pixel); font-size: 13px; line-height: 1.5; }
.ss-hero-tag { display: inline-block; background: var(--primary); color: var(--edge);
  font-family: var(--pixel); font-size: 8px; padding: 5px 10px; border: 2px solid var(--edge);
  text-transform: uppercase; }
.ss-hero-grants { color: var(--text); font-size: 13.5px; margin: 0 0 6px; }
.ss-hero-note { color: var(--accent); font-size: 12px; font-weight: 700; margin: 0 0 4px; }
.ss-hero-buy { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
.ss-hero-buy b { font-size: 24px; font-weight: 850; letter-spacing: -0.02em; }
.ss-hero-buy s { font-size: 13px; }

/* unclaimed-daily-gift marker on its tab — a free reward must not hide behind a tab */
.ss-dot { display: inline-block; width: 7px; height: 7px; margin-left: 6px; vertical-align: 1px;
  background: var(--primary); border: 1px solid var(--edge); }

.ss-blurb { margin: 0 0 12px; font-size: 13px; }
.ss-tiny { font-size: 12px; }
/* The daily gift's 7-chest trail: seven tiles in a row, the 7th (the big chest) wider. Open
   chests dim, the next one glows and carries the Claim button. Phones wrap to two rows. */
.ss-gift-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  flex-wrap: wrap; margin-bottom: 10px; }
.ss-gift-head .muted { font-size: 12px; }
.ss-gift-trail { list-style: none; margin: 0; padding: 0; display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr)) minmax(0, 1.5fr); gap: 6px; }
.ss-chest { position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 18px 4px 8px; border: 2px solid var(--edge); background: var(--bg-2); min-width: 0; }
.ss-chest-no { position: absolute; top: 2px; left: 5px; font-size: 11px; color: var(--muted); }
/* The padlock: 16px pixel art, corner-pinned, on every chest still ahead. */
.ss-chest-lock { position: absolute; top: 3px; right: 3px; width: 16px; height: 16px;
  image-rendering: pixelated; }
.ss-chest-artbox { position: relative; display: grid; place-items: center; }
.ss-chest-art { width: 48px; height: 48px; image-rendering: pixelated; }
.ss-chest.big .ss-chest-art { width: 64px; height: 64px; }
/* The tick sits ON the opened chest at an integer 2x (32px from a 16px source). */
.ss-chest-check { position: absolute; width: 32px; height: 32px; image-rendering: pixelated; }
.ss-chest.big { border-color: var(--accent); background: linear-gradient(180deg, rgba(255, 201, 60, 0.12), var(--bg-2)); }
.ss-chest.open .ss-chest-art, .ss-chest.open .ss-chest-loot { opacity: 0.45; }
.ss-chest.next { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent) inset, 0 0 12px rgba(255, 201, 60, 0.45); }
.ss-chest-loot { display: flex; flex-wrap: wrap; justify-content: center; gap: 2px 6px; font-size: 11px;
  font-variant-numeric: tabular-nums; }
.ss-loot { display: inline-flex; align-items: center; gap: 2px; white-space: nowrap; }
.ss-loot img { width: 16px; height: 16px; image-rendering: pixelated; flex: none; }
.ss-chest-tag { margin-top: auto; font-size: 11px; color: var(--muted); padding: 1px 6px; }
.ss-chest-tag.done { color: var(--green); border: 1px solid var(--green); }
.ss-chest-tag.today { color: var(--accent); font-weight: bold; }
.ss-gift-claim { display: flex; justify-content: center; margin-top: 12px; }
.ss-gift-claim-btn { min-width: 220px; font-size: 15px; padding: 12px 28px;
  box-shadow: 0 0 14px rgba(255, 201, 60, 0.55); }
@media (max-width: 640px) {
  .ss-gift-trail { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .ss-chest.big { grid-column: span 3; }
}

/* Gifting an SS-shop entitlement to a friend (SsShopLive's gift picker).
   ⚠️ `ss-give-*`, not `ss-gift-*` — everything directly above belongs to the DAILY gift, which
   is a different feature. The two share the English word and nothing else.
   The picker is `position: absolute` inside `.ss-store`, not `fixed`: this shop is a nested
   live_render inside a modal that already owns the viewport, and a fixed overlay would escape
   it and cover the modal's own chrome. */
/* ⚠️ No width of its own. The Gift button sits in three layouts that already size their
   buttons — `.skin-card .btn` and `.ss-plan .btn` are both full-width, and `.pbanner-buy` is a
   flex row where a 100% button would wrap onto a line by itself. */
.ss-give-btn { font-size: 12px; }
.ss-store { position: relative; }
.ss-give-picker { position: absolute; inset: 0; z-index: 5; display: flex;
  align-items: flex-start; justify-content: center; padding: 24px 12px;
  background: rgba(6, 6, 8, 0.72); }
.ss-give-card { width: min(420px, 100%); max-height: 100%; overflow-y: auto;
  background: var(--panel); border: 2px solid var(--edge); box-shadow: var(--shadow);
  padding: 14px; }
.ss-give-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.ss-give-head b { font-size: 14px; line-height: 1.35; }
.ss-give-x { margin-left: auto; flex: none; padding: 2px 10px; font-size: 16px; line-height: 1; }
.ss-give-list { list-style: none; margin: 10px 0 0; padding: 0; display: flex;
  flex-direction: column; gap: 6px; }
.ss-give-list li { display: flex; align-items: center; gap: 10px; padding: 6px 8px;
  background: var(--bg-2); border: 2px solid var(--edge); }
.ss-give-who { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ss-give-list .btn { margin-left: auto; flex: none; }
/* "A gift for X" — on a bundle card above its price, and again on the payment panel. Flex so
   the way out sits beside it: a named recipient the buyer cannot drop is a trap in front of a
   card form. */
.ss-give-for { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  justify-content: center; font-size: 12px; font-weight: 700; margin: 0 0 8px; }
.ss-give-for .btn { font-size: 11px; padding: 2px 8px; }
/* The optional note. The <form> exists only to give the textarea a phx-change target — it must
   never wrap the friend list, or every Send button in it becomes a submit. */
.ss-give-note { margin: 8px 0 2px; }
.ss-give-note textarea { width: 100%; box-sizing: border-box; resize: vertical; min-height: 62px;
  background: var(--bg-2); color: var(--text); border: 2px solid var(--border); border-radius: 0;
  padding: 8px 10px; font: inherit; font-size: 13px; }
.ss-give-count { text-align: right; margin: 0 0 6px; }
/* The note as the recipient will see it, quoted on the card and on the payment panel.
   `pre-wrap` because the note keeps the newlines the player typed — the Mailbox renders it the
   same way, so the two previews agree. */
.ss-give-quote { white-space: pre-wrap; border-left: 3px solid var(--border); padding-left: 8px;
  margin: 0 0 8px; text-align: left; }

.ss-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.ss-plan { background: var(--bg-2); border: 2px solid var(--edge); padding: 14px; text-align: center; }
.ss-plan h4 { margin: 0 0 8px; font-family: var(--pixel); font-size: 10px; line-height: 1.5; }
.ss-plan-price { font-size: 26px; font-weight: 850; color: var(--accent); margin-bottom: 10px; }
.ss-plan-price small { font-size: 13px; color: var(--muted); font-weight: 700; }
.ss-plan .btn { width: 100%; justify-content: center; }

/* Buy Star Shards page */
.ss-back { margin-bottom: 12px; }
.ss-cur { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin: 0 0 10px; }
.ss-cur .tab { padding: 4px 10px; }
.plan.focused { box-shadow: inset 0 0 0 3px var(--accent), var(--shadow); }
.plan .was { display: block; font-size: 13px; color: var(--muted); text-decoration: line-through;
  font-weight: 600; letter-spacing: 0; }
.beta-pill { display: inline-block; vertical-align: middle; margin-left: 8px; background: var(--green, #4ade80);
  color: var(--edge); font-family: var(--pixel); font-size: 8px; padding: 4px 8px;
  border: 2px solid var(--edge); text-transform: uppercase; }
.ss-pay-row { display: flex; gap: 8px; margin-top: auto; padding-top: 14px; }
.ss-pay-row .btn { flex: 1; justify-content: center; }

/* A bundle the buyer already holds: green edge instead of the offer's primary edge, and no
   price or buy row at all. `position: relative` is NOT optional — `.plan .tag` is absolute and
   only `.plan.highlight` sets a containing block, so an owned pack that is not highlighted
   would otherwise fling its badge to the nearest positioned ancestor. */
.plan.owned { position: relative; box-shadow: inset 0 0 0 2px var(--green, #4ade80), var(--shadow); }
.plan.owned .ss-amount { opacity: 0.75; }
.plan .tag.owned-tag { background: var(--green, #4ade80); }
.ss-owned-note { margin-top: auto; padding-top: 14px; color: var(--muted); font-size: 13px; }

.ss-qr { background: #fff; padding: 8px; display: inline-block; border: 2px solid var(--edge); }
.ss-brcode { display: flex; gap: 6px; margin: 8px auto; max-width: 420px; }
.ss-brcode input { flex: 1; font-size: 11px; padding: 5px; }
.ss-stripe { max-width: 420px; margin: 0 auto; text-align: left; }
.ss-stripe .stripe-slot { margin: 10px 0; background: #fff; padding: 10px; border: 2px solid var(--edge); }
.ss-stripe .stripe-error { color: #f87171; font-size: 12px; margin: 4px 0; min-height: 16px; }
.ss-stripe .stripe-submit { width: 100%; justify-content: center; }
/* PayPal renders its own buttons into .paypal-slot; the white plate is there for the same
   reason Stripe's is — PayPal's marks assume a light ground and vanish on the panel. */
.ss-paypal { max-width: 420px; margin: 0 auto; text-align: left; }
.ss-paypal .paypal-slot { margin: 10px 0; background: #fff; padding: 10px; border: 2px solid var(--edge); min-height: 52px; }
.ss-paypal .paypal-error { color: #f87171; font-size: 12px; margin: 4px 0; min-height: 16px; }

@media (max-width: 720px) {
  .ss-balance { margin-left: 0; }
  .ss-hero-art { width: 64px; height: 64px; background-size: 576px 512px; }
}

/* ---------------- character-creation wizard (PlayLive :create) ----------------
   Full-viewport cinematic flow over generated key art: intro → name → starter →
   skin → sendoff. GameLive's .create-shell hosts the embedded live_render. */
.create-shell { min-height: 100vh; }
.wizard { position: relative; min-height: 100vh; display: flex; flex-direction: column;
  align-items: center; justify-content: center; padding: 48px 16px 32px; overflow: hidden; }
.wizard-bg { position: absolute; inset: 0; background-size: cover; background-position: center;
  image-rendering: pixelated; }
.wizard-bg-lab { background-image: url("/assets/game/ui/creation/lab.png"); }
.wizard-bg-sendoff { background-image: url("/assets/game/ui/creation/sendoff.png"); }
/* readability scrim over the key art — panels sit on --bg anyway, this keeps the
   edges calm and the progress dots legible */
.wizard-scrim { position: absolute; inset: 0;
  background: linear-gradient(rgba(8, 17, 32, 0.35), rgba(8, 17, 32, 0.85)); }
.wizard-progress { position: relative; z-index: 1; display: flex; gap: 10px; margin-bottom: 18px; }
.wizard-dot { width: 12px; height: 12px; border: 2px solid var(--edge); background: var(--bg-2);
  box-shadow: 1px 1px 0 var(--edge); }
.wizard-dot.done { background: var(--primary); }
.wizard-dot.on { background: var(--accent, #ffd447); }
.wizard-stage { position: relative; z-index: 1; width: min(640px, 100%); }
.wizard-panel { background: var(--bg); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 20px 22px;
  animation: wizard-in 0.25s ease-out; }
@keyframes wizard-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.wizard-title { font-family: var(--pixel); font-size: 13px; color: var(--primary-2);
  text-shadow: 2px 2px 0 var(--edge); margin: 0 0 16px; }
.wizard-dialogue { display: flex; gap: 14px; align-items: flex-start; }
.wizard-portrait { width: 96px; height: 96px; }
.wizard-nav { display: flex; justify-content: space-between; gap: 10px; margin-top: 18px; }
.wizard-name { margin-top: 8px; font-size: 13px; }
.wizard-name.ok { color: #7ee787; }
.wizard-name.err { color: #ff8f8f; }
/* Oak's reaction to the picked starter / the send-off line */
.wizard-react { display: flex; gap: 12px; align-items: center; margin-top: 16px; min-height: 84px;
  border: 2px solid var(--border); background: var(--bg-2); padding: 10px 12px; }
.wizard-react p { margin: 0; line-height: 1.5; }
.wizard-react .wizard-portrait { width: 64px; height: 64px; }
/* the pickers are <button>s here (no radios) — normalize + selected states */
.wizard .starter-card, .wizard .skin-card { font: inherit; color: inherit; }
.wizard .starter-card.picked { box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); background: var(--panel-2); }
.wizard .skin-pick { cursor: pointer; }
.wizard .skin-pick.picked { border-color: var(--accent, #ffd447);
  box-shadow: inset 0 0 0 1px var(--accent, #ffd447); }
/* live walk preview: the 8 walk frames of the south row. The sheets are 9x8 cells of
   64px (576x512) — col 0 of each row is the dedicated *stand* frame and the walk frames
   follow it, exactly as buildAnimsKey() slices them in app.js. So the cycle runs
   col 1 -> col 8, i.e. -64px -> -512px in 64px steps. Stepping 576/8 = 72px instead
   walked the window off-register a little more each frame, which read as sliding left. */
.skin-preview.walk { animation: skin-walk 0.9s steps(8) infinite; }
@keyframes skin-walk {
  from { background-position-x: calc(-1 * var(--sk-cell)); }
  to   { background-position-x: calc(-9 * var(--sk-cell)); }
}
/* Turning: background-position-x and -y are independent longhands, so the walk cycle and the
   facing can be two animations on one element with no JS. 8 rows at one full walk cycle each.
   Emotes sweep from col 0 (no stand frame) across all 9 and stay on row 0, facing the camera.
   Timings are the game's, not the wizard's: buildAnimsKey() runs trainers at 12fps and the
   emote manifest declares fps 12, so 8/12s and 9/12s. */
@keyframes skin-turn {
  from { background-position-y: 0; }
  to   { background-position-y: calc(-8 * var(--sk-cell)); }
}
@keyframes skin-emote {
  from { background-position-x: 0; }
  to   { background-position-x: calc(-9 * var(--sk-cell)); }
}
.wizard-summary { display: flex; gap: 18px; justify-content: center; margin: 16px 0 4px; }
.wizard-sum-item { display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: 12px 16px; border: 2px solid var(--border); background: var(--bg-2); min-width: 120px; }
.wizard-sum-item img { image-rendering: pixelated; }
.wizard-sum-item .skin-preview { width: 64px; height: 64px; image-rendering: pixelated;
  background-repeat: no-repeat; background-position: 0 0; background-size: 576px 512px; }
.wizard-sum-label { font-family: var(--pixel); font-size: 9px; }
@media (max-width: 560px) {
  .wizard { padding-top: 32px; }
  .wizard-dialogue { flex-direction: column; align-items: center; text-align: center; }
  .wizard-summary { flex-wrap: wrap; }
}

/* ---- Emote wheel (hold G, aim with mouse, release to play) ---- */
.emote-capture { position: fixed; inset: 0; z-index: 59; cursor: crosshair; }
.emote-wheel { position: fixed; left: 50%; top: 50%; width: 0; height: 0; z-index: 60;
  pointer-events: none; --cd: 0deg;
  animation: emote-pop 120ms ease-out; }
@keyframes emote-pop { from { opacity: 0; transform: scale(0.82); } to { opacity: 1; transform: scale(1); } }
/* translucent disc backdrop */
.emote-wheel .emote-disc { position: absolute; left: 0; top: 0; width: 216px; height: 216px;
  transform: translate(-50%, -50%); border-radius: 50%;
  background: radial-gradient(circle at 50% 50%, rgba(10,14,24,0.62) 0%, rgba(6,10,18,0.86) 70%, rgba(6,10,18,0.9) 100%);
  border: 2px solid var(--edge); box-shadow: 0 6px 26px rgba(0,0,0,0.5), inset 0 0 30px rgba(0,0,0,0.35); }
/* cooldown "recharge" sweep over the disc */
.emote-wheel.on-cd .emote-disc { background:
  conic-gradient(rgba(6,10,18,0.9) var(--cd), rgba(30,40,60,0.55) var(--cd)),
  radial-gradient(circle at 50% 50%, rgba(10,14,24,0.7), rgba(6,10,18,0.9)); }
.emote-wheel.on-cd .emote-badge { opacity: 0.35; filter: grayscale(0.7); }
/* centre hub: shows the aimed emote name / hint / countdown */
.emote-wheel .emote-hub { position: absolute; left: 0; top: 0; transform: translate(-50%, -50%);
  width: 84px; height: 84px; border-radius: 50%; display: flex; flex-direction: column;
  align-items: center; justify-content: center; text-align: center; gap: 1px;
  background: rgba(6,10,18,0.92); border: 2px solid var(--edge); box-shadow: inset 0 0 10px rgba(0,0,0,0.5); }
.emote-wheel .emote-hub .eh-t { font-size: 15px; font-weight: 700; color: var(--text); line-height: 1.1; }
.emote-wheel .emote-hub .eh-s { font-size: 9px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted, #9fb0c8); }
/* emote icon badges on the ring */
.emote-wheel .emote-badge { position: absolute; left: 0; top: 0; transform: translate(-50%, -50%);
  width: 58px; height: 58px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
  font-size: 30px; line-height: 1; background: rgba(14,20,32,0.94); border: 2px solid var(--edge);
  transition: transform 90ms ease-out, box-shadow 90ms, border-color 90ms; }
.emote-wheel .emote-badge.aim { transform: translate(-50%, -50%) scale(1.22);
  border-color: var(--accent, #ffd447); box-shadow: 0 0 0 3px rgba(255,212,71,0.25), 0 0 16px rgba(255,212,71,0.5); }

/* ---------------- Leaderboards hub ---------------- */
/* Board picker inside a category tab. Reuses .pill's box, adds the selected state
   the pill never needed elsewhere. */
.lb-pills { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 16px; }
.lb-pill { background: transparent; color: var(--muted); cursor: pointer; font-family: inherit; }
.lb-pill:hover { color: var(--text); background: var(--panel-2); }
.lb-pill.on { color: var(--bg); background: var(--primary); border-color: var(--primary); }

/* Rows are clickable (→ inspect whatever the board ranks: the trainer, or on a mon
   board the Pokémon), so they need a pointer + a hover that reads as "target", not
   just the generic table stripe. */
.lb-row { cursor: pointer; }
.lb-rank { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--muted); }
.lb-row td { font-variant-numeric: tabular-nums; }

/* The viewer's own row — both when it lands inside the top 50 and when it is
   pinned to the bottom because they ranked below it. */
.lb-me > td { background: rgba(247,148,30,0.14); border-top: 2px solid var(--primary); }
.lb-me .lb-rank { color: var(--primary); }

.lb-mon { display: inline-flex; align-items: center; gap: 8px; }
.lb-mon img { width: 32px; height: 32px; image-rendering: pixelated; flex: none; }
.lb-shiny { color: #ffd447; margin-left: 4px; }

/* A mon row holds TWO targets — the row opens the Pokémon, this opens its owner — so the
   owner's name has to look like the separate thing it is. `inline-block` keeps the hit box
   to the name itself: a block span would span the cell and swallow clicks meant for the row. */
.lb-owner { display: inline-block; cursor: pointer; }
.lb-owner:hover { color: var(--primary); text-decoration: underline; }

/* Guild: nothing to rank yet, so the tab is a centred interest prompt. */
.lb-soon { text-align: center; padding: 38px 20px; background: var(--panel);
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }
.lb-soon-mark { font-size: 52px; line-height: 1; margin-bottom: 6px; filter: grayscale(0.4); opacity: 0.8; }
.lb-soon p { max-width: 460px; margin: 0 auto 18px; }

/* ---- ⚙ Hunt automation modal (farm settings) ------------------------------ */
/* ⚠️ These two are PORTALLED to #pk-modals (root layout) and no longer render under
   .farm-overlay at all, so they need neither the pointer-events rescue the rail's
   `pointer-events: none` used to force nor an ancestor-scoped selector.

   The rescue is gone because the trap is: `.hud-rail` is `position: fixed`, which is a
   stacking context, so nothing inside it could ever out-paint the HUD — z 310 was capped at
   the rail's own level and the team HUD (12) drew over it. An earlier fix removed
   `.farm-overlay`'s z-index and the comment here claimed the problem was solved; it was not,
   because the rail's own `position` is a second context and no number could escape it.

   The 300/310/320/500/700 ladder below only means anything now that these boxes are back in
   the root stacking context, which is where it was written for. */

.farm-set-backdrop { position: fixed; inset: 0; z-index: 310; background: rgba(0,0,0,0.72);
  display: flex; align-items: center; justify-content: center; padding: 16px; }
.farm-set { position: relative; width: min(620px, 100%); max-height: calc(88vh * var(--ui-inv)); overflow-y: auto;
  background: var(--panel); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 18px 20px 20px; }
/* ⚙ Hunt Automation as a MODAL OF THE SHELL (2026-08-24), rather than the floating box it
   used to be. `.game-modal` already supplies the frame, the padding, the height cap and the
   scroll — so wearing `.farm-set` unmodified drew a second bordered panel inside the first,
   with its own scrollbar and doubled padding. That is what "the layout doesn't look right"
   was: a box in a box.

   ⚠️ The class is KEPT rather than dropped, because `.farm-set h3` and `.farm-set .tabs`
   still style the panel's own heading and tab strip, and every `.fa-*` rule below is written
   against this subtree. This resets only what made it a window.

   ⚠️ `overflow: visible`, not `auto`. Two nested scroll containers means the inner one traps
   the wheel and the outer scrollbar moves nothing — and the species grid inside has a third.
   The shell is the one that scrolls. */
.farm-set-embedded { position: static; width: auto; max-height: none; overflow: visible;
  background: none; border: 0; box-shadow: none; padding: 0; }

.farm-set-close { position: absolute; top: 10px; right: 10px; width: 28px; height: 28px; padding: 0;
  background: var(--bg-2); border: 2px solid var(--edge); color: var(--muted);
  font-size: 13px; cursor: pointer; }
.farm-set-close:hover { color: var(--text); }
.farm-set h3 { margin: 0 0 12px; font-family: var(--pixel); font-size: 13px;
  color: var(--primary-2); text-shadow: 2px 2px 0 var(--edge); }
.farm-set .tabs { margin: 0 0 14px; }
.farm-set-foot { font-size: 11px; margin-top: 12px; }
/* The modal's VIP pitch — a plain block in the flow. ⚠️ It carried `.fa-lock-note` until
   2026-08-11, i.e. `position: absolute; inset: -6px` inside `position: relative` `.farm-set`:
   an invisible overlay over the whole modal that swallowed every click a free player made, on
   every tab, including the switches that are free. The scrim belongs to `.fa-gate` only. */
.farm-set-vip { display: flex; flex-direction: column; align-items: center; gap: 8px;
  text-align: center; padding: 12px; background: var(--bg-2); border: 2px solid var(--edge); }
.farm-set-vip b { font-family: var(--pixel); font-size: 11px; color: var(--primary-2); }

/* ---- 🌙 "While you were away" report -------------------------------------- */
/* Portalled to #pk-modals like the automation modal above, and for the same reason — see the
   note there. Sits one step above it: the two never coexist, but ordering them removes the
   question. */

.farm-report-backdrop { position: fixed; inset: 0; z-index: 320; background: rgba(0,0,0,0.78);
  display: flex; align-items: center; justify-content: center; padding: 16px; }
.farm-report { position: relative; width: min(600px, 100%); max-height: calc(88vh * var(--ui-inv)); overflow-y: auto;
  background: var(--panel); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow); padding: 18px 20px 20px; }
.farm-report-close { position: absolute; top: 10px; right: 10px; width: 28px; height: 28px; padding: 0;
  background: var(--bg-2); border: 2px solid var(--edge); color: var(--muted);
  font-size: 13px; cursor: pointer; }
.farm-report-close:hover { color: var(--text); }
.farm-report h3 { margin: 0 0 6px; font-family: var(--pixel); font-size: 13px;
  color: var(--primary-2); text-shadow: 2px 2px 0 var(--edge); }
.farm-report-sub { font-size: 12px; margin: 0 0 14px; }
.farm-report-ended { margin: 0 0 14px; padding: 9px 11px; font-size: 12px;
  background: var(--bg-2); border: 2px solid var(--edge); border-left: 4px solid var(--danger, #d9534f); }
.farm-report-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
@media (max-width: 520px) { .farm-report-tiles { grid-template-columns: repeat(2, 1fr); } }
/* The analyzer collapses its tile labels until `.ha-tiles` is hovered. The report reuses
   `.ha-tile` under a DIFFERENT wrapper, so that reveal rule never matched here and all six
   labels were permanently invisible — six bare numbers with no way to find out what they
   meant. This is a summary you read once, not a live panel, so they always show. */
.farm-report-tiles .ha-tile span { max-height: 20px; opacity: 1; }
/* The net tile is the bottom line, not a seventh peer: the grid is 3 columns and drops to 2
   under 520px, and the six facts above fill BOTH layouts exactly — so a plain 7th tile orphans
   either way. Spanning the row lands clean in both with one rule and no media query, and gives
   the unpriced caveat somewhere to sit that a 1/3-width box does not have. */
.farm-report-tiles .ha-tile.net { grid-column: 1 / -1; }
.farm-report-tiles .ha-tile.net b { color: var(--accent); }
.farm-report-tiles .ha-tile.net.neg b { color: var(--danger); }
.farm-report-net-note { display: block; margin-top: 3px; font-size: 10px; font-style: normal;
  color: var(--muted); }
.farm-report-sec { margin-bottom: 14px; }
/* The one section in this panel that explains a bad night rather than reporting one, so it wears
   `.farm-report-ended`'s face: same box, same left rule, same danger colour. Reusing that class
   outright is wrong — it carries `margin: 0 0 14px` for a block that sits at the TOP of the
   panel, and this one sits mid-list under `.farm-report-sec`'s own spacing. */
.farm-report-dry { padding: 9px 11px; background: var(--bg-2); border: 2px solid var(--edge);
  border-left: 4px solid var(--danger, #d9534f); }
.farm-report-dry .farm-report-line:last-child { margin-bottom: 0; }
.farm-report-sec-title { font-family: var(--pixel); font-size: 9px; text-transform: uppercase;
  color: var(--link); margin: 0 0 7px; }
.farm-report-line { margin: 0 0 4px; font-size: 12px; }
.farm-report-scroll { max-height: 260px; overflow-y: auto; }
.farm-report-catch { display: flex; align-items: center; gap: 7px; font-size: 12px;
  padding: 5px 0; border-bottom: 1px solid var(--border); }
.farm-report-catch:last-child { border-bottom: 0; }
.farm-report-catch > img { width: 34px; height: 34px; image-rendering: pixelated; flex: none; }
.farm-report-catch-main { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.farm-report-catch-name { text-transform: capitalize; }
/* second line: nature / IV% / size — dense, muted, and allowed to wrap on narrow screens */
.farm-report-catch-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px;
  font-size: 11px; color: var(--muted); }
.farm-report-catch-meta .pill { font-size: 10px; padding: 1px 5px; }
.farm-report-ball { width: 18px; height: 18px; image-rendering: pixelated; vertical-align: -4px; }
/* 📈 the hunt chart. Replaces `.farm-tl-*` — a flex row of <i> bars (one per minute of ONE
   offline window) plus a scrolling "moments" list. The chart is now server-rendered inline SVG
   from PlayServiceWeb.HuntChart, so everything about its geometry, colours and hover lives in
   Elixir; the only thing CSS decides is the box it scales inside. */
.hunt-chart { padding: 3px; margin-bottom: 8px; background: var(--bg-2); border: 2px solid var(--edge); }
/* the viewBox does the scaling — height auto keeps the aspect ratio on a narrow phone */
.hunt-chart-svg { display: block; width: 100%; height: auto; }
/* Series key. Colours come from the view (SimChart's validated palette) as --k, never from here:
   the chart and its key must never be able to disagree about which hue means what. */
.hunt-chart-key { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 11px; color: var(--muted); }
.hunt-chart-key span { display: inline-flex; align-items: center; gap: 5px; }
.hunt-chart-key span::before { content: ""; width: 9px; height: 9px; background: var(--k); flex: none; }
.farm-report-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; }
.farm-report-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
  padding: 3px 7px; background: var(--bg-2); border: 2px solid var(--edge); text-transform: capitalize; }
.farm-report-chip img { width: 20px; height: 20px; image-rendering: pixelated; }
.farm-report-chip b { color: var(--accent); font-variant-numeric: tabular-nums; }

/* ---- trainer XP bar (animated by the XpReport hook) ---- */
/* Reuses the .prog-* / .xp-bar chrome defined above; only the single-column override and
   the level-number emphasis are new. */
.farm-report-xp { margin-bottom: 16px; }
.farm-report-xp .prog-panel { grid-template-columns: 1fr; }
/* the level number reads as part of the bar, so it takes the XP colour too */
.farm-report-xp .prog-lvl { color: var(--xp-b); }
.farm-report-xp .xp-bar { height: 16px; }
/* The hook drives width frame-by-frame, so the 0.3s CSS transition inherited from .xp-fill
   would fight it — and a level rollover (100% -> 0%) would animate BACKWARDS across the bar. */
.farm-report-xp .xp-fill { transition: none; }
.farm-report-xp.levelling .prog-lvl { color: #fff; text-shadow: 0 0 10px var(--xp-b); }
.farm-report-go { width: 100%; margin-top: 4px; }

.fa-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 620px) { .fa-cols { grid-template-columns: 1fr; } }

.fa-sec { position: relative; margin-bottom: 16px; }
.fa-sec-title { font-family: var(--pixel); font-size: 9px; text-transform: uppercase;
  color: var(--link); margin: 0 0 7px; }
.fa-hint { font-size: 11px; margin: 6px 0 0; line-height: 1.45; }
/* A hint carries no bottom margin and `.fa-sec` no top one, so a section opening straight
   after an explanatory paragraph had its title sitting on the last line of that paragraph —
   visible in the 🔴 tab, where "Jogar balls em" butts against the ball-order hint above it. */
.fa-hint + .fa-sec { margin-top: 14px; }
.fa-field { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
.fa-vip { font-size: 10px; margin-left: 4px; }

/* Stacked switches. `.toggle` is inline-flex, so three of them in a plain block flow onto
   one line and wrap at whatever width the modal happens to be — the master switch has to
   read as the one that governs the two under it, which needs a column. A switch whose box
   is disabled fades with it, so "this does nothing right now" is visible on the label too. */
.fa-switches { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
.fa-switches .toggle:has(input:disabled) { opacity: 0.5; cursor: default; }
.toggle input:disabled { cursor: default; }

/* ⚡ Skills tab: party strip picking whose skill list is being ranked */
.fa-mons { display: flex; gap: 6px; flex-wrap: wrap; }
.fa-mon { display: flex; align-items: center; gap: 6px; padding: 4px 8px 4px 4px; cursor: pointer;
  background: var(--panel-2); border: 2px solid var(--border); color: var(--text); }
.fa-mon:hover { border-color: var(--link); }
.fa-mon.on { border-color: var(--primary); background: var(--panel); }
.fa-mon-ico { width: 30px; height: 30px; image-rendering: pixelated; flex: 0 0 30px; }
.fa-mon-name { font-size: 12px; text-transform: capitalize; }

.fa-list { display: flex; flex-direction: column; gap: 4px; }
.fa-row { display: flex; align-items: center; gap: 7px; padding: 5px 7px;
  background: var(--panel-2); border: 2px solid var(--border); }
.fa-row.off { opacity: 0.45; border-style: dashed; }
.fa-rank { font-family: var(--pixel); font-size: 9px; color: var(--muted); width: 20px; flex: 0 0 20px; }
.fa-ico { width: 26px; height: 26px; image-rendering: pixelated; flex: 0 0 26px; }
.fa-name { font-size: 13px; text-transform: capitalize; }
.fa-warn { font-size: 10px; background: var(--primary-deep); color: #1a1400; }
.fa-qty { margin-left: auto; font-weight: 700; font-size: 12px; color: var(--muted); }
.fa-move, .fa-never { width: 22px; height: 22px; flex: 0 0 22px; padding: 0; cursor: pointer;
  background: var(--bg-2); border: 2px solid var(--edge); color: var(--text); font-size: 10px;
  line-height: 1; display: flex; align-items: center; justify-content: center; }
.fa-move:disabled, .fa-never:disabled { opacity: 0.25; cursor: default; }

/* Ball-shell rows: one line per fitting, with a toggle per ball it can fit. `margin-left` is
   0 because `.fa-qty` above already took the `auto` — the strip sits hard against the count,
   which keeps the four balls in a fixed place down the column instead of ragging with the
   name length. */
.fa-balls { display: flex; gap: 4px; margin-left: 10px; }
.fa-ball { width: 26px; height: 26px; flex: 0 0 26px; padding: 0; cursor: pointer;
  background: var(--bg-2); border: 2px solid var(--edge); display: flex;
  align-items: center; justify-content: center; }
.fa-ball img { width: 18px; height: 18px; image-rendering: pixelated; opacity: 0.3;
  filter: grayscale(1); }
/* ⚠️ The lit state is carried by the BORDER and the sprite together, never by colour alone —
   the four ball sprites are already four colours, so a colour-only cue would read as
   decoration. An off ball is greyed and dimmed; an on ball is full colour on a lit edge. */
.fa-ball.on { border-color: var(--primary); background: var(--panel-2); }
.fa-ball.on img { opacity: 1; filter: none; }
.fa-ball:disabled { cursor: default; }

/* VIP scrim. Cosmetic only — every control underneath also carries `disabled`, and
   the real guard is the read-time VIP check in GameService.FarmAutomation.
   ⚠️ `.fa-lock`/`.fa-lock-note` are absolutely positioned against the nearest positioned
   ancestor, so they may only ever be used inside a box that establishes one AND is sized to
   the thing being locked — `.fa-gate` or `.fa-sec`. Put `.fa-lock-note` on a bare child of
   `.farm-set` and it covers the entire modal and eats every click (it did, until 2026-08-11).
   A gate must never contain a switch: enabling automation is free. See `vip_gate/1`. */
.fa-gate { position: relative; }
/* ⚠️ The scrim and the note are `inset: -6px`, so a locked gate bleeds 6px past itself on every
   side — straight over the free switch or pill row sitting directly above it, where it would
   swallow clicks along that row's bottom edge. These margins are what keep the bleed in empty
   space, so they are load-bearing rather than spacing taste. */
.fa-gate.fa-lock { margin: 12px 0 18px; }
.fa-lock-body { display: flex; flex-direction: column; align-items: center; gap: 8px; }
/* A gate deeper than the modal viewport: centring the pitch inside it hides the pitch behind a
   scroll. Sticky rather than a top padding, so it stays in view for the whole scrim — and on the
   single `.fa-lock-body` wrapper, since sticky siblings all pin to the same offset and overlap.
   `.farm-set` is the scrollport (`overflow-y: auto`); `top` clears its tab strip. */
.fa-gate.tall.fa-lock .fa-lock-note { justify-content: flex-start; }
.fa-gate.tall.fa-lock .fa-lock-body { position: sticky; top: 24px; }
.fa-lock::after { content: ""; position: absolute; inset: -6px; background: rgba(5,11,22,0.72); z-index: 1; }
.fa-lock-note { position: absolute; inset: -6px; z-index: 2; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 8px; text-align: center; padding: 12px; }
.fa-lock-note b { font-family: var(--pixel); font-size: 11px; color: var(--primary-2); }

.fa-pills { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.fa-pill { padding: 4px 10px; font-size: 12px; cursor: pointer; background: var(--bg-2);
  border: 2px solid var(--edge); color: var(--muted); }
.fa-pill.on { border-color: var(--primary); color: var(--text); }
.fa-pill:disabled { opacity: 0.4; cursor: default; }

/* 🎯 Targets tab — the species allow-list picker.
   `.fa-dim` fades the list while the mode is not "list", rather than hiding it: the
   selection still means something the moment the player switches back, and a section that
   vanishes reads as "my list was deleted". Everything stays clickable on purpose — picking
   species before flipping the mode is the natural order to do it in. */
.fa-dim { opacity: 0.5; }
.fa-count { align-self: center; font-size: 11px; margin-left: 2px; }
.fa-search { margin: 8px 0 6px; }
.fa-search input { width: 100%; }
.fa-bulk { display: flex; gap: 6px; margin-bottom: 8px; }
.fa-dex { display: flex; flex-wrap: wrap; gap: 5px; max-height: 250px; overflow-y: auto;
  padding: 2px; }
.fa-dex-cell { display: flex; flex-direction: column; align-items: center; gap: 2px;
  width: 58px; padding: 4px 2px; cursor: pointer; color: var(--muted);
  background: var(--bg-2); border: 2px solid var(--edge); }
.fa-dex-cell:hover { border-color: var(--link); }
.fa-dex-cell:disabled { cursor: default; }
/* `:not(.on)` matters: a selected cell keeps its primary border while disabled, and a bare
   `:disabled:hover` outspecifies `.fa-dex-cell.on` whatever the source order. */
.fa-dex-cell:disabled:not(.on):hover { border-color: var(--edge); }
.fa-dex-cell.on { border-color: var(--primary); color: var(--text); background: var(--panel); }
.fa-dex-cell img { image-rendering: pixelated; }
.fa-dex-cell span { font-size: 9px; line-height: 1.1; text-align: center;
  text-transform: capitalize; max-height: 22px; overflow: hidden; }

/* Farm history (profile tab): one expandable row per past hunt. The head is a
   button so the whole line is the hit target; the body only exists when open. */
.fh-row { border: 2px solid var(--edge); background: var(--bg-2); margin-bottom: 6px; }
.fh-row.live { border-color: var(--primary); }
.fh-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 9px;
  background: none; border: none; color: inherit; text-align: left; cursor: pointer; font: inherit; }
.fh-head:hover { background: rgba(255, 255, 255, 0.04); }
.fh-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.fh-top { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-size: 13px; }
.fh-top .pill { padding: 1px 6px; font-size: 9px; border-width: 1px; }
.fh-main small { font-size: 11px; }
.fh-tot { font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
.fh-caret { flex: 0 0 auto; color: var(--muted); font-size: 11px; }
.fh-body { border-top: 1px solid var(--border); padding: 8px 9px 10px; }
.fh-sec + .fh-sec { margin-top: 8px; }
.fh-sec-title { font-family: var(--pixel); font-size: 8px; letter-spacing: 0; text-transform: uppercase;
  color: var(--link); margin-bottom: 5px; }
.fh-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.fh-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; font-size: 12px;
  background: rgba(28, 38, 55, 0.7); border: 2px solid var(--edge); }
/* An item sprite in a chip or on the totals line. Its own class rather than `.fh-chip img`,
   which would also catch the capture chips' 22px species sprites — their size comes from
   width/height attributes, and a CSS rule would silently override it. */
.fh-ico { width: 16px; height: 16px; image-rendering: pixelated; vertical-align: -3px; }
/* one offline window inside a hunt — a breakdown row, not a peer of .fh-row */
.fh-sub { display: flex; flex-wrap: wrap; gap: 4px; padding: 3px 0; }
.fh-note { font-size: 11px; margin: 0 0 6px; }

/* ---- Medals panel (HUD 🏅) ---- */
/* Tier palette reuses what already exists: gold is the accent (#ffd447) the quality pills
   and equipped-skin border already use, platinum is --tier-mythical. Only bronze and
   silver are new — the sheet had no colour for them. */
.medal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.medal-card { display: flex; flex-direction: column; align-items: center; gap: 5px;
  padding: 12px 10px; border: 2px solid var(--edge); border-radius: 10px; background: var(--bg-2);
  text-align: center; }
/* An unstarted medal stays visible, just dimmed — a hidden medal is not a goal. */
.medal-card.locked { opacity: 0.55; filter: grayscale(1); }

.medal-badge { width: 60px; height: 60px; display: flex; align-items: center; justify-content: center;
  border: 3px solid var(--border); border-radius: 50%; background: var(--panel); }
.medal-ico { font-size: 30px; line-height: 1; }
.medal-badge.t1 { border-color: var(--medal-bronze); box-shadow: 0 0 6px #b0722d55; }
.medal-badge.t2 { border-color: var(--medal-silver); box-shadow: 0 0 6px #c9d1d955; }
.medal-badge.t3 { border-color: #ffd447; box-shadow: 0 0 9px #ffd44777; }
.medal-badge.t4 { border-color: var(--tier-mythical); box-shadow: 0 0 12px #c084fc99; }

/* A struck plate IS the tier, so it gets no ring — the ribbon-and-disc art is bronze,
   silver, gold or platinum on its own. The tier colour survives as a glow, because the
   pips and the summary pills use those same four and the card should still read "gold"
   at a glance across the grid. Only `.medal-ico` medals (no art yet) keep the ring above.
   ⚠️ This block must stay BELOW the four rules above or `box-shadow: none` loses the
   specificity tie and every plate keeps its old ring. */
.medal-badge.art { width: 64px; height: 80px; border: 0; border-radius: 0;
  background: none; box-shadow: none; }
.medal-badge.art.t1 { filter: drop-shadow(0 0 4px #b0722d55); }
.medal-badge.art.t2 { filter: drop-shadow(0 0 4px #c9d1d955); }
.medal-badge.art.t3 { filter: drop-shadow(0 0 6px #ffd44777); }
.medal-badge.art.t4 { filter: drop-shadow(0 0 8px #c084fc99); }

.medal-name { font-size: 13px; font-weight: 600; line-height: 1.2; }
.medal-desc { font-size: 11px; color: var(--muted); line-height: 1.3; min-height: 28px; }

.medal-bar { width: 100%; height: 8px; background: var(--panel); border: 2px solid var(--edge); }
.medal-bar > i { display: block; height: 100%; background: var(--primary); }
.medal-progress { display: flex; align-items: baseline; gap: 4px; font-size: 12px;
  font-variant-numeric: tabular-nums; }

.medal-pips { display: flex; gap: 4px; }
.medal-pip { width: 9px; height: 9px; border: 2px solid var(--edge); background: var(--panel); }
.medal-pip.on.t1 { background: var(--medal-bronze); }
.medal-pip.on.t2 { background: var(--medal-silver); }
.medal-pip.on.t3 { background: #ffd447; }
.medal-pip.on.t4 { background: var(--tier-mythical); }

.medal-bonus { font-size: 11px; color: var(--green); font-weight: 600; min-height: 15px; }
.medal-bonus.muted { color: var(--muted); font-weight: 400; }

/* type proficiency — the endless curve, sat under the capped catch bonus. Tabular
   numerals so the percentages line up down the 15-card type grid. */
.medal-prof { font-size: 11px; color: var(--link); font-weight: 600; font-variant-numeric: tabular-nums; }
/* the same bonus as a pill on the Pokémon card, beside tier/quality */
.pill.prof-pill { color: var(--link); font-variant-numeric: tabular-nums; }

.medal-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.medal-count { font-family: var(--pixel); font-size: 9px; color: var(--link); }
.medal-tally { font-size: 10px; padding: 2px 7px; }
.medal-tally.t1 { color: var(--medal-bronze); }
.medal-tally.t2 { color: var(--medal-silver); }
.medal-tally.t3 { color: #ffd447; }
.medal-tally.t4 { color: var(--tier-mythical); }
.medal-rarity { font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; }
/* The struck plate, 1:1 with its 64x80 source — nearest-neighbour, never smoothed. It
   used to render a 64px icon at 44px, a 1.45:1 downscale that drops source rows unevenly.
   ⚠️ `height: auto`, never a fixed 80px: the onerror fallback is the old 64x64 icon and a
   fixed height would stretch it. */
.medal-art { width: 64px; height: auto; image-rendering: pixelated; }

/* ---------------- Collectible badges (profile picker) ---------------- */
/* A second cosmetic slot beside the title. The whole card is the button: clicking the
   equipped one clears the slot, which is why there is no separate remove control. */
.badge-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); gap: 8px; }
.badge-card { display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 8px 6px; border: 2px solid var(--border); background: var(--bg-2);
  font: inherit; color: inherit; cursor: pointer; text-align: center; }
.badge-card:hover { border-color: var(--muted); }
.badge-card.equipped { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.badge-ico { width: 32px; height: 32px; image-rendering: pixelated; }
.badge-name { font-size: 12px; font-weight: 600; }
.badge-state { font-size: 10px; }
/* The grid shows the whole catalog, so an unearned badge has to read as a silhouette you
   could fill in — greyed and dimmed, but still legible enough to be a goal. */
.badge-card.locked { opacity: 0.4; cursor: default; }
.badge-card.locked:hover { border-color: var(--edge); }
.badge-card.locked .badge-ico { filter: grayscale(1) brightness(0.75); }
.badge-count { font-size: 11px; font-weight: 600; margin-left: 6px; font-variant-numeric: tabular-nums; }
/* badge shown inline before a name in DOM surfaces (inspect panel) */
.badge-inline { width: 20px; height: 20px; image-rendering: pixelated; vertical-align: -0.18em; margin-right: 4px; }
/* Collectible badge before a chat sender's name. Distinct from `.chat-badge`, which is the
   unread counter in the panel header — same name used to mean both, so a sender badge
   inherited the counter's pink pill. Renders the 32px art scaled to the line, falling back
   to the emoji glyph when a badge has no PNG yet. */
.chat-sender-badge { margin-right: 3px; display: inline-block; vertical-align: -0.15em; line-height: 1; }
.chat-sender-badge img { width: 14px; height: 14px; image-rendering: pixelated; display: block; }
/* The sender's guild, immediately before their name. Same 14px as the badge so the two sit on
   one line, and `pixelated` for the same reason as everywhere else the emblem appears. */
.chat-sender-guild { margin-right: 3px; display: inline-flex; align-items: center; gap: 2px; vertical-align: -0.15em; line-height: 1; }
.chat-sender-guild img { width: 14px; height: 14px; image-rendering: pixelated; display: block; }
.chat-guild-tag { color: var(--accent, #ffc93c); font-weight: 700; font-size: 0.9em; }

/* ---- guild chip: the emblem + [TAG] beside a trainer name -------------------
   One renderer for every DOM surface (PlayServiceWeb.GuildComponents); the Phaser nameplate
   composes its own because a canvas cannot host a DOM node.

   ⚠️ `image-rendering: pixelated` is not optional here. The emblem is a 32x32 indexed PNG
   drawn on a 32x32 grid and shown at 16px — a smoothed 2:1 downscale is exactly the mush the
   repo's whole art rule exists to prevent, and unlike a sprite sheet nobody would think to
   check it. Same reason `.badge-inline` carries it.

   Sized in `em`, not px, so the chip rides the font of whatever row it lands in — a
   leaderboard cell, an inspect header and a roster line are three different sizes. */
.guild-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 6px; vertical-align: baseline; }
.guild-chip .guild-inline { width: 1.15em; height: 1.15em; image-rendering: pixelated; vertical-align: -0.2em; }
.guild-chip .guild-tag { color: var(--accent, #ffc93c); font-weight: 700; letter-spacing: 0.02em; }
.guild-chip .guild-name { color: var(--muted, #8fa3c4); }
/* The inspect card gives it a line of its own, so the emblem gets to be bigger than the
   inline case — it is the one surface with room. */
.inspect-guild { display: flex; align-items: center; justify-content: center; margin-top: 2px; }
.inspect-guild .guild-chip { margin-left: 0; font-size: 0.95rem; }
.inspect-guild .guild-inline { width: 1.6em; height: 1.6em; }

/* ---- chat links (shift-click a Pokémon/item into a message) ----------------
   An inline chip inside .chat-row. The hover card is NOT a child of this element:
   .chat-msgs is `overflow-y: auto`, which computes overflow-x to auto and would slice
   the tooltip off — so the chip is wrapped in `.gtip` and the content is teleported into
   the fixed #global-tip layer, the same fix `.move-badge` uses.
   `overflow-wrap` is inherited from .chat-row, so a long nickname still breaks. */
/* A Pokémon chip wears its QUALITY colour, set as `--q` by `Chat.linkChip` — the same custom
   property the Depot's box cells carry, so one palette serves both and they cannot disagree.
   ⚠️ The fallback is the OLD colour and must stay: an item chip, a pending one and a missing one
   never set `--q`, and `--accent` is what a Pokémon chip falls back to when its tier is unknown.
   ⚠️ `--accent` (#ffc93c) is NOT `--q-absolute` (#ffd447) — two near-identical golds, which is
   exactly why every chip reading as Absolute went unnoticed for so long. Do not "simplify" the
   fallback to one of them on the assumption they are the same value.
   The three overrides below are 0,2,0 and still win, so a loading or dead chip is never tinted. */
.chat-link { color: var(--q, var(--accent, #ffd447)); cursor: pointer; text-decoration: none; font-weight: 700; }
.chat-link:hover { text-decoration: underline; }
/* Items are hover-only — no click target, so no pointer cursor promising one. */
.chat-link.link-item { color: #7fd4ff; cursor: help; }
/* Not resolved yet: reads as a placeholder rather than a broken link. */
.chat-link.pending { color: var(--muted); cursor: default; font-weight: 400; }
/* Released, sold, or an id typed by hand. Struck through so it is obviously dead. */
.chat-link.missing { color: var(--muted); cursor: default; text-decoration: line-through; font-weight: 400; }
.chat-link.missing:hover { text-decoration: line-through; }
/* Affordance for the shift-click sources, using the body class ConsumableDnd already
   maintains for its shift-drag. Purely a hint — the handler checks e.shiftKey itself. */
body.shift-held .team-card, body.shift-held .islot, body.shift-held [data-mon-id] { cursor: copy; }

/* ---- profile banners (fourth cosmetic slot) --------------------------------
   NOTE the `pbanner` prefix: `.banner` is already the flash-message class above.
   Source art is 512x192 (16x6 tiles) indexed to the 64-colour master palette. */

/* The banner is the BACKGROUND of the profile header, with the avatar/name/level on top —
   the Discord arrangement. Without .has-pbanner this is a plain .page-head and nothing
   about the layout changes, so a player who owns no banner sees exactly what they did. */
.profile-top { position: relative; }
.profile-top.has-pbanner {
  /* bleed to the modal edges: .game-modal pads 22px 24px */
  margin: -22px -24px 16px; padding: 22px 24px 18px;
  background-size: cover; background-position: center; background-repeat: no-repeat;
  image-rendering: pixelated;
  border-bottom: 3px solid var(--edge);
}
/* Scrim. The art is player-chosen and can be bright anywhere, so the text needs its own
   contrast rather than trusting the image — darkest at the bottom where the name sits. */
.profile-top.has-pbanner::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(8,17,32,0.30) 0%, rgba(8,17,32,0.62) 55%, rgba(8,17,32,0.86) 100%);
  pointer-events: none;
}
.profile-top-inner { position: relative; z-index: 1; }
/* The equipped trainer look, drawn as frame 0 of the 8-direction sheet. 64x64 at 1:1 —
   the sheet is 576x512, so any other size would resample pixel art fractionally. */
.profile-avatar { width: 64px; height: 64px; flex: none; image-rendering: pixelated;
  background-repeat: no-repeat; background-position: 0 0; background-size: 576px 512px;
  background-color: var(--panel-2, #1c2637); border: 2px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border); }
.profile-top.has-pbanner .profile-top-inner h1,
.profile-top.has-pbanner .profile-top-inner .profile-top-sub { text-shadow: 0 2px 0 rgba(3,7,14,0.85); }
/* .muted is too dim to read over art */
.profile-top.has-pbanner .profile-top-sub { color: #dbe7ff; }
.profile-top.has-pbanner .profile-top-inner > img { box-shadow: inset 0 0 0 2px var(--border), var(--shadow); }

/* the inspect card reuses it, one notch shorter */
.inspect-top.has-pbanner { margin: -22px -24px 14px; padding: 26px 24px 14px; }

.pbanner-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.pbanner-card { display: flex; flex-direction: column; align-items: stretch; gap: 4px; padding: 6px;
  border: 2px solid var(--edge); background: var(--bg-2); text-align: left; cursor: pointer; }
.pbanner-card img { width: 100%; height: auto; display: block; image-rendering: pixelated; }
.pbanner-card.equipped { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.pbanner-card.locked { opacity: 0.42; cursor: default; filter: grayscale(1) brightness(0.8); }
.pbanner-name { font-size: 12px; font-weight: 600; }
.pbanner-state { font-size: 10px; }

/* shop row: wide preview + name/price + buy button */
.pbanner-shop { display: flex; flex-direction: column; gap: 10px; }
.pbanner-buy { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  border: 2px solid var(--edge); background: var(--bg-2); padding: 8px; }
.pbanner-buy.owned { opacity: 0.72; }
.pbanner-img { width: 256px; max-width: 100%; height: auto; display: block; image-rendering: pixelated; }
.pbanner-meta { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 150px; }
@media (max-width: 640px) { .pbanner-img { width: 100%; } }

/* ---- GM balance simulations (/gm/simulations) ------------------------------
   Staff-only tooling, so it borrows the game's panels and tokens rather than
   introducing a second visual language. Chart colours are NOT here: they live in
   PlayServiceWeb.SimChart, because they were picked by running the dataviz
   validator against --panel and must be changed together with that check. */
.sim-controls {
  display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end;
  margin: 0 0 16px; padding: 12px 14px;
  background: var(--panel); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border);
}
.sim-controls label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.sim-controls input, .sim-controls select {
  background: var(--bg-2); border: 2px solid var(--border); color: var(--text);
  padding: 6px 8px; font: inherit; min-width: 0;
}
.sim-controls input[type="number"] { width: 88px; }
.sim-controls select { max-width: 190px; }
.sim-controls .muted { font-size: 12px; align-self: center; }

.sim-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 18px; }
.sim-tile {
  display: flex; flex-direction: column; gap: 2px; padding: 10px 12px;
  background: var(--panel); border: 2px solid var(--border);
}
.sim-tile b { font-size: 20px; font-weight: 850; letter-spacing: -0.02em; color: var(--text); }
.sim-tile span { font-size: 12px; color: var(--muted); }
.sim-tile em { font-size: 11px; color: var(--muted); opacity: 0.75; font-style: normal; }

/* Two per row on wide screens; a `wide` panel always spans the full width because a
   15x15 heatmap or a 43-row dumbbell is unreadable at half width. */
.sim-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
@media (max-width: 900px) { .sim-grid { grid-template-columns: 1fr; } }
.sim-panel { margin-bottom: 14px; padding: 14px 16px; }
.sim-panel.sim-wide { grid-column: 1 / -1; }
.sim-title { font-family: var(--pixel); font-size: 10px; line-height: 1.6; color: var(--text); margin: 0 0 4px; }
.sim-hint { font-size: 12px; color: var(--muted); margin: 0 0 10px; max-width: 78ch; }

/* The SVG scales to the panel; its own viewBox decides the aspect ratio. */
.sim-svg { display: block; width: 100%; height: auto; }

.sim-legend { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 8px; }
.sim-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.sim-legend-txt { font-size: 11px; color: var(--muted); }
.sim-swatch { display: inline-block; width: 14px; height: 10px; border: 1px solid var(--edge); }
/* Immunity reads without colour too — the same hatch the cells use. */
.sim-swatch-immune {
  background: #1a1424;
  background-image: repeating-linear-gradient(45deg, transparent 0 1px, #544d6b 1px 2px);
}

.sim-note { margin: 14px 0 0; font-size: 12px; line-height: 1.6; }
.sim-note code { font-size: 11px; }

/* ============================================================
   Mobile: every modal is the whole screen
   ============================================================
   A modal on a phone was a box inside a box: the backdrop padded 32/16, the box capped at
   860px with `max-height: 92vh`, and the content then scrolled inside a viewport-sized
   scroller inside another one. But every modal in this game IS a screen of UI — a depot, a
   shop, an auction book, the bag — so below the layout breakpoint they take the viewport
   and the box becomes the only scroller.

   ⚠️ This block lives at the END OF THE FILE on purpose. Media queries add no specificity,
   so these rules beat the desktop ones they override (`.modal-controls` at ~2227,
   `.game-modal-shell > .modal-controls` at ~2233, `.bag-shell > .modal-controls` at ~1513,
   `.game-modal-backdrop > .modal-close` at ~469) only by coming after them. Moving it up
   next to those components — the convention everywhere else in this file — silently
   restores the desktop control column on phones.

   ⚠️ NOT gated on `body.touchctl`, unlike the HUD reflow. That gate is right for the reflow
   because the reflow exists to make room for the joystick, so it must fire only where the
   joystick does. This asks how much room the CONTENT has, which a narrow window asks just
   as loudly as a phone; and it has to work on the standalone routes (/pokedex, /auction,
   /exchange), where `body.touchctl` is never set at all — `TOUCH.sync()` runs inside the
   game-canvas hook, which those pages do not mount.

   ⚠️ The ✕ is the ONLY way out at this size. `phx-click-away` on the box needs a backdrop
   to click and full-bleed leaves none, and a phone has no Esc key — so the control columns
   are re-pinned OVER the box's top-right. Left beside it (the desktop layout) a full-width
   box pushes them off-screen and the player is trapped in the modal.

   `dvh`, not `vh`: mobile Safari resolves `vh` against the address-bar-COLLAPSED viewport,
   so a 100vh modal hides its own last row behind browser chrome. The `vh` line above each
   `dvh` is the fallback for engines that don't know the unit. */
@media (max-width: 820px) {
  /* Full-bleed, and the backdrop stops scrolling — the box does. Gated by the same `:has()`
     as the boxes below: a backdrop whose dialog has no ✕ must keep its padding, because
     that visible margin IS the click-away target. */
  .game-modal-backdrop:has(> .modal-close, .modal-controls),
  .modal-backdrop:has(> .modal-close),
  .farm-pop-backdrop,
  .farm-set-backdrop,
  .farm-report-backdrop,
  .skillmodal-backdrop { padding: 0; overflow: hidden; }

  .game-modal-shell {
    width: 100%;
    height: calc(100vh * var(--ui-inv));
    height: calc(100dvh * var(--ui-inv));
  }

  /* ⚠️ Only boxes that own an EXIT CONTROL go full-bleed, which is what the `:has()` gates
     test for. Three dialogs in bag_live (the mint / use / repel pickers) have no ✕ at all —
     `phx-click-away` on the box is their only close — so full-bleed would eat the backdrop
     they need to be clicked away on and strand the player in an unclosable dialog. Same
     reason the PvP result box is excluded: it exits through its own "Back to overworld"
     button, and a three-line "Victory!" does not want to be a screen.
     A browser without `:has()` support drops these rules and keeps the desktop box — the
     safe direction to fail in.

     `.game-modal.depot-modal` and `.mon-card-modal` are spelled out because their own
     two-class rules (`max-width: 1320px` / `720px`) outrank a bare `.game-modal` wherever
     it sits in the file. */
  .game-modal-backdrop:has(> .modal-close, .modal-controls) .game-modal,
  .game-modal-backdrop:has(> .modal-close, .modal-controls) .game-modal.depot-modal,
  .game-modal-backdrop:has(> .modal-close, .modal-controls) .game-modal.trade-modal,
  .game-modal-backdrop:has(> .modal-close, .modal-controls) .game-modal.exchange-modal,
  .game-modal-backdrop:has(> .modal-close, .modal-controls) .game-modal.professions-modal,
  .game-modal-backdrop:has(> .modal-close, .modal-controls) .game-modal.cards-modal,
  .game-modal-backdrop:has(> .modal-close, .modal-controls) .game-modal.gyms-modal,
  .game-modal-backdrop:has(> .modal-close, .modal-controls) .game-modal.chat-cfg-modal,
  .game-modal-backdrop:has(> .modal-close) .mon-card-modal,
  .modal-backdrop:has(> .modal-close) > .modal,
  .farm-pop,
  .farm-set,
  .farm-report,
  .skillmodal,
  /* already a full-height drawer — this only drops its 420px cap */
  .help-panel {
    width: 100%; max-width: none; max-height: none;
    height: calc(100vh * var(--ui-inv));
    height: calc(100dvh * var(--ui-inv));
    overflow-y: auto;
    /* the frame is what a full-bleed panel has no room for; the screen edge is the border */
    border-width: 0; box-shadow: none;
    /* top pad clears the pinned controls below — they overlap the box, not the content.
       ⚠️ The safe-area insets ride ON TOP of those numbers rather than replacing them: the
       controls are themselves offset by the same insets just below, so the content has to clear
       both the control row and the notch, not one or the other. */
    padding:
      calc(48px + var(--hud-safe-t) * var(--ui-inv))
      calc(14px + var(--hud-safe-r) * var(--ui-inv))
      calc(24px + var(--hud-safe-b) * var(--ui-inv))
      calc(14px + var(--hud-safe-l) * var(--ui-inv));
  }
  /* A framed modal keeps padding 0 here too: the `:has()` rule above outranks the two-class
     `.game-modal.framed-modal` rule, and the padding it would add is a bare margin around a
     frame that already carries a 40-56px border — the pinned close button sits on the frame's
     top strip, which is what the padding exists to clear. */
  .game-modal-backdrop:has(> .modal-close, .modal-controls) .game-modal.framed-modal { padding: 0; }

  /* Controls over the box's corner rather than beside it. `fixed`, so they hold still while
     the box scrolls under them — the backdrops are all `position: fixed` with no transform,
     so the viewport is the containing block. The three that were already `absolute` inside
     their own box are re-pinned for that reason alone: full-bleed makes the box the
     scroller, and an absolute ✕ scrolls off the top of a long panel. */
  .game-modal-backdrop > .modal-close,
  .modal-backdrop > .modal-close,
  .game-modal-shell > .modal-controls,
  .bag-shell > .modal-controls,
  .farm-pop > .farm-pop-close,
  .farm-set > .farm-set-close,
  .farm-report > .farm-report-close,
  .skillmodal > .skillmodal-x,
  .help-panel > .modal-close {
    /* ⚠️ The insets are load-bearing HERE more than anywhere else in this file: at this width
       the box is full-bleed, so there is no backdrop to click away on and a phone has no Esc
       key — this ✕ is the ONLY way out. Under `viewport-fit=cover` a bare `top: 8px` puts it
       under the notch in landscape, and a player who cannot reach it is trapped in the modal. */
    position: fixed;
    top: calc(8px + var(--hud-safe-t) * var(--ui-inv));
    right: calc(8px + var(--hud-safe-r) * var(--ui-inv));
    z-index: 6;
    flex-direction: row; align-self: auto; order: 0; margin: 0;
  }

  /* The two-button columns become a row, and the row needs a plate: the box scrolls UNDER
     these, and `.modal-controls` is transparent with a 6px gap, so a line of text would
     show through the slot between ✕ and ? mid-scroll. The single-button cases need none —
     one opaque 32px square with its own border already reads as a control, not a glitch.
     `box-shadow` rather than padding, so the plate cannot move the buttons. */
  .game-modal-shell > .modal-controls,
  .bag-shell > .modal-controls {
    gap: 6px; background: var(--bg); box-shadow: 0 0 0 5px var(--bg);
  }

  /* The item detail stacks: icon + text on one line, actions on their own beneath.
     Side by side, a phone gave the description ~90px between the 34px icon and a button
     column sized to "Pôr na barra de itens" — the text became an unreadable ribbon and the
     buttons overlapped it. `100%` on the actions is what forces the wrap; the buttons then
     pair up two per row, which keeps the panel short enough that the description stays on
     screen with them. */
  .bag-detail-actions {
    flex: 1 0 100%;
    flex-direction: row;
    flex-wrap: wrap;
  }
  .bag-detail-actions > .btn { flex: 1 1 150px; }

  /* The header's three regions cannot share a 390px row. Title and gold stay paired on the
     first line (the title flexes, the gold is `flex: 0 0 auto`) and the capacity takes a full
     row of its own beneath them — which is also what gives the slim bar back a useful width.
     ⚠️ This is only reachable because the header carries classes: an inline style on any of
     these boxes would beat this block, which is what `bag_detail_layout_test.exs` is about. */
  .bag-head-cap { flex: 1 0 100%; justify-content: flex-start; order: 3; }
  .bag-cap-read { flex: 1; max-width: none; }

  /* The bag is a HUD panel with no backdrop (the overworld stays live behind it), so it
     centres ITSELF — `inset: 0` alone would still be shifted by the centring transform.
     Its z-index is deliberately left at 60: the drag-to-hotbar raise
     (`body.dragging-item .hud-hotbars`, z 200) has to keep winning over it. */
  .bag-shell { inset: 0; transform: none; }
  .bag-shell .bag-panel {
    width: 100%; max-width: none; max-height: none;
    height: calc(100vh * var(--ui-inv));
    height: calc(100dvh * var(--ui-inv));
    overflow-y: auto; border-width: 0; box-shadow: none; padding-top: 48px;
    /* nothing legible is behind a full-screen panel — don't pay a phone GPU to blur it */
    backdrop-filter: none;
  }
}

/* ---- Event calendar panel (CalendarLive) ---- */
/* No new palette entries — every colour is an existing var, so the panel inherits the game's
   theme instead of starting a second one. */
.cal-years { display: flex; gap: 4px; margin: 0 0 12px; flex-wrap: wrap; }
/* Twelve month tabs have to wrap on a phone; `.tabs` alone is a single non-wrapping row. */
.cal-months { flex-wrap: wrap; }
.cal-months .tab { padding: 8px 10px; }

.cal-list { display: flex; flex-direction: column; gap: 8px; }
.cal-row {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 12px; background: var(--panel); border: 2px solid var(--edge);
}
.cal-when { display: flex; flex-direction: column; align-items: center; min-width: 42px; }
.cal-day { font-family: var(--pixel); font-size: 14px; color: var(--text); }
.cal-dow { font-size: 10px; color: var(--muted); text-transform: uppercase; }
/* Project art rule: 32px pixel art, nearest-neighbour only. Never smooth a sprite. */
.cal-sprite { width: 48px; height: 48px; image-rendering: pixelated; flex: none; }
.cal-body { flex: 1; min-width: 0; }
.cal-title { font-family: var(--pixel); font-size: 9px; line-height: 1.6; color: var(--text); }
.cal-icon { margin-right: 4px; }
.cal-mon { margin-top: 4px; font-size: 12px; color: var(--text); text-transform: capitalize; }
.cal-zones { margin-top: 6px; font-size: 11px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.cal-zone { text-transform: capitalize; color: var(--muted); }
.cal-live { color: var(--primary); margin-left: 6px; }
.cal-cancelled { color: var(--muted); margin-left: 6px; }

/* Past days recede but stay readable — the panel is meant to be scrolled back through. */
.cal-past { opacity: 0.5; }
.cal-now { border-color: var(--primary); }
.cal-off { opacity: 0.45; }
.cal-off .cal-day, .cal-off .cal-mon { text-decoration: line-through; }

/* ---- 📰 Bulletin board (BulletinLive) ---------------------------------------
   Three stacked sections: what is live now, one featured offer, then the feed.
   The offer block deliberately reuses .ss-hero's shape and scrim trick — it sells
   the same bundles, so looking like a second kind of thing would be the bug. */

/* "Live now": the same chips the HUD buff bar shows, laid out inline rather than
   fixed-position. Hidden entirely when nothing is running — an empty heading
   reads as breakage, which is why the :if is on the container in the template. */
.bul-live { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;
  padding: 10px 12px; background: var(--panel-2); border: 2px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--primary); }
.bul-live-tag { font-family: var(--pixel); font-size: 8px; text-transform: uppercase;
  color: var(--primary); letter-spacing: 0.04em; }
.bul-live-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px 4px 4px;
  background: var(--bg); border: 2px solid var(--edge); font-size: 12px; color: var(--text); }
.bul-live-ico { width: 26px; height: 26px; image-rendering: pixelated;
  border: 1px solid var(--edge); background: var(--bg-2); }
.bul-live-text { white-space: nowrap; }

.bul-offer { display: flex; gap: 18px; align-items: center; flex-wrap: wrap;
  background: linear-gradient(100deg, var(--panel-2), var(--panel)); padding: 18px;
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--primary), var(--shadow);
  margin-bottom: 18px; }
.bul-offer.has-banner { position: relative; background-size: cover; background-position: center;
  background-repeat: no-repeat; image-rendering: pixelated; }
.bul-offer.has-banner::before {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(100deg, rgba(8,17,32,0.88) 0%, rgba(8,17,32,0.72) 45%, rgba(8,17,32,0.42) 100%);
}
.bul-offer.has-banner > * { position: relative; z-index: 1; }
.bul-offer.has-banner .bul-offer-body h2 { text-shadow: 0 2px 0 rgba(3,7,14,0.8); }
/* Same sprite-sheet crop as .ss-hero-art — a trainer skin is one frame of an 864x768 sheet. */
.bul-offer-art { width: 96px; height: 96px; flex: none; image-rendering: pixelated;
  background-repeat: no-repeat; background-position: 0 0; background-size: 864px 768px; }
.bul-offer-body { flex: 1; min-width: 220px; }
.bul-offer-body h2 { margin: 6px 0 4px; font-family: var(--pixel); font-size: 13px; line-height: 1.5; }
.bul-offer-tag { display: inline-block; background: var(--primary); color: var(--edge);
  font-family: var(--pixel); font-size: 8px; padding: 5px 10px; border: 2px solid var(--edge);
  text-transform: uppercase; }
.bul-offer-note { color: var(--accent); font-size: 12px; font-weight: 700; margin: 0 0 4px; }

.bul-list { display: flex; flex-direction: column; gap: 12px; }
.bul-post { padding: 14px 16px; background: var(--panel); border: 2px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border); }
.bul-post.pinned { border-color: var(--primary); box-shadow: inset 0 0 0 2px var(--primary); }
.bul-post h3 { margin: 8px 0 6px; font-family: var(--pixel); font-size: 12px; line-height: 1.6; }
.bul-post-img { display: block; width: 100%; max-height: 140px; object-fit: cover;
  image-rendering: pixelated; border: 2px solid var(--edge); margin-bottom: 10px; }
.bul-post-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.bul-date { font-size: 12px; margin-left: auto; }
/* ⚠️ pre-line, not pre-wrap: the body is plain text a GM typed into a textarea, so the blank
   lines between their paragraphs are the only structure it has. Nothing parses it as markup —
   here or on the website — and it must stay that way. */
.bul-post-body { margin: 0; font-size: 13.5px; line-height: 1.6; color: var(--text);
  white-space: pre-line; }
.bul-post .btn { margin-top: 10px; }
.bul-kind { font-family: var(--pixel); font-size: 8px; text-transform: uppercase; }
.bul-kind-news { background: var(--panel-2); }
.bul-kind-patch { background: var(--primary); color: var(--edge); }
.bul-kind-promo { background: var(--accent); color: var(--edge); }
.bul-pin { background: var(--panel-2); font-size: 11px; }

/* ---- guild panel + the 32x32 emblem editor ---------------------------------
   The editor's DOM is built by the `GuildIconEditor` hook (the container is
   `phx-update="ignore"`), so these class names live in app.js rather than in a HEEx
   template — the same arrangement `.chat-row` and `.party-row` already have. */
.guild-head { display: flex; align-items: center; gap: 12px; }
.guild-emblem-lg { width: 64px; height: 64px; image-rendering: pixelated; flex: 0 0 auto; }
.guild-create { display: flex; flex-direction: column; gap: 8px; max-width: 380px; }
.guild-create label { display: flex; flex-direction: column; gap: 3px; font-size: 13px; }
.guild-roster { list-style: none; padding: 0; margin: 0; }
.guild-roster li { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
.guild-roster .gr-name { font-weight: 600; }
.guild-roster .gr-actions { margin-left: auto; display: flex; gap: 4px; }
.gr-role-leader { background: rgba(255, 201, 60, 0.18); color: var(--accent); }
.gr-role-officer { background: rgba(132, 155, 228, 0.18); }
.guild-seats { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }

/* Guild level: the chip + the "N / M EXP" line, then a slim bar under them. The bar is
   rendered only when `next_level_exp` is non-nil, so there is no "permanently full" state to
   style — at the ceiling the copy replaces it entirely. */
.guild-level { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.guild-xp-bar {
  margin-top: 4px; width: 220px; max-width: 100%; height: 5px;
  background: var(--panel-2); border-radius: 3px; overflow: hidden;
}
.guild-xp-bar > span { display: block; height: 100%; background: var(--accent, #ffc93c); }

/* The vendor's board. A row is icon · name · price · button, and a locked one is dimmed
   rather than removed — see the tab's own comment for why hiding it would be wrong. */
.guild-shop { list-style: none; padding: 0; margin: 0; }
.gs-row {
  display: flex; align-items: center; gap: 8px;
  padding: 5px 0; border-bottom: 1px solid var(--line, #2b3242);
}
.gs-row:last-child { border-bottom: 0; }
/* Icon + name are wrapped in a .gtip trigger for the item hover card, so the WRAPPER is the
   flex child of .gs-row now and .gs-main carries the layout the two used to have between them.
   Exactly what `.shop-row > .gtip` does one vendor over, and for the same reason: `.gtip` is
   inline-flex, which would shrink-wrap the pair and pull the price left off the row's edge. */
.gs-row > .gtip { flex: 1 1 auto; min-width: 0; }
.gs-main { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1 1 auto; }
.gs-icon { width: 24px; height: 24px; image-rendering: pixelated; flex: 0 0 auto; }
.gs-name { flex: 1 1 auto; display: flex; align-items: baseline; gap: 5px; }
.gs-price { color: var(--accent, #ffc93c); font-variant-numeric: tabular-nums; }
.gs-buy { flex: 0 0 auto; margin-left: 4px; }
/* Dimmed, still legible: the required level is the whole reason the row is on screen. */
.gs-locked { opacity: 0.55; }
/* The tooltip layer resolves a hover with `e.target.closest(".gtip")`, and a disabled <button>
   is unreliable about dispatching mouse events at all — so the pointer is taken off the button
   and the hover lands on the `.gtip` wrapper instead. Without it the refusal reason can simply
   never appear, which is the one thing a disabled button must not do here.
   Scoped to this board rather than made global: `ShopLive.buy_button/1` renders the same
   disabled-inside-tooltip shape without such a rule, and changing how ITS tooltips behave is
   not this change's business. */
.gs-buy .gtip > .btn[disabled] { pointer-events: none; }

/* ⚠️ The canvas is 32x32 and upscaled HERE, never a 320x320 canvas painting 10x rects: one
   is the artefact at its real size, the other is a drawing of it that has to be re-derived
   to save. `pixelated` is mandatory for the same reason it is on `.guild-inline`. */
.pe-wrap { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.pe-stage { line-height: 0; }
.pe-canvas {
  width: 352px; height: 352px; max-width: 100%;
  image-rendering: pixelated;
  background:
    repeating-conic-gradient(#2a2f3a 0% 25%, #232833 0% 50%) 0 0 / 22px 22px;
  border: 1px solid var(--line, #2b3242);
  cursor: crosshair;
  touch-action: none;   /* or a drag scrolls the modal instead of drawing */
}
.pe-tools { display: flex; flex-wrap: wrap; gap: 4px; }
.pe-tool {
  width: 30px; height: 30px; line-height: 1;
  background: var(--panel-2, #1b2130); color: inherit;
  border: 1px solid var(--line, #2b3242); border-radius: 4px; cursor: pointer;
}
.pe-tool.on { border-color: var(--accent, #ffc93c); background: rgba(255, 201, 60, 0.15); }
.pe-palette { display: grid; grid-template-columns: repeat(13, 22px); gap: 2px; }
.pe-swatch {
  width: 22px; height: 22px; padding: 0;
  border: 1px solid var(--line, #2b3242); border-radius: 2px; cursor: pointer;
}
.pe-swatch.on { outline: 2px solid var(--accent, #ffc93c); outline-offset: -2px; }
/* The transparent swatch has to LOOK transparent, not white — it is the eraser. */
.pe-swatch-none {
  background: repeating-conic-gradient(#3a4152 0% 25%, #252b38 0% 50%) 0 0 / 10px 10px;
}
.pe-label { font-size: 12px; color: var(--muted, #8fa3c4); align-self: center; margin-right: 4px; }
.pe-preview { display: flex; align-items: center; gap: 6px; }
/* ⚠️ 16px — the size the nameplate actually draws it at. The point of this row is to catch a
   dark-on-dark emblem BEFORE it ships, so it must not be shown bigger than it will be. */
.pe-prev-canvas { width: 16px; height: 16px; image-rendering: pixelated; border: 1px solid var(--line, #2b3242); }
.pe-presets { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.pe-preset {
  width: 34px; height: 34px; padding: 2px; line-height: 0;
  background: var(--panel-2, #1b2130);
  border: 1px solid var(--line, #2b3242); border-radius: 4px; cursor: pointer;
}
.pe-preset canvas { width: 28px; height: 28px; image-rendering: pixelated; }
.pe-preset:hover { border-color: var(--accent, #ffc93c); }
.pe-save { align-self: flex-start; }

/* ---- the guild directory ("Look for a Guild") ------------------------------- */
.guild-directory { list-style: none; padding: 0; margin: 0; }
.guild-directory li {
  display: flex; align-items: center; gap: 10px;
  padding: 6px 0; border-top: 1px solid var(--line, #2b3242);
}
.guild-directory li:first-child { border-top: 0; }
/* 32px: the emblem's own size, so the directory is the one surface that shows it 1:1 — this
   is where a player decides whether they like it. `pixelated` for the usual reason. */
.guild-dir-emblem { width: 32px; height: 32px; image-rendering: pixelated; flex: 0 0 auto; }
.guild-dir-name { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.guild-dir-name .muted { font-size: 12px; }
.guild-directory li > .btn, .guild-directory li > .chip { margin-left: auto; flex: 0 0 auto; }

/* The applicant's own open requests, above the directory. */
.guild-applied { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; }
.guild-applied-row { display: inline-flex; align-items: center; gap: 4px; }
.guild-applicants { margin-top: 14px; border-top: 1px solid var(--line, #2b3242); padding-top: 10px; }
.guild-applicants h4 { margin: 0 0 6px; font-size: 13px; }

/* ---- founding terms, on the Create a Guild tab -----------------------------
   Every number in here is server-supplied (GameService.Guilds.founding_terms/1) — the price
   is a GM knob and the length bounds live in another service. Nothing is styled to look
   optional: the permanence lines are the ones a player most needs to read before they spend. */
.guild-terms {
  border: 1px solid var(--line, #2b3242); border-radius: 6px;
  padding: 10px 12px; margin-bottom: 12px;
  background: var(--panel-2, #1b2130);
}
.guild-price { margin: 0 0 8px; font-size: 15px; font-weight: 600; }
.guild-price .muted { font-weight: 400; margin-left: 6px; }
.guild-terms-list { margin: 0; padding-left: 18px; font-size: 13px; line-height: 1.5; }
.guild-terms-list li { margin: 2px 0; }
/* Used for the shortfall line and the "you have" figure when they cannot pay. Deliberately a
   colour and not an icon: it accompanies a disabled button, which already says "not yet". */
.danger-text { color: var(--danger, #ff6b6b); }

/* ---- the Depósito da Guilda ------------------------------------------------
   The bank's own second-level tab strip and the leader's permission grid. */

/* A MODIFIER on the existing strip, not a second vocabulary. `.tab` is 9px `var(--pixel)` with
   10px/16px padding, and five of those overflow the ~812px modal — so the padding tightens and
   nothing else about how a tab looks or reads changes. */
.tabs.sub { margin: 8px 0 12px; }
.tabs.sub .tab { padding: 6px 10px; }

/* ---- the two-sided board -------------------------------------------------
   `TradeLive`'s layout: what you are giving beside what you are getting. Collapses to one
   column on a narrow modal — two 44px Pokémon grids side by side stop being readable well
   before the panel runs out of width. */
.gb-board { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: start; }
@media (max-width: 720px) { .gb-board { grid-template-columns: 1fr; } }

.gb-side { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.gb-side-head {
  margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
  font-size: 14px;
}

/* The drop target is the whole pane, so the cue has to be the pane — a ring on a cell would
   suggest dropping ONTO that cell means something, and it does not. Same colour and the same
   0.09 alpha as the Depot's two panes, so the gesture reads identically in both places. */
.gb-side.drop-hover { background: rgba(247, 148, 30, 0.09); }
body.dragging-bank .gb-item, body.dragging-bank .dcell { cursor: grabbing; }

/* ---- the Warehouse's drag ------------------------------------------------
   The two panes are `.gb-side` and so already light up through the rule above this one —
   that reuse is the whole reason the panel borrowed the bank's board classes. What is new
   is that a TAB BUTTON is also a drop target (drop a stack on tab 3 to send it there), and
   a `.tab` has no drop state of its own.

   ⚠️ It borrows `.tab.on`'s underline rather than inventing a colour, so "this is where it
   would land" reads as the same language as "this is the tab you are on" — and it uses the
   accent, not the primary, so a hovered tab is distinguishable from the selected one when
   they are the same tab. */
.tab.drop-hover { color: var(--text); background: var(--panel); border-bottom-color: var(--accent); }

/* Dragging in the Warehouse. Deliberately NOT `dragging-item`: that class raises the hotbars
   above the modal backdrop for the item-bar drag, and this gesture has nothing to do with the
   bar — `dragging-mon` keeps itself separate from it for the same reason. */
body.dragging-wh .islot { cursor: grabbing; }

.gb-gold { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.gb-gold form { display: flex; align-items: center; gap: 6px; }
.gb-gold input[type="number"] { width: 110px; }

/* The per-side asset switch. `.tabs.sub`'s tighter padding, one level in again. */
.gb-cols .tab { padding: 5px 9px; }

/* One item stack, the trade window's row shape: icon, name, how many you have, then the
   stepper. `min-width: 0` on the name is what lets a long item name ellipsis instead of
   pushing the stepper out of the pane. */
.gb-rows { display: flex; flex-direction: column; }
.gb-item {
  display: flex; align-items: center; gap: 6px;
  padding: 3px 4px; border-top: 1px solid var(--line, #2b3242);
}
.gb-rows .gb-item:first-child { border-top: 0; }
/* A staged row is tinted rather than ringed: the ring is the Pokémon grid's language (`.sel`),
   and a row is read as text. */
.gb-item.staged { background: rgba(247, 148, 30, 0.10); }
.gb-item-icon { line-height: 0; flex: 0 0 auto; }
.gb-item-icon img { width: 24px; height: 24px; image-rendering: pixelated; }
.gb-item-name {
  flex: 1; min-width: 0; text-transform: capitalize; font-size: 13px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.gb-item-have { font-size: 11px; flex: 0 0 auto; }
.gb-item-staged { min-width: 20px; text-align: center; font-variant-numeric: tabular-nums; }

.gb-mons { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 6px; }
/* Positioned over the cell's top-left corner, and `pointer-events: none` so it cannot eat the
   hover that raises the card explaining it. */
.gb-mon-lock { position: absolute; top: 1px; left: 2px; font-size: 10px; pointer-events: none; }

/* The commit. `margin-top: auto` pins it to the bottom of the pane, so the two sides' buttons
   line up however unevenly the columns above them fill. */
.gb-foot {
  margin-top: auto; padding-top: 8px; display: flex; align-items: center; gap: 8px;
  border-top: 1px solid var(--line, #2b3242);
}
.gb-transfer { flex: 1; }
.gb-count {
  display: inline-block; margin-left: 6px; padding: 0 5px; border-radius: 8px;
  background: rgba(0, 0, 0, 0.28); font-variant-numeric: tabular-nums;
}

/* The grid: a label column then one narrow, centred column per role (leader first, always on
   and always disabled). */
.gb-matrix { display: flex; flex-direction: column; gap: 2px; }
.gb-matrix .gb-row {
  display: grid;
  grid-template-columns: 1fr repeat(3, 64px);
  align-items: center;
  gap: 4px;
  padding: 3px 0;
  border-top: 1px solid var(--line, #2b3242);
}
.gb-matrix .gb-head-row {
  border-top: 0;
  font-size: 12px;
  color: var(--muted, #93a1b5);
  text-align: center;
}
.gb-matrix .gb-head-row > span:first-child { text-align: left; }
.gb-matrix .gb-cap { font-size: 13px; }
.gb-matrix .gb-cell { text-align: center; }

.gb-limits {
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
  padding: 6px 0; border-top: 1px solid var(--line, #2b3242);
}
.gb-limits label { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
.gb-limits input { width: 96px; }

.gb-log { list-style: none; padding: 0; margin: 0; }
.gb-log li {
  padding: 5px 0; border-top: 1px solid var(--line, #2b3242); font-size: 13px;
}
.gb-log li:first-child { border-top: 0; }
.gb-log-pager { display: flex; align-items: center; gap: 8px; margin-top: 10px; }

/* The Pokémon on a history line: its own name, what it was, and a link to its certificate. An
   inline-flex chip rather than a plain link so the meta and the quality word sit on the baseline
   with the name however the line wraps. `border-bottom` rather than `text-decoration`, because the
   chip contains three differently-coloured spans and an underline under all of them reads as one
   run of text. */
.gb-log-mon {
  display: inline-flex; align-items: baseline; gap: 5px;
  padding: 0 3px; border-radius: 4px;
  color: inherit; text-decoration: none;
  border-bottom: 1px dotted var(--muted, #93a1b5);
}
.gb-log-mon:hover { background: rgba(247, 148, 30, 0.10); border-bottom-color: var(--accent, #ffc93c); }
.gb-log-mon-name { text-transform: capitalize; font-weight: 600; }
.gb-log-mon-meta { font-size: 11px; white-space: nowrap; }
/* ⚠️ The card is a child of the `<a>` (the hover target must be the element the pointer is over,
   because `#global-tip` resolves with `closest(".gtip")`) — so it has to be excluded from the
   chip's own inline-flex layout, or it lays out as a fourth inline item. */
.gb-log-mon > .gtip-body { position: absolute; }

/* ⚠️ MANDATORY, and the same rule (and the same reason) as `.gs-buy`'s: the tooltip layer
   resolves a hover with `e.target.closest(".gtip")`, and a disabled <button> is unreliable about
   dispatching mouse events at all. Without this the refusal reason — which rank cannot do this,
   or that today's allowance is spent — can simply never appear, which is the one thing a
   disabled control must not do on a surface whose whole job is explaining itself.
   Scoped to this section for the reason `.gs-buy`'s comment gives. */
.gb-act .gtip > .btn[disabled] { pointer-events: none; }

/* ---- guild confirmations ---------------------------------------------------
   In-flow inside the panel rather than a fixed overlay: GuildLive is a nested live_render
   inside `.game-modal-shell`, which carries a transform for DragModal — and a transformed
   ancestor becomes the containing block for `position: fixed`, so an `.exit-ask-overlay`
   here would cover the modal and not the viewport. Attaching the question to the panel it
   belongs to is the better answer anyway.

   The BUTTONS reuse `.exit-ask-actions` / `.exit-ask-no` / `.exit-ask-yes`, so the colour
   convention documented there carries: the safe answer is neutral and the destructive one is
   red, deliberately the reverse of "confirm is the bright button" muscle memory. */
.guild-confirm {
  margin-top: 12px; padding: 12px 14px;
  border: 1px solid var(--line, #2b3242); border-radius: 6px;
  background: var(--panel-2, #1b2130);
}
.guild-confirm.danger { border-color: var(--danger, #ff6b6b); }
.guild-confirm h4 { margin: 0 0 6px; font-size: 14px; }
.guild-confirm p { margin: 0 0 8px; line-height: 1.45; }
.guild-confirm .row-form { margin: 6px 0; }
.guild-confirm .exit-ask-actions { justify-content: flex-start; margin-top: 10px; }
/* Smaller than the full-screen ask's, which is sized for a cinematic overlay. */
.guild-confirm .exit-ask-actions button { padding: 6px 16px; border-width: 2px; }

/* ---------------- Collector Magnus's bounty board ---------------- */
/* The cards used to borrow `.mon`, which is `display: block` with no way to pin anything —
   so a two-line zone hint, a wrapped chip row or a missing condition pushed the reward line
   and the button to a different height on every card, and the row read as ragged. Each
   region is sized here instead, and the footer is pinned with `margin-top: auto`, so the
   buttons line up whatever the content does.

   ⚠️ The regions are `min-height`, never fixed `height`: a species name that wraps to two
   lines must be allowed to, and clipping it would hide the one word the card is about. */
.bounty-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }

/* Laurel gold rather than `--edge`: this is Magnus's board and the whole card is about money,
   so it borrows the same accent his Gold badge and the reward line already use. `.mon` gets
   its border from a per-cell `--q` (quality tier) — a bounty describes a SPECIES and has no
   quality, so there is nothing for that variable to say here. */
.bounty-card {
  display: flex; flex-direction: column; background: var(--panel);
  border: 3px solid var(--accent); box-shadow: inset 0 0 0 2px var(--border);
  padding: 12px; text-align: center; position: relative;
}
.bounty-card .sprite { width: 96px; height: 96px; margin: 4px auto 2px; image-rendering: pixelated; display: block; }
.bounty-card .name {
  font-family: var(--pixel); font-size: 9px; line-height: 1.5; text-transform: capitalize;
  color: var(--text); min-height: 27px;
}
/* Two rows of chips fit without moving anything below. Three conditions is the ceiling the
   generator enforces (`Bounties.Generator`), so this cannot be outgrown by a data change. */
.bounty-chips {
  display: flex; flex-wrap: wrap; gap: 4px; justify-content: center;
  margin: 8px 0 6px; min-height: 50px; align-content: flex-start;
}
/* The zone hint is the most variable line on the card — "Rota 5" against "Indigo plateau lago
   central". Two lines of room, then clamp: a third line is worth less than a straight row of
   buttons, and `zones_line/1` already caps the list at three zones. */
.bounty-where {
  font-size: 11px; color: var(--muted); line-height: 1.35; min-height: 30px;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
/* `margin-top: auto` is what makes every button in the row land on the same line. */
.bounty-foot { margin-top: auto; padding-top: 8px; }
.bounty-foot .price-badge { margin-top: 0; font-size: 13px; line-height: 1.3; }
.bounty-foot .btn { margin-top: 8px; width: 100%; }
/* "↑ up from 10x · 13x in 5h" — why a stale shelf pays more than it was posted at.
   ⚠️ It needs NO reserved min-height, unlike every other region on this card, and the reason is
   the `margin-top: auto` above: the footer is pinned to the card's bottom, so a taller footer
   grows UPWARD and the button — the thing that block exists to keep aligned — does not move.
   Reserving a line here would add ~28px of blank to every card that is not rising. */
.bounty-risen {
  font-size: 11px; line-height: 1.3; color: var(--accent); margin-top: 4px;
}

/* ── Collector: supply and demand ────────────────────────────────────────────────
   The ▲/▼ chip beside a price, and the two-column market board.

   ⚠️ The chip's colours are read the way a PLAYER reads them, not the way the class
   names describe the market: `.up` means "the Collector pays more for this", so it is
   green. That is the opposite convention from `.farm-wx-line.up`, which names what the
   sky does to a TYPE and is deliberately painted against the player's interest. Two
   different questions, two different answers — do not "fix" one to match the other. */
.demand-chip {
  display: inline-block;
  margin-left: 6px;
  padding: 0 5px;
  border-radius: 4px;
  font-size: 11px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  vertical-align: middle;
}
.demand-chip.up { color: #7ee08a; background: rgba(126, 224, 138, 0.12); }
.demand-chip.down { color: #ff8f8f; background: rgba(255, 143, 143, 0.12); }

.market-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
@media (max-width: 720px) { .market-cols { grid-template-columns: 1fr; } }

.market-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 8px;
  border-radius: 8px;
  background: var(--panel-2, rgba(255, 255, 255, 0.03));
  margin-bottom: 6px;
}
.market-row .sprite { width: 40px; height: 40px; image-rendering: pixelated; flex: 0 0 40px; }

/* Mailbox item slot. ⚠️ It has to size BOTH children, not just the img: `item_icon/1` renders
   the sprite and a hidden emoji sibling it swaps in when the art 404s, so a rule scoped to the
   image alone leaves an un-sized glyph in a 48px row and the whole line jumps. The mailbox used
   to hand-build the <img> with an inline width, which is exactly what skipped the fallback. */
.mail-ico { width: 48px; flex: 0 0 48px; text-align: center; }
.mail-ico .sprite { width: 48px; height: 48px; image-rendering: pixelated; display: block; }
.mail-ico .islot-glyph { font-size: 28px; line-height: 48px; }
.market-row .name { font-weight: 700; font-size: 13px; }
/* The price block is the row's right edge — the zone list is the variable-width part. The
   `margin-left: auto` moved here from `.demand-chip` when the price landed above it; leaving it
   on both makes the chip drift away from the number it annotates. Only the `.market-row`-scoped
   chip rule changes — the bare `.demand-chip` is shared with the sell grid's price badge. */
.market-row .market-price { margin-left: auto; flex: 0 0 auto; text-align: right; }
.market-row .market-price .price { display: block; font-size: 12px; }
.market-row .demand-chip { margin-left: 0; font-size: 12px; }

/* The board is the WHOLE list now (it used to be the top 8 a side), so each column scrolls on
   its own. Two columns scrolling independently rather than the modal scrolling once is what
   keeps both headings on screen — with one long page you lose sight of which list you are in.

   ⚠️ `min-height: 0` on the sections is load-bearing: a grid item's default `min-height: auto`
   refuses to shrink below its content, so the column would simply grow to ~120 rows and the
   `max-height` here would never engage. The symptom is "the scrollbar does not appear".
   ⚠️ `padding-right` reserves the gutter, so the scrollbar does not sit on top of the price. */
.market-cols > section { min-height: 0; display: flex; flex-direction: column; }
.market-list { overflow-y: auto; max-height: min(46vh, 420px); padding-right: 6px; }
/* One row is ~58px, so this stops a 3-row list from being a short box with a big empty gap
   under it while still making it obvious the long one scrolls. */
.market-list:empty { display: none; }
.market-count { font-weight: 400; font-size: 12px; opacity: 0.65; margin-left: 6px; }

/* ---- Sprite report (ReportLive, category "sprite") -------------------------------------- */
/* The nine walk tiles are built by the SpriteReport hook, which sets --pk-cell, --pk-w0 and
   --pk-w1 per tile and writes a literal `steps(N)` into the inline animation shorthand — N is
   per-species (frames run 3..10 across the manifest), and steps() cannot read a custom property.

   ⚠️ The cycle runs column --pk-w0 -> --pk-w1, i.e. the first walk column to one PAST the last.
   steps(N) defaults to jump-end, so it samples w0 .. w0+N-1 and never paints the wrap column.
   Stepping by sheetWidth/N instead walks the window off-register a little more each frame, which
   reads as the sprite sliding sideways — see the .skin-preview block above, which learned it. */
@keyframes pkanim-walk {
  from { background-position-x: calc(-1 * var(--pk-w0) * var(--pk-cell)); }
  to   { background-position-x: calc(-1 * var(--pk-w1) * var(--pk-cell)); }
}
.pkanim-grid { display: grid; grid-template-columns: repeat(3, auto); gap: 6px;
  justify-content: center; justify-items: center; margin: 0 0 12px; }
/* Placed by the hook, but declared here so an empty grid (no manifest entry) collapses cleanly. */
.pkanim-grid:empty { display: none; }
.pkanim-note { text-align: center; margin: 0 0 12px; }
/* A tile is a button: it is clickable, keyboard-reachable and toggles a mark. The reset is
   because .btn's chrome would fight the sprite for the 120px box. */
.pkanim-tile, .pkanim-still { background: var(--bg-2); border: 2px solid var(--border);
  border-radius: 0; padding: 0; cursor: pointer; position: relative;
  display: flex; flex-direction: column; align-items: center; }
.pkanim-tile { background-repeat: no-repeat; image-rendering: pixelated; }
.pkanim-tile:hover, .pkanim-still:hover { border-color: var(--text); }
/* ⚠️ The marked state is an outline plus a tint, NOT a badge glyph. The art-direction rule bans
   emoji in game UI, and anything drawn INSIDE a tile would be mistaken for part of the sprite the
   player is being asked to judge — which is the one thing this control must never do. */
.pkanim-tile.marked, .pkanim-still.marked { border-color: #e0564f; outline: 2px solid #e0564f;
  outline-offset: 1px; }
.pkanim-tile.marked::after, .pkanim-still.marked::after { content: ""; position: absolute;
  inset: 0; background: rgba(224, 86, 79, 0.18); pointer-events: none; }
.pkanim-label { font-family: var(--pixel); font-size: 9px; color: var(--muted);
  padding: 2px 0 3px; pointer-events: none; }
.pkanim-tile.marked .pkanim-label, .pkanim-still.marked span { color: #e0564f; }
.pkanim-still { gap: 2px; padding: 4px 8px 5px; margin: 0 auto; width: fit-content;
  font-family: var(--pixel); font-size: 9px; color: var(--muted); }
.pkanim-still img { display: block; }

/* ── Battle Pass (BattlePassLive) ────────────────────────────────────────────
   A 14-day ladder of reward tiers on two tracks. Borrows `.medal-card.locked`'s
   dimming vocabulary on purpose: an unearned rung and an unearned medal should
   read alike, and `opacity .55 + grayscale(1)` is already the panel language for
   "visible, but not yours yet".

   ⚠️ The XP meter is `.bar.xp-bar > .xp-fill`, defined once near the top of this
   file. Do not hand-pick a gradient for it — the palette block says so outright,
   and a second XP colour is how two bars in one game stop matching. */
/* The whole panel sits inside ONE `<.league_frame class="bp-board">` (the Gyms board's frame;
   the modal itself is `framed-modal`, so the League plate is the only ring). The header is a
   single row plus the bar — the explanations it used to carry live in the ? help. */
.bp-board .bp-empty { padding: 12px 4px; }
.bp-head { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; }
.bp-head-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.bp-head-ico { width: 32px; height: 32px; image-rendering: pixelated; flex: none; }
.bp-name { font-size: 16px; margin-right: auto; }
.bp-deluxe-buy { display: inline-flex; align-items: center; gap: 4px; }
.bp-deluxe-pill { border-color: var(--accent); color: var(--accent); }
/* The XP figure rides INSIDE the bar, so the meter and its number are one object. The bar is
   taller here than the shared 12px for that reason only; the fill is still `.xp-fill`. */
.bp-head .xp-bar { position: relative; height: 20px; }
.bp-xp { position: absolute; inset: 0; display: grid; place-items: center; font-size: 11px;
  font-variant-numeric: tabular-nums; color: var(--text); text-shadow: 0 1px 0 #000, 0 0 2px #000; }
.bp-cap { font-size: 11px; }

/* The two column headers sit above the ladder and line up with the two cells in
   each row, so a player can tell which track a reward is on without reading the
   chip. The 64px lead column is the rung number. */
.bp-track-labels { display: grid; grid-template-columns: 64px 1fr 1fr; gap: 8px;
  margin: 12px 0 6px; padding: 0 4px; }
.bp-track-labels > :first-child { grid-column: 2; }
.bp-track-label { display: flex; align-items: center; gap: 6px; font-size: 12px;
  font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.bp-track-label.locked { opacity: 0.7; }

/* ⚠️ Scrolls in its own box rather than growing the modal. Thirty rungs is taller
   than any screen, and a panel that scrolls the page behind it loses the header
   with the XP bar in it — which is the one thing a player opens this to see. */
.bp-ladder { display: flex; flex-direction: column; gap: 6px;
  max-height: 52vh; overflow-y: auto; padding-right: 4px; }

.bp-rung { display: grid; grid-template-columns: 64px 1fr 1fr; gap: 8px; align-items: stretch; }
.bp-rung-no { display: flex; flex-direction: column; align-items: center; justify-content: center;
  border: 2px solid var(--edge); border-radius: 8px; background: var(--panel); padding: 6px 0; }
.bp-rung-no b { font-size: 15px; line-height: 1; }
.bp-rung-no span { font-size: 10px; font-variant-numeric: tabular-nums; }
.bp-rung.reached .bp-rung-no { border-color: var(--primary); }

.bp-cell { display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 5px; min-height: 66px; padding: 8px; border: 2px solid var(--edge);
  border-radius: 8px; background: var(--bg-2); text-align: center; }
/* The deluxe column is tinted so the two tracks are distinguishable at a glance
   even where the headers have scrolled out of view. */
.bp-cell.deluxe { background: var(--panel); }
/* Reached but not yours — the medal vocabulary. Dimmed, but not so far that the reward art
   stops being the thing you see: at 0.55/grayscale(1) the icon lost to its own caption. */
/* ⚠️ A locked rung is UPCOMING, not unavailable: it renders in full colour and the corner padlock
   is what says "not yet" (2026-10-15, user call). Greying it read as "this can never be earned". */
/* An empty rung on a track is a GAP, not a claimable nothing: no border, no plate,
   so the eye skips it instead of reading it as a button that does not work. */
.bp-cell.empty { border-color: transparent; background: none; min-height: 0; }
.bp-cell.claimed { border-color: var(--green); }
/* State marks shared with the daily gift trail: a tick (claimed) and a padlock (not yours yet),
   both 16px pixel art pinned to the cell's top-right corner. A claimed cell's rewards dim so the
   tick reads first; a claimable one glows, which is the only state with no mark. */
.bp-cell { position: relative; }
.bp-mark { position: absolute; top: 4px; right: 4px; width: 16px; height: 16px; image-rendering: pixelated; z-index: 1; }
.bp-mark-check { width: 32px; height: 32px; top: 2px; right: 2px; }
.bp-cell.claimed .bp-rewards { opacity: 0.45; }
.bp-cell.claimable { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent) inset, 0 0 10px rgba(255, 201, 60, 0.4); }

.bp-rewards { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
/* ⚠️ A reward is ART FIRST — the item is what the rung is *for*, so it gets the mass of the
   chip and the amount rides its corner, exactly as a bag slot draws it (`.islot .qty`). It
   used to be a 16px icon beside a number inside a text-sized pill, which made the reward
   smaller than the word "Bloqueado" printed under it. Reported from live play. */
.bp-reward { position: relative; display: inline-flex; align-items: center;
  justify-content: center; width: 76px; height: 76px; padding: 0;
  border: 2px solid var(--edge); border-radius: 8px; background: var(--panel); }
/* Item art is authored at 32px on the 32px grid, so 64 is an exact 2x — every source pixel
   becomes a clean 2x2 block under the game's nearest-neighbour rendering, with no row
   doubling unevenly. ⚠️ Keep this an INTEGER multiple of 32: it briefly sat at 38px (1.1875x)
   and the art visibly softened. `size={64}` on the gold `cur_icon` in `battle_pass_live.ex`
   is the same number and moves with it — gold sizes itself inline and never reads this rule. */
.bp-reward-ico { width: 64px; height: 64px; image-rendering: pixelated; flex: none; }
/* The amount is a badge over the art, not a sibling of it: the bag's vocabulary, so a
   quantity is read the same way in both places. Wide numbers (gold is the common case)
   run leftwards across the icon rather than widening the chip. */
.bp-qty { position: absolute; right: 1px; bottom: 0; padding: 0 3px; border-radius: 4px;
  font-size: 11px; font-weight: 800; line-height: 1.35; color: #fff;
  background: rgba(10, 15, 24, 0.82); text-shadow: 0 1px 2px #000;
  font-variant-numeric: tabular-nums; }
/* Cosmetics have no art, so their chip stays a text pill and opts out of the square. */
.bp-reward.text { width: auto; height: auto; padding: 3px 7px; font-size: 11px; }
/* There is deliberately no state WORD in a cell. It was the same three captions on every
   rung — claimed / locked / coming soon — carrying nothing the green border and the
   desaturation do not already say, and costing the rung a whole line of height. */

/* "Today" — the closing checklist of what can still pay pass XP. A done row dims and keeps its
   place (so the list never reshuffles under the player's eye); an open row offers Go. */
.bp-today { margin-top: 14px; border-top: 2px solid var(--edge); padding-top: 10px; }
.bp-today-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
  margin-bottom: 6px; }
.bp-today-head .muted { font-size: 11px; font-variant-numeric: tabular-nums; }
.bp-today-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.bp-today-row { display: grid; grid-template-columns: 32px 1fr auto 64px; align-items: center; gap: 8px;
  padding: 4px 8px; border: 2px solid var(--edge); background: var(--bg-2); }
.bp-today-row.done { opacity: 0.55; }
.bp-today-ico { width: 32px; height: 32px; image-rendering: pixelated; }
.bp-today-label { font-size: 13px; }
.bp-today-xp { font-size: 12px; color: var(--accent); font-variant-numeric: tabular-nums; }
.bp-today-state.done { font-size: 11px; color: var(--green); text-align: center; }
.bp-today-row .btn.small { justify-self: stretch; }

/* Phones: the two tracks stack rather than squeezing to 40px each, and the
   ladder gets the whole screen since the modal is full-bleed there anyway. */
@media (max-width: 640px) {
  .bp-rung, .bp-track-labels { grid-template-columns: 48px 1fr; }
  .bp-track-labels { display: none; }
  .bp-rung > .bp-cell { grid-column: 2; }
  .bp-ladder { max-height: none; }
  .bp-today-row { grid-template-columns: 28px 1fr auto 56px; gap: 6px; }
}

/* ---- Cookie consent banner ------------------------------------------------
   The same `#pk-consent` markup the website renders (one shared consent.js serves
   both), so these rules mirror the website's app.css. Keep the two in step.

   ⚠️ In this app the banner is the FALLBACK, not the main event: a player answers on
   the portal and arrives with the cookie set, so consent.js normally leaves it hidden.
   It is here for somebody who opens play.pokeafk.com directly with a live session.

   ⚠️ z-index 730, above the toast stack (700) and the floating layer (720). It asks a
   question that has to be answerable, and a HUD element painting over the buttons would
   leave a player unable to accept OR decline — with no way to dismiss it either.

   ⚠️ `[hidden]` is honoured by the UA stylesheet because nothing here sets `display`
   on `.consent` itself. Do NOT add `.consent { display: flex }` without an
   `[hidden] { display: none }` override, or the banner renders for everyone, forever. */
.consent {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 730;
  background: var(--panel);
  border-top: 2px solid var(--border);
  box-shadow: 0 -4px 0 rgba(3, 7, 14, 0.55);
  padding: 12px 14px;
}
.consent-inner {
  max-width: 1120px;
  margin: 0 auto;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
  justify-content: space-between;
}
.consent-text {
  margin: 0;
  flex: 1 1 380px;
  font-size: 12px;
  line-height: 1.55;
  color: var(--muted);
}
.consent-text a { color: var(--link); }
.consent-actions { display: flex; gap: 8px; flex: 0 0 auto; }
/* Refusing must be exactly as easy as accepting — same size, same weight, only the
   colour differs. */
.consent-actions .btn { white-space: nowrap; }

@media (max-width: 560px) {
  .consent-inner { flex-direction: column; align-items: stretch; }
  .consent-actions .btn { flex: 1 1 0; text-align: center; }
}

/* ---- Trading cards -----------------------------------------------------------
   The binder panel (`CardsLive`), the card tile (`CardComponents.card_tile/1`), the trade
   window's card picker, the `sk-card` chip on a skill row and the socket row on the Pokémon
   card. The booster reveal overlay in app.js carries its own self-injected style block and
   only SHARES the four sheen classes below (`.card-foil` / `.card-reverse` / `.card-fullart`
   / `.card-illus`), so a foil looks like a foil in the binder and in the reveal alike.

   ⚠️ The sheens are CSS over the composed PNG, never a second render: `build_cards.py compose`
   already draws the holo pattern into the art (foil) or the frame (reverse foil); this is the
   moving highlight on top. Sizes are multiples of the 192x256 art (96x128, 64x85.33 → the
   `height` attr rounds), and the art is always `image-rendering: pixelated`. */
.game-modal.cards-modal { max-width: 1080px; }
.cards-head p { margin: 4px 0 0; }
.cards-strip { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
.cards-strip .pill { display: inline-flex; align-items: center; gap: 6px; }
.cards-tabs { margin-bottom: 12px; }
.cards-filters { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.cards-search input { width: 100%; background: var(--bg-2); border: 2px solid var(--border);
  border-radius: 0; padding: 6px 8px; color: var(--text); font: inherit; }
.cards-chips { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.cards-chips .chip { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; font: inherit; font-size: 12px; }
.cards-chips .chip.on { background: var(--primary); color: var(--edge); border-color: var(--edge); font-weight: 800; }
.cards-chip-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; margin-right: 4px; }
.cards-empty { padding: 14px 0; }
.cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); gap: 10px; }
.cards-grid-trade { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 6px; }

/* ---- the tile ---- */
.card-tile { position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 8px 6px; border: 2px solid var(--edge); background: var(--bg-2); text-align: center; color: var(--text); }
.card-tile[role="button"] { cursor: pointer; }
.card-tile[role="button"]:hover { background: var(--panel); }
.card-tile.sel { box-shadow: inset 0 0 0 2px var(--primary); }
.card-tile.card-dormant .card-frame { filter: grayscale(0.8); opacity: 0.7; }
.card-frame { position: relative; display: inline-block; line-height: 0; }
.card-img { image-rendering: pixelated; display: block; background: var(--panel); }
.card-sheen { position: absolute; inset: 0; pointer-events: none; opacity: 0; }
/* foil / reverse foil / illustration: a moving highlight, drawn as a SEAMLESS loop.
   ⚠️ The band is a HORIZONTAL gradient on a tile that repeats, painted on an oversized
   `::before` rotated 25deg (so the bands run diagonally), and `cardSheenLoop` moves the tile by
   exactly ONE period — the last frame is therefore pixel-identical to the first. For a tile `t`
   of the layer's width, background-position p% offsets it by p·(1−t) of the width, so one period
   is p = t / (1 − t): 75% → 300%. The gradient's 0% and 100% stops must both be transparent or the
   tile edges show as seams. Two earlier versions moved a single diagonal band corner to corner;
   its ends never matched, and every loop visibly jumped back (reported from the binder twice).
   `.card-sheen` is the static clip (and, for reverse foil, the frame mask); only `::before` moves. */
.card-foil .card-sheen, .card-reverse .card-sheen, .card-illus .card-sheen {
  opacity: 1; mix-blend-mode: screen; overflow: hidden; }
.card-foil .card-sheen::before, .card-reverse .card-sheen::before, .card-illus .card-sheen::before {
  content: ""; position: absolute; inset: -50%; transform: rotate(25deg);
  background-size: 75% 100%; background-repeat: repeat;
  animation: cardSheenLoop 6.4s linear infinite; }
/* ⚠️ Where each foil shines is the COMPOSITOR's geometry (`scripts/build_cards.py`, 192x256):
   HOLO_BOX is the art window plus its 2 px rim (14,40 164x112). A foil shines ON that box; a
   reverse foil is its exact complement — everywhere else, the text bands and the outer border
   included (the user's call: "the reverse foil should be the reverse"). Written in % of the card
   so every card size lines up; mask-position is x/(1 − size) per axis. Move the window in the
   compositor and these move with it, or the CSS shine drifts off the printed pattern (reported
   from the binder). `cards_sheen_loop_test.exs` derives the same numbers from the Python. */
.card-foil .card-sheen { inset: auto; left: 7.2917%; top: 15.625%; width: 85.4167%; height: 43.75%; }
.card-foil .card-sheen::before {
  background-image: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0) 34%,
    rgba(255,255,255,0.35) 44%, rgba(120,220,255,0.35) 50%, rgba(255,160,255,0.30) 56%,
    rgba(255,255,255,0) 66%, rgba(255,255,255,0) 100%); }
.card-foil .card-frame { box-shadow: 0 0 0 2px #c9d3e0; }
/* reverse foil: the same highlight over the WHOLE card with one hole, HOLO_BOX — the split the
   compose script makes. The mask is the full layer XOR the window box. */
.card-reverse .card-sheen {
  -webkit-mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
  -webkit-mask-size: 100% 100%, 85.4167% 43.75%;
  -webkit-mask-position: 0 0, 50% 27.7778%;
  -webkit-mask-repeat: no-repeat; -webkit-mask-composite: xor;
  mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
  mask-size: 100% 100%, 85.4167% 43.75%;
  mask-position: 0 0, 50% 27.7778%;
  mask-repeat: no-repeat; mask-composite: exclude; }
.card-reverse .card-sheen::before {
  background-image: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0) 34%,
    rgba(255,255,255,0.4) 45%, rgba(120,220,255,0.4) 50%, rgba(255,160,255,0.35) 55%,
    rgba(255,255,255,0) 66%, rgba(255,255,255,0) 100%); }
/* full art: gold ring and a slow conic sweep */
.card-fullart .card-frame { box-shadow: 0 0 0 2px var(--accent); }
/* ⚠️ The spin rotates an oversized `::before` INSIDE a clipping `.card-sheen`, never the sheen
   itself — rotating the sheen swung its rectangle's corners outside the card (reported from the
   binder). `inset: -50%` makes the layer 2x the card, whose inscribed circle (radius = the card's
   width) covers the card's half-diagonal at every angle, so no corner of the card is ever bare. */
.card-fullart .card-sheen { opacity: 1; mix-blend-mode: screen; overflow: hidden; }
.card-fullart .card-sheen::before { content: ""; position: absolute; inset: -50%;
  background: conic-gradient(from 0deg, rgba(255,255,255,0) 0deg, rgba(255,230,140,0.35) 40deg,
    rgba(255,255,255,0) 80deg, rgba(120,220,255,0.25) 200deg, rgba(255,255,255,0) 240deg);
  animation: cardSpin 6s linear infinite; }
/* illustration rare: gold shimmer around the whole tile */
.card-illus .card-frame { box-shadow: 0 0 0 2px var(--accent), 0 0 14px rgba(255,201,60,0.55); }
.card-illus .card-sheen::before { animation-duration: 4.8s;
  background-image: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0) 38%,
    rgba(255,230,140,0.45) 50%, rgba(255,255,255,0) 62%, rgba(255,255,255,0) 100%); }
/* the graded slab — a minted card sits in a case */
/* the sheen sits on the IMAGE, not on the slab's padding — inset by that padding */
.card-slab .card-sheen { inset: 4px; }
/* a minted FOIL: HOLO_BOX measured inside the slab's 4px padding (percentages of the padding
   box = image + 8px, so each term subtracts that 8px back out) */
.card-slab.card-foil .card-sheen { inset: auto; left: calc(7.2917% + 3.4167px); top: calc(15.625% + 2.75px);
  width: calc(85.4167% - 6.8333px); height: calc(43.75% - 3.5px); }
.card-slab .card-frame { padding: 4px; border: 2px solid #9fb3cc;
  background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.03)); }
.card-slab-seal { position: absolute; right: 2px; top: 2px; width: 16px; height: 16px; pointer-events: none;
  background: url("/assets/game/ui/cards/graded.png") no-repeat center / 16px 16px; image-rendering: pixelated; }
.card-tile-name { font-weight: 800; font-size: 12px; line-height: 1.2; }
.card-tile-move { font-size: 11px; line-height: 1.2; }
.card-tile-meta { display: flex; align-items: center; justify-content: center; gap: 4px; flex-wrap: wrap; font-size: 11px; }
.card-tile-rarity { color: var(--muted); }
.card-tile-effect { font-size: 11px; }
.card-tile-chips { display: flex; gap: 4px; flex-wrap: wrap; justify-content: center; }
.card-tile-chips .pill { font-size: 9px; padding: 1px 6px; }
.card-chip-equipped { color: var(--link); }
.card-chip-dormant { color: var(--muted); }
.card-tile-actions { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; margin-top: 2px; }
.card-tile-actions .btn { display: inline-flex; align-items: center; gap: 4px; }
.card-tile-on, .card-tile-wear { font-size: 10px; }
.card-rarity-ico { image-rendering: pixelated; vertical-align: -3px; }
.card-grade { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; font-weight: 800;
  padding: 0 4px; border: 1px solid var(--edge); background: var(--panel); line-height: 1.6; }
.card-grade img { image-rendering: pixelated; }
.card-grade-nm { color: #7fc96b; }
.card-grade-lp { color: #b5d96b; }
.card-grade-mp { color: #ffd447; }
.card-grade-hp { color: #ffa03c; }
.card-grade-d { color: #ff5a4d; }
.card-grade-pct { color: var(--accent); font-variant-numeric: tabular-nums; }

/* ---- boosters ---- */
.cards-tokens { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.cards-tokens p { margin: 2px 0 0; font-size: 12px; }
.cards-sets { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.cards-set { display: flex; gap: 14px; align-items: center; }
.cards-pack { image-rendering: pixelated; flex: none; }
.cards-set-body { flex: 1; }
.cards-set-body p { margin: 4px 0 8px; font-size: 12px; }
.cards-set-body .btn { display: inline-flex; align-items: center; gap: 6px; }
.cards-short { font-size: 11px; margin-top: 6px; }
.cards-how { margin-top: 12px; }
.cards-how p { margin: 4px 0 0; font-size: 12px; }

/* ---- equip ---- */
.cards-equip { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 12px; align-items: start; }
.cards-party { display: flex; flex-direction: column; gap: 6px; }
.cards-party-mon { display: flex; align-items: center; gap: 8px; padding: 6px; border: 2px solid var(--edge);
  background: var(--panel); color: var(--text); cursor: pointer; text-align: left; font: inherit; }
.cards-party-mon:hover { background: var(--panel-2); }
.cards-party-mon.on { border-color: var(--primary); }
.cards-party-mon img { image-rendering: pixelated; flex: none; }
.cards-party-name { font-weight: 800; font-size: 12px; flex: 1; }
.cards-party-slots { font-size: 10px; }
.cards-moves-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.cards-move-row { display: flex; flex-direction: column; gap: 6px; padding: 8px 0; border-top: 2px solid var(--border); }
.cards-move-name { display: flex; align-items: center; gap: 6px; }
.cards-move-cards { display: flex; flex-wrap: wrap; gap: 8px; }
.cards-none { font-size: 12px; }
.cards-dormant { margin-top: 10px; padding-top: 8px; border-top: 2px solid var(--border); }
.cards-dormant p { margin: 0 0 8px; font-size: 12px; }

/* ---- the confirm dialog (unequip fee / mint) ---- */
/* The large card view: one card at up to 2x its native 192x256 (384x512), shrunk to fit a
   phone or a short window while keeping the aspect. Pixelated, like every card image. */
.cards-zoom-backdrop { position: fixed; inset: 0; z-index: 61; display: flex; align-items: center;
  justify-content: center; padding: 16px; background: rgba(5, 10, 22, 0.82); cursor: zoom-out; }
.cards-zoom { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 18px;
  max-width: 100%; }
.cards-zoom .card-img { width: min(384px, 86vw, calc((100vh - 140px) * 0.75)); height: auto; }
.cards-zoom-info { display: flex; flex-direction: column; gap: 6px; min-width: 180px; max-width: 280px;
  font-size: 13px; }
.cards-zoom-info b { font-size: 16px; }
.cards-zoom-info .card-tile-meta { justify-content: flex-start; }
.cards-zoom-hint { margin-top: 8px; font-size: 11px; }
.cards-confirm-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center;
  justify-content: center; background: rgba(2, 6, 14, 0.7); }
.cards-confirm { display: flex; gap: 14px; width: min(92vw, 460px); }
.cards-confirm-body { flex: 1; display: flex; flex-direction: column; gap: 8px; }
.cards-confirm-body p { margin: 0; font-size: 12px; }
.cards-confirm-body p.cards-confirm-offer { color: var(--accent); }
.cards-confirm-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
.cards-confirm-actions .btn { display: inline-flex; align-items: center; gap: 6px; }

/* ---- on the Pokémon card ---- */
.card-sockets { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; }
.card-sockets-label { margin-right: 2px; }
.card-socket img { image-rendering: pixelated; opacity: 0.45; display: block; }
.card-socket.filled img { opacity: 1; }
.sk-tag.sk-card { display: inline-flex; align-items: center; gap: 4px; background: rgba(255, 201, 60, 0.14); }
.sk-tag.sk-card-dormant { opacity: 0.6; }
.sk-card-grade { margin-left: 2px; }
/* the card row on the move hover cards — the rarity is a name, not a stat, so no capitalize */
.mt-card { text-transform: none; }

@keyframes cardSheenLoop { from { background-position: 0% 0; } to { background-position: 300% 0; } }
@keyframes cardSpin { to { transform: rotate(360deg); } }

@media (max-width: 640px) {
  .cards-equip { grid-template-columns: 1fr; }
  .cards-party { flex-direction: row; flex-wrap: wrap; }
  .cards-set { flex-direction: column; align-items: stretch; text-align: center; }
  .cards-pack { align-self: center; }
  .cards-confirm { flex-direction: column; align-items: center; }
}

/* ==================================================================== */
/* The logged-out sandbox demo (/try) — PlayServiceWeb.DemoLive.        */
/*                                                                      */
/* ⚠️ Every colour here is an existing token. This is the first page a   */
/* stranger sees, so it has to read as the same product as the rest of  */
/* the client — a demo with its own palette advertises a game that does */
/* not exist. Pixel rules apply as everywhere else: no radii, hard      */
/* edges, `image-rendering: pixelated` on every sprite.                 */
/* ==================================================================== */
.demo-shell { position: fixed; inset: 0; overflow: hidden; background: var(--bg); }
.demo-shell #game-canvas { position: absolute; inset: 0; }

/* The one piece of chrome that is always up: a stranger must never be unsure whether they are
   looking at the real game or a sample of it. */
.demo-badge {
  position: absolute; top: 10px; left: 10px; z-index: 20;
  padding: 5px 9px; border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  background: var(--panel); color: var(--accent);
  font-family: var(--pixel); font-size: 9px; letter-spacing: 0.06em; text-transform: uppercase;
}

/* ---- the running HUD ------------------------------------------------ */
/* ⚠️ `max-height` + `overflow-y`, and it is not cosmetic: the log grows by a line per catch,
   and an unbounded panel eventually runs down the screen into `.hud-hotbars` — which is fixed
   to the bottom centre and is the one thing a player must always be able to click. */
.demo-hud {
  position: absolute; top: 10px; right: 10px; z-index: 20; width: min(46vw, 260px);
  max-height: calc(100vh - 160px); overflow-y: auto;
  display: flex; flex-direction: column; gap: 8px;
  padding: 10px; border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  background: rgba(16, 31, 54, 0.88); color: var(--text);
}
/* The whole header is the toggle — a 10px caret is not a touch target. */
.demo-hud-head {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  padding: 0; background: none; border: 0; cursor: pointer; font: inherit; text-align: left;
}
.demo-hud-head:hover .demo-team, .demo-hud-head:focus-visible .demo-team { color: var(--primary-2); }
.demo-caret { color: var(--muted); font-size: 11px; }
.demo-team { margin: 0; font-family: var(--pixel); font-size: 10px; color: var(--accent); }
.demo-hud.collapsed { gap: 6px; }
/* ⚠️ Required, and the reason is easy to miss: the UA stylesheet's `[hidden] { display: none }`
   is a plain element rule, so the `display: flex` on `.demo-hints` below WINS and the list stays
   on screen with the attribute set. The panel then "collapsed" by exactly the 4px of its own
   gap — the hints were never hidden at all. */
.demo-hints[hidden], .demo-log[hidden] { display: none; }
.demo-hints, .demo-log { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; }
.demo-hints li { font-size: 11px; color: var(--muted); }
.demo-log:empty { display: none; }

/* Log lines are TOASTS. They appear, hold, and fade — the panel is drawn over the map and a
   permanent transcript of "Oddish broke free" ends up as a wall of text across the world.
   ⚠️ The 4.5s here must match `DemoLive.@line_ttl_ms`, which is what actually removes the node:
   a shorter animation leaves an invisible line holding layout, a longer one gets cut off
   mid-fade. `forwards` holds the final frame for the gap between the two. */
@keyframes demoLogLife {
  0%   { opacity: 0; transform: translateY(-3px); }
  6%   { opacity: 1; transform: none; }
  72%  { opacity: 1; }
  100% { opacity: 0; }
}
.demo-log li {
  font-size: 11px; color: var(--text);
  animation: demoLogLife 4.5s ease-out forwards;
}
/* Someone who cannot take the motion still gets the line and still gets it removed on time —
   the server's TTL is what retires it, so only the animation has to go. */
@media (prefers-reduced-motion: reduce) {
  .demo-log li { animation: none; }
}

/* ---- the centred step banner ----------------------------------------- */
/* ⚠️ Deliberately the SAME object as `#pull-cd` in app.js — the party countdown is the one
   thing in this game that already interrupts the middle of the screen, and inventing a second
   visual language for "read this now" would be a second thing for a stranger to learn. The
   values below are that rule's, copied on purpose: Press Start 2P, the same pane colour, the
   same 3px edge with a gold inset ring and glow, the same scale-in curve.
   It is NOT shared code: that style is injected from inside app.js's IIFE, which this view
   cannot reach and must not be edited to expose. */
.demo-announce {
  position: absolute; inset: 0; z-index: 40;
  /* ⚠️ Sits ABOVE centre, unlike `#pull-cd`. That overlay is a party countdown on the overworld
     and centres freely; here the camera follows the active Pokémon, so dead centre is exactly
     where the player and their nameplate always are — the banner landed on top of the one thing
     the visitor is trying to watch. 18vh clears the plate and the move-icon row above it while
     staying plainly in the middle of the screen. */
  display: flex; align-items: flex-start; justify-content: center; padding-top: 18vh;
  /* ⚠️ Non-negotiable. This paints over a live field the visitor is steering; a banner that ate
     a click aimed at a wild would be worse than no banner. `#pull-cd` carries the same rule. */
  pointer-events: none;
}
.demo-announce-box {
  font-family: var(--pixel); text-align: center; color: #fff;
  max-width: min(86vw, 640px); padding: 16px 24px;
  background: rgba(8, 17, 32, 0.72); border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--accent), 0 0 26px rgba(255, 201, 60, 0.45);
  text-shadow: 0 2px 0 #000, 0 0 12px rgba(255, 201, 60, 0.7);
  animation: demoAnnounceLife 4s ease-out forwards;
}
.demo-announce-kicker {
  display: block; font-size: clamp(8px, 1.5vw, 11px); color: var(--accent);
  margin-bottom: 10px; letter-spacing: 1px; text-transform: uppercase;
}
/* Smaller than `#pull-cd`'s number on purpose: that one renders a two-character count, this one
   renders a sentence, and a sentence at 64px wraps into a wall on a phone. */
.demo-announce-text { display: block; font-size: clamp(11px, 2.4vw, 20px); line-height: 1.7; }

/* Hold, then fade — the server drops the banner at the same 4s (`@announce_ttl_ms`). The
   scale-in curve is `#pull-cd`'s. */
@keyframes demoAnnounceLife {
  0%   { opacity: 0; transform: scale(0.85); }
  6%   { opacity: 1; transform: scale(1); }
  80%  { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .demo-announce-box { animation: none; }
}

/* ---- the story overlays --------------------------------------------- */
/* Deliberately NOT `backdrop-filter`: the canvas behind is the proof that this is a real game,
   and blurring it hides exactly the thing the page is selling. A flat scrim keeps it legible. */
.demo-overlay {
  position: absolute; inset: 0; z-index: 30;
  display: flex; align-items: center; justify-content: center;
  padding: 16px; background: rgba(5, 11, 22, 0.78);
}
.demo-card {
  width: min(94vw, 560px); max-height: 92vh; overflow-y: auto;
  display: flex; flex-direction: column; gap: 10px;
  padding: 18px; border: 3px solid var(--edge);
  box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  background: var(--panel); color: var(--text); text-align: center;
}
.demo-card h1 { margin: 0; font-family: var(--pixel); font-size: 14px; line-height: 1.6; color: var(--accent); }
.demo-card p { margin: 0; font-size: 13px; line-height: 1.55; color: var(--text); }

/* The illustrated key frame. `aspect-ratio` + a token background means a missing or
   still-generating render leaves a tidy band rather than collapsing the card. */
.demo-frame {
  width: 100%; aspect-ratio: 16 / 7; object-fit: cover; display: block;
  border: 3px solid var(--edge); background: var(--bg-2); image-rendering: pixelated;
}

/* ---- the partner picker --------------------------------------------- */
.demo-picks { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.demo-pick {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: 12px 8px; border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border);
  background: var(--panel-2); color: var(--text); cursor: pointer; font: inherit;
}
.demo-pick:hover, .demo-pick:focus-visible { box-shadow: inset 0 0 0 2px var(--primary); background: var(--bg-2); }
.demo-pick img { width: 72px; height: 72px; image-rendering: pixelated; }
.demo-pick span { font-family: var(--pixel); font-size: 9px; color: var(--accent); }
/* The full "Choose X" sentence is the accessible label; sighted users get the name above it. */
.demo-pick em { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

/* ---- the theft ------------------------------------------------------- */
.demo-say { text-align: left; font-size: 13px; }
.demo-say b { color: var(--primary-2); margin-right: 6px; }
.demo-stolen { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin: 0; padding: 0; list-style: none; }
/* Greyed and dimmed, because they are gone — but still READABLE. At 48px and 0.45 alpha under
   a full greyscale they became six identical smudges, which reads as a rendering fault rather
   than as a loss. The player has to recognise the team they just spent five minutes catching. */
.demo-stolen img { width: 64px; height: 64px; image-rendering: pixelated; opacity: 0.7; filter: grayscale(0.85); }

/* ---- buttons --------------------------------------------------------- */
.demo-btn {
  align-self: center; display: inline-block; padding: 10px 16px;
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border), var(--shadow);
  background: var(--panel-2); color: var(--text); cursor: pointer;
  font-family: var(--pixel); font-size: 10px; text-decoration: none;
}
.demo-btn:hover { background: var(--bg-2); color: var(--accent); text-decoration: none; }
.demo-btn-primary { background: var(--primary); color: var(--edge); }
.demo-btn-primary:hover { background: var(--primary-2); color: var(--edge); }
.demo-link { background: none; border: 0; color: var(--link); cursor: pointer; font: inherit; font-size: 12px; text-decoration: underline; }
.demo-keep { color: var(--accent); font-size: 12px; }
.demo-busy { display: flex; align-items: center; justify-content: center; }

@media (max-width: 640px) {
  /* ⚠️ Stays at the TOP on a phone. It used to move to `bottom: 10px`, which is exactly where
     `.hud-hotbars` is fixed — so the instructions panel sat on top of the skill bar and the
     consumable bar and swallowed every click meant for them. The bars are the game; the panel
     is the manual. */
  .demo-hud { width: auto; left: 10px; right: 10px; top: 46px; max-height: 40vh; }
  .demo-badge { font-size: 8px; }
  .demo-card h1 { font-size: 12px; }
  .demo-picks { grid-template-columns: 1fr; }
  .demo-pick { flex-direction: row; justify-content: flex-start; gap: 12px; }
}

/* ---- Gyms board (GymsLive) ----------------------------------------------------------------
   Registrar Oswin's League notice board: a nine-slice frame, a title ribbon, the 2x4 badge
   case and eight poster tiles; then one gym's detail under a key-art hero. The art is in two
   places on purpose — the POSTERS are Nano Banana renders under priv/static/images/gyms/,
   TRACKED (the delve hero's reasoning: a render is not regenerable), while the CHROME
   (frame, slot, ribbon) is PixelLab output under assets/game/ui/gyms/, gitignored like every
   generated UI PNG. ⚠️ No URL is hardcoded here: `<.league_frame>` hands the frame in as
   `--league-frame`, GymsLive hands the slot and ribbon in as custom properties on the board
   (--gym-slot / --gym-ribbon) and the poster as an inline background on the art layer, each with
   a ?v= bust — the same rule the delve hero's comment above states, and for the same reason (a
   redrawn PNG must reach a warm browser).
   Every background-image element is `image-rendering: pixelated` itself: the global pixel
   scaling only covers img[src^="/assets/game/"]. */

/* The board lives in a 1080px box like the crafting window; ⚠️ two-class, so it is ALSO
   spelled out in the ≤820px full-bleed block above. */
.game-modal.gyms-modal { max-width: 1080px; }

/* The League frame — `<.league_frame>` in CoreComponents, the ONE `border-image` in this
   stylesheet. Every other frame here is a CSS border + an inset keyline (`.panel`), and that
   idiom cannot draw a generated plate with riveted corners — a corner is a picture, not a line.
   The nine-slice is the 328x166 board PixelLab drew, cut at 56: its riveted corner plates reach
   53px, and a 24px slice repeated fragments of them along every edge (measured tile score 2.26
   vs 0.15 at 56 — see work/gym_hub_art.json), drawn at 56px, 1x.
   ⚠️ The border is 56px of TRANSPARENT: it reserves the ring and draws nothing on its own, so
   without the PNG the wrapper is a plain box and never a plain `.panel`. It is deliberately NOT
   a `.panel` — that border pair underneath the plate was the second ring the Gyms modal drew,
   and the plate's hollow centre let the inset keyline show straight through it. The painted edge
   strip is only ~24px deep; the inner 32px of each 56px slice is clear, so content may sit right
   at the padding edge and the padding is small. `fill` paints the slice's centre, which is
   transparent, hence the explicit background clipped to the padding box.
   A second `border-image` anywhere in this file is the thing to refuse: one frame asset, one
   rule, one component that knows the URL. */
.league-frame { position: relative;
  border: 56px solid transparent;                  /* reserves the ring; nothing drawn without the PNG */
  border-image-source: var(--league-frame, none); border-image-slice: 56 fill;
  border-image-width: 56px; border-image-repeat: repeat; image-rendering: pixelated;
  /* border-box, not padding-box: the painted strip is only the OUTER ~24px of each 56px slice
     and the inner 32px of the PNG is transparent, so a fill clipped to the padding box leaves a
     ring of game world showing between the strip and the content (reported from the board). The
     fill runs under the whole border and the strip paints over it. */
  background: var(--bg); background-clip: border-box;
  padding: 4px 8px 12px; }

/* The title ribbon: 128x48 art at exactly 2x (256x96), stretched 100%/100% so the forked
   tails stay where the generator put them. Centred, hanging over the frame's top strip the way
   a pinned banner would: the strip is inside the 56px border, 24px deep, and the ribbon is 96px
   tall, so -44px lifts its top 8px onto the strip while the text stays clear of it. Without the
   PNG the text still sits on its own ground. */
.gym-ribbon { position: relative; width: 256px; height: 96px; margin: -44px auto 2px;
  background-image: var(--gym-ribbon, none); background-size: 100% 100%; background-repeat: no-repeat;
  image-rendering: pixelated; display: flex; align-items: center; justify-content: center; }
.gym-ribbon-text { font-family: var(--pixel); font-size: 10px; line-height: 1.4; text-align: center;
  color: var(--text); text-shadow: 2px 2px 0 var(--edge); padding: 0 28px; }
.gym-board-intro { font-size: 12px; text-align: center; margin: 0 auto 14px; max-width: 640px; }

/* The badge case: the trainer card's 2x4. A cell is 96px so the 64x64 badge (1:1, the profile
   picker's art) leaves the recess's rim visible — the badges keep a 4-16px transparent margin,
   and on a 64px cell the rim would be under the badge. The slot PNG is 64x64 drawn at 1.5x;
   it is a soft recess, not a lettered thing, and the case reading as a case is worth the
   half-step. ⚠️ `background-size: 100% 100%` so the recess follows the cell on a phone, where
   four cells share ~360px. */
.gym-case { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 12px;
  justify-items: center; max-width: 440px; margin: 0 auto; }
.gym-slot { position: relative; width: 100%; max-width: 96px; aspect-ratio: 1;
  display: flex; align-items: center; justify-content: center;
  background-color: var(--bg-2); box-shadow: inset 0 0 0 2px var(--edge), inset 0 0 0 4px var(--border);
  background-image: var(--gym-slot, none); background-size: 100% 100%; background-repeat: no-repeat;
  image-rendering: pixelated; }
.gym-slot-badge { width: 64px; max-width: 70%; height: auto; image-rendering: pixelated; display: block; }
/* Owned: lit — a soft glow in the accent, the one place a badge gets to shine. Locked: the
   profile picker's silhouette (`.badge-card.locked`), so "not yet" reads the same on both
   surfaces. */
.gym-slot-badge.owned { filter: drop-shadow(0 0 4px var(--accent)) drop-shadow(0 0 1px var(--accent)); }
.gym-slot-badge.locked { opacity: 0.4; filter: grayscale(1) brightness(0.75); }
.gym-slot-type { position: absolute; right: -4px; bottom: -4px; line-height: 0; }
.gym-case-caption { text-align: center; font-size: 12px; margin: 8px 0 16px;
  font-variant-numeric: tabular-nums; }

/* The poster grid: two across, one on a phone. A tile is 352x192 art (11:6) under `cover`;
   in a 1080px box a column is ~490px, 1.4x — a non-integer scale the delve hero already
   accepts for key art, and the one trade for a fluid grid. */
.gym-tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
/* The tile is the button; `.mon`'s lift + primary inset on hover so it reads as the same
   kind of pickable thing as a Pokémon card. Layers, bottom up: tint (the type colour, the
   fallback when there is no poster yet) → art (the poster, which covers the tint when it
   loads) → scrim → chips/badge/text. Everything above the art is `pointer-events: none`
   except nothing — the button is the whole target. */
.gym-tile { position: relative; display: block; width: 100%; aspect-ratio: 11 / 6; overflow: hidden;
  padding: 0; margin: 0; font: inherit; color: var(--text); text-align: left; cursor: pointer;
  background: var(--bg-2); border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--border); }
.gym-tile:hover { box-shadow: inset 0 0 0 2px var(--primary), var(--shadow); transform: translate(-2px, -2px); }
.gym-tile:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.gym-tile-tint { position: absolute; inset: 0; opacity: 0.45; }
.gym-tile-art { position: absolute; inset: 0; background-size: cover; background-position: center;
  background-repeat: no-repeat; image-rendering: pixelated; }
/* Bottom-weighted, `.dlv-hero-scrim`'s gradient: the art stays readable at the top while the
   name sits on near-solid ground. */
.gym-tile-scrim { position: absolute; inset: 0;
  background: linear-gradient(rgba(8, 17, 32, 0.1) 40%, rgba(8, 17, 32, 0.9)); }
.gym-tile-badge { position: absolute; top: 8px; left: 8px; width: 32px; height: 32px;
  image-rendering: pixelated; pointer-events: none; }
.gym-tile-badge.owned { filter: drop-shadow(0 0 3px var(--accent)); }
.gym-tile-badge.locked { opacity: 0.4; filter: grayscale(1) brightness(0.75); }
.gym-tile-chips { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; flex-wrap: wrap;
  justify-content: flex-end; pointer-events: none; }
.gym-tile-chips .pill { font-size: 10px; }
/* Today's bonus, the delve card's `.dlv-bonus` mark in the same corner family; pointer-events
   off so the click still lands on the button under it. Sits under the chips when both show. */
.gym-tile-daily { position: absolute; top: 44px; right: 8px; width: 24px; height: 24px;
  image-rendering: pixelated; pointer-events: none; }
.gym-tile-text { position: absolute; left: 0; right: 0; bottom: 0; padding: 8px 12px; pointer-events: none; }
.gym-tile-name { font-family: var(--pixel); font-size: 11px; line-height: 1.5; color: var(--text);
  text-shadow: 2px 2px 0 var(--edge); }
.gym-tile-type { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700;
  color: var(--accent); margin-top: 4px; flex-wrap: wrap; }

/* The gym view. `.gym-card` is plain content inside the League frame — it WAS the `.panel` the
   detail used to be, which made the frame's second ring a third one on this view. No border,
   no shadow, no background of its own: the frame is the chrome. (The team picker's `.mon` cells
   keep their own sub-boxes.) */
.gym-card { margin-top: 10px; }
/* The hero: `.dlv-hero`'s idiom on the poster, cropped — the art is 11:6 and the delve's
   32:9 band would show a third of it, so the hero is a fixed-height `cover` crop instead
   (~160px, the width the stepper block wants above it). Same layer order as the tile. It bleeds
   to the frame's padding edge (4px 8px), not to a card edge the card no longer draws. */
.gym-hero { position: relative; height: 160px; margin: -4px -8px 12px; overflow: hidden;
  border-bottom: 3px solid var(--edge); background: var(--bg-2); }
.gym-hero-tint { position: absolute; inset: 0; opacity: 0.45; }
.gym-hero-art { position: absolute; inset: 0; background-size: cover; background-position: center;
  background-repeat: no-repeat; image-rendering: pixelated; }
.gym-hero-scrim { position: absolute; inset: 0;
  background: linear-gradient(rgba(8, 17, 32, 0.15), rgba(8, 17, 32, 0.88)); }
.gym-hero-row { position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 14px;
  display: flex; align-items: flex-end; gap: 12px; }
/* The leader's portrait crop (GymsLive.portrait_style/1 sets the sheet and the 64px frame);
   on a plinth so a sheet not yet generated is still a visible square. */
.gym-portrait { flex: 0 0 auto; background-color: rgba(8, 17, 32, 0.5);
  box-shadow: inset 0 0 0 2px var(--edge); }
.gym-hero-text { min-width: 0; }
.gym-hero-name { font-family: var(--pixel); font-size: 13px; line-height: 1.5; color: var(--text);
  text-shadow: 2px 2px 0 var(--edge); }
.gym-hero-tag { font-size: 12px; font-weight: 700; color: var(--accent); margin-top: 3px; }
.gym-hero-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 6px;
  font-size: 12px; font-weight: 700; }
.gym-team { margin-top: 12px; padding-top: 10px; border-top: 2px solid var(--border); }

@media (max-width: 820px) {
  .gym-tiles { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
  /* The ring at ~0.7x, nearest-neighbour: the 56px slice drawn into 40px keeps the corner plates
     whole (a smaller slice would repeat fragments of them) and gives the content back 32px. */
  .league-frame { border-width: 40px; border-image-width: 40px; }
  .gym-ribbon { width: 224px; height: 84px; }
  .gym-ribbon-text { font-size: 8px; }
  .gym-hero-name { font-size: 10px; }
  .gym-hero { height: 140px; }
}

/* ===================================================================================
   Tutorial MODE (`PlayService.Tutorial`, `TUT` in app.js) — 2026-10-15
   =================================================================================== */
/* A rail button the tutorial has not reached yet. A class, never an absent node, so the Esc
   menu's "show the whole interface" (`body.tut-ui-all`) brings it back with no round-trip.
   !important because the rail's own show/hide rules (`.hud-corner-toggle`, the 720px folds)
   would otherwise win by specificity on some buttons and not others. */
body:not(.tut-ui-all) .hud-menu-btn.tut-hide { display: none !important; }

/* The reveal: a button the current step just unlocked pops in; at the graduation they cascade
   60ms apart (`--tut-i`, set by tutRevealFresh). */
.hud-menu-btn.tut-pop { animation: tutPop .55s cubic-bezier(.2, 1.5, .4, 1) both;
  animation-delay: calc(var(--tut-i, 0) * 60ms); }
@keyframes tutPop {
  0% { transform: scale(.2); opacity: 0; box-shadow: 0 0 0 0 rgba(255, 212, 71, .9); }
  60% { transform: scale(1.18); opacity: 1; box-shadow: 0 0 18px 6px rgba(255, 212, 71, .75); }
  100% { transform: none; opacity: 1; box-shadow: 0 0 0 0 rgba(255, 212, 71, 0); }
}

/* The objective card — Oak's current step, top-centre, in the HUD's centre stack. */
.tut-card { pointer-events: auto; display: flex; align-items: center; gap: 12px;
  width: min(420px, calc(100vw - 32px)); margin: 0 auto; padding: 10px 14px 10px 10px;
  background: rgba(8, 17, 32, .92); color: var(--text); text-align: left; cursor: pointer;
  border: 3px solid var(--edge); box-shadow: inset 0 0 0 2px var(--accent), var(--shadow);
  font: inherit; }
.tut-card:hover { box-shadow: inset 0 0 0 2px #ffe27a, var(--shadow); }
.tut-card-oak { flex: 0 0 auto; width: 48px; height: 48px; image-rendering: pixelated;
  background: #16233f; border: 2px solid var(--edge); }
.tut-card-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
.tut-card-kicker { font-family: var(--pixel); font-size: 8px; color: var(--accent); letter-spacing: 1px; }
.tut-card-name { font-family: var(--pixel); font-size: 9px; color: var(--primary-2); line-height: 1.5; }
.tut-card-goal { font-size: 15px; line-height: 1.35; color: var(--text); }
.tut-card-bar { display: block; height: 8px; background: #16233f; border: 2px solid var(--edge); }
.tut-card-fill { display: block; height: 100%; background: var(--accent); transition: width .4s ease; }
.tut-card-count { font-family: var(--pixel); font-size: 8px; color: var(--muted); }
.tut-card-dots { display: flex; gap: 5px; }
.tut-dot { width: 8px; height: 8px; background: #23324f; border: 1px solid var(--edge); }
.tut-dot.done { background: var(--accent); }
.tut-dot.cur { background: #fff3b0; box-shadow: 0 0 0 1px var(--accent); }
@media (max-width: 640px) {
  .tut-card { gap: 8px; padding: 6px 8px 6px 6px; }
  .tut-card-oak { width: 32px; height: 32px; }
  .tut-card-goal { font-size: 13px; }
  .tut-card-name { display: none; }
}

/* "New task": the card, big in the middle, then flown to its place (TutCard). */
.tut-card-hero { position: fixed; left: 50%; top: 40%; z-index: 999996; pointer-events: auto;
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  transform: translate(-50%, -50%) scale(.8); opacity: 0;
  transition: opacity .25s ease, transform .3s cubic-bezier(.2, 1.35, .4, 1); }
.tut-card-hero.in { opacity: 1; transform: translate(-50%, -50%) scale(1.3); }
.tut-card-hero.out { opacity: .2; transform: translate(-50%, -50%) var(--tut-fly, scale(1));
  transition: opacity .45s ease-in, transform .45s ease-in; }
.tut-card-hero .tut-card { pointer-events: none; }
.tut-hero-kicker { font-family: var(--pixel); font-size: 11px; color: #fff; padding: 6px 12px;
  background: #3a6fd8; border: 3px solid var(--edge); text-shadow: 0 2px 0 #000; }

/* The edge arrow: points at the objective NPC while it is off screen. Positioned by the
   overworld in page pixels; 0rad points right. */
.tut-arrow { position: fixed; left: 0; top: 0; z-index: 60; pointer-events: none;
  width: 28px; height: 28px; margin: -14px 0 0 -14px;
  background: var(--accent); clip-path: polygon(100% 50%, 15% 5%, 32% 50%, 15% 95%);
  filter: drop-shadow(0 0 2px #050b16) drop-shadow(0 0 6px rgba(255, 212, 71, .7));
  animation: tutArrowPulse 1.1s ease-in-out infinite; }
.tut-arrow[hidden] { display: none; }
@keyframes tutArrowPulse { 50% { opacity: .55; } }

/* The arrival cutscene's veil: letterbox bars and the skip hint; it is also what takes the
   click that skips. */
.tut-intro-veil { position: fixed; inset: 0; z-index: 70; cursor: pointer; background: transparent; }
.tut-intro-veil::before, .tut-intro-veil::after { content: ""; position: absolute; left: 0; right: 0;
  height: 9vh; background: #050b16; }
.tut-intro-veil::before { top: 0; }
.tut-intro-veil::after { bottom: 0; }
.tut-intro-hint { position: absolute; left: 0; right: 0; bottom: 2.5vh; z-index: 1; text-align: center;
  font-family: var(--pixel); font-size: 9px; color: var(--muted); }

@media (prefers-reduced-motion: reduce) {
  .hud-menu-btn.tut-pop, .tut-arrow { animation: none; }
  .tut-card-hero, .tut-card-hero.in, .tut-card-hero.out, .tut-card-fill { transition: none; }
}

/* Oak "on the radio" — an auto turn-in's dialogue (quest:radio). The ordinary box in a
   communicator frame: a green signal edge, a faint scanline over the portrait, and the 48px
   radio icon (ui/radio.png, which may be absent — the img removes itself). */
.dialogue-box.dialogue-radio { box-shadow: inset 0 0 0 2px #4fd18b, 0 0 18px rgba(79, 209, 139, .35), var(--shadow); }
.dialogue-box.dialogue-radio .dialogue-portrait {
  background: repeating-linear-gradient(0deg, rgba(79, 209, 139, .10) 0 2px, transparent 2px 4px), #0d1f1a; }
.dialogue-radio-ico { float: right; width: 48px; height: 48px; margin: -4px -4px 4px 8px; image-rendering: pixelated; }
