/* moria — programmer art only; no AI-generated assets. Dark, amber, monospace. */
:root {
  --bg: #14100c;
  --panel: #1d1712;
  --fg: #d8c7a8;
  --dim: #7a6a52;
  --accent: #c98b3a;
  --line: #2b2118;
  --danger: #d06a4a;
  --tile: 48px;
}
* { box-sizing: border-box; }
/* The UA gives every fieldset `min-inline-size: min-content`, so it refuses to shrink
   below its widest child and drags its dialog wider than a phone — the recurring
   "authoring form scrolls sideways" bug. Neutralized globally so no future fieldset
   reintroduces it (.grant carries its own copy for good measure). */
fieldset { min-width: 0; }
html, body {
  margin: 0;
  height: 100%;
  background: var(--bg);
  color: var(--fg);
  font-family: ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
}
/* Kill iOS Safari's grey tap-flash box on every control — the buttons have their own
   :hover/:active feedback, and the flash reads as jank on a game UI. */
* { -webkit-tap-highlight-color: transparent; }

/* ---- centered card pages (login / invite / set-password / landing) ---- */
.center {
  min-height: 100%;
  display: grid;
  place-items: center;
  padding: 1.5rem;
}
main.card {
  width: min(26rem, 100%);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 2rem;
}
h1 {
  margin: 0 0 0.25rem;
  font-size: clamp(2rem, 8vw, 3.5rem);
  letter-spacing: 0.15em;
  color: var(--accent);
}
.tag { color: var(--fg); margin: 0.25rem 0 1.5rem; }
.meta { color: var(--dim); font-size: 0.8rem; }

label { display: block; margin: 1rem 0 0.3rem; color: var(--dim); font-size: 0.85rem; }
input[type="text"], input[type="password"] {
  width: 100%;
  padding: 0.6rem 0.7rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--fg);
  font: inherit;
}
input:focus { outline: 1px solid var(--accent); border-color: var(--accent); }

button {
  margin-top: 1.2rem;
  padding: 0.6rem 1rem;
  background: var(--accent);
  color: #1a1206;
  border: none;
  border-radius: 6px;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
button:hover { filter: brightness(1.08); }
button.ghost { background: transparent; color: var(--dim); border: 1px solid var(--line); font-weight: 400; }

a { color: var(--accent); }
.msg { margin-top: 1rem; min-height: 1.2em; font-size: 0.85rem; }
.msg.error { color: var(--danger); }
.msg.ok { color: var(--accent); }
.hint { color: var(--dim); font-size: 0.8rem; margin-top: 0.4rem; }

/* ---- game ---- */
.game { height: 100%; display: flex; flex-direction: column; }
.hud {
  position: relative; /* anchors the collapsed .hud-actions dropdown on mobile */
  display: flex;
  align-items: center;
  gap: 1rem;
  /* viewport-fit=cover (index.html) lets the page reach under the notch / Dynamic Island
     and the landscape sensor housing, so pad the bar out of those unsafe zones. All three
     insets are 0 on a non-notched device, so this is a no-op there. */
  padding:
    calc(0.5rem + env(safe-area-inset-top, 0px))
    calc(0.9rem + env(safe-area-inset-right, 0px))
    0.5rem
    calc(0.9rem + env(safe-area-inset-left, 0px));
  background: var(--panel);
  border-bottom: 1px solid var(--line);
  font-size: 0.85rem;
}
.hud .who { color: var(--accent); font-weight: 600; }
/* The level label doubles as a rename button for admins — styled to read as text,
   with a dotted underline as the only hint that it's clickable. */
.hud .who .level-rename {
  background: transparent; border: 0; padding: 0; margin: 0; font: inherit;
  color: inherit; cursor: pointer; text-decoration: underline dotted;
  text-underline-offset: 3px;
}
.hud .who .level-rename:hover { text-decoration-style: solid; }
/* The PVP indicator/toggle: a small pill next to the level. A safe area reads muted; an
   arena glows danger-red. As a button (admin/owner) it's clickable; as a span (everyone
   else) it's a plain badge. */
.hud .who .level-pvp {
  background: transparent; border: 1px solid var(--line); border-radius: 999px;
  padding: 0 0.4em; margin: 0; font: inherit; font-size: 0.78rem; line-height: 1.5;
  color: var(--dim); cursor: pointer; vertical-align: middle;
}
.hud .who span.level-pvp { cursor: default; }
.hud .who .level-pvp:hover { border-color: var(--accent); }
.hud .who .level-pvp.on {
  color: #ff5a5a; border-color: #ff5a5a; font-weight: 700;
}
/* The music picker (admin/owner): the ♪ pill next to PVP. Lit when the area has a track. */
.hud .who .level-music {
  background: transparent; border: 1px solid var(--line); border-radius: 999px;
  padding: 0 0.45em; margin: 0; font: inherit; font-size: 0.78rem; line-height: 1.5;
  color: var(--dim); cursor: pointer; vertical-align: middle;
}
.hud .who .level-music:hover { border-color: var(--accent); }
.hud .who .level-music.on { color: var(--accent); border-color: var(--accent); font-weight: 700; }
/* The permadelete chip wears danger red so it never reads as just another settings toggle. */
.hud .who .level-delete { color: var(--danger); }
.hud .who .level-delete:hover { color: var(--danger); border-color: var(--danger); }
#musicDialog select { width: 100%; margin: 0.5rem 0; }
.hud .spacer { flex: 1; }
.hud .count { color: var(--dim); }
.hud label { margin: 0; display: inline; color: var(--dim); cursor: pointer; }
.hud input[type="file"] { display: none; }
/* The action cluster: inline on desktop, a dropdown behind the hamburger on mobile.
   Two groups (editor-cleanup slice 1b) — .hud-primary is what you reach for while playing or
   building and stays inline; .hud-more is the rare stuff (invite / bug / settings / log out) and
   hides behind the ☰ at every width. On a phone both collapse into the ☰ together. */
.hud-actions { display: flex; align-items: center; gap: 0.75rem; }
.hud-primary { display: flex; align-items: center; gap: 0.75rem; }
/* The 🔨 toolbar: tiles/copy/cut/erase, opened on demand. The [hidden] companion is mandatory —
   an author `display` beats the UA `[hidden]` sheet, which is how #indoorRow drew on all 8 tile
   kinds until 0.7.18. Same reason .hud-more carries one below. */
.build-tools { display: flex; align-items: center; gap: 0.75rem; }
.build-tools[hidden] { display: none; }
.hud-primary .linkbtn.on { color: var(--accent); }
/* Desktop: .hud-more is the ☰ dropdown. It anchors to .hud (position: relative, line 87). */
.hud-more {
  position: absolute;
  top: calc(100% + 1px);
  right: 0.4rem;
  display: none;
  flex-direction: column;
  align-items: stretch;
  gap: 0.4rem;
  min-width: 12rem;
  padding: 0.5rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 0 0 8px 8px;
  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45);
  z-index: 20;
}
.hud.menu-open .hud-more { display: flex; }
.hud-more[hidden] { display: none; }
/* Full-width tap targets in the menu. :not([hidden]) is load-bearing — forcing display would
   otherwise override `hidden` and show admin-only buttons (invite) to everyone. */
.hud-more > .linkbtn:not([hidden]) { display: block; width: 100%; margin: 0; text-align: left; padding: 0.5rem 0.6rem; }
/* Hamburger — now shown at every width (it owns .hud-more), not just on mobile. */
.hud-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 1px solid var(--line);
  color: var(--fg);
  padding: 0.3rem 0.6rem;
  margin: 0;
  border-radius: 6px;
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
}
.hud .linkbtn { background: transparent; border: 1px solid var(--line); color: var(--fg); padding: 0.35rem 0.6rem; margin: 0; border-radius: 6px; font-size: 0.8rem; cursor: pointer; }
/* A toggled-on HUD button (e.g. erase mode active). */
.hud .linkbtn.on { background: var(--danger); border-color: var(--danger); color: #1a1206; font-weight: 600; }

/* Per-tile animation-speed slider in the authoring form: a turtle→rabbit control that
   sets how fast the chosen idle animation plays for this tile type. */
.anim-speed { display: flex; align-items: center; gap: 0.4rem; font-size: 0.95rem; }
.anim-speed input[type="range"] { flex: 1; cursor: pointer; accent-color: var(--accent); }
.anim-speed .anim-speed-val, .fx-color-row .anim-speed-val { min-width: 3ch; text-align: right; font-size: 0.8rem; opacity: 0.8; font-variant-numeric: tabular-nums; }
.fx-color-row input[type="range"] { flex: 1; }
.anim-speed.disabled { opacity: 0.4; } /* no animation selected → the speed does nothing */

/* Light-effect colour picker in the authoring form: a swatch beside its label, greyed
   when the effect is "none" (nothing to tint). */
.fx-color-row { display: flex; align-items: center; gap: 0.5rem; }
.fx-color-row label { margin: 0; }
.fx-color-row input[type="color"] { width: 2.4rem; height: 1.6rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: none; cursor: pointer; }
.fx-color-row.disabled { opacity: 0.4; } /* no light effect → the colour does nothing */

/* Sound settings + the procedural-SFX authoring editor (sound-system spike, slice 1). */
.settings-subhead { font-size: 0.95rem; margin: 1.2rem 0 0.3rem; }
.vol-row { display: flex; align-items: center; gap: 0.5rem; margin: 0.35rem 0; }
.vol-row label { margin: 0; min-width: 4.5rem; font-size: 0.85rem; }
.vol-row input[type="range"] { flex: 1; }
.vol-row .vol-val { min-width: 3.5ch; text-align: right; font-size: 0.8rem; opacity: 0.8; font-variant-numeric: tabular-nums; }
.sfx-presets { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.4rem 0; }
.sfx-preset { padding: 0.2rem 0.6rem; font-size: 0.8rem; }
.sfx-row { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.4rem 0; }
.sfx-row select { flex: 1; min-width: 6rem; }
.sfx-env { margin: 0.4rem 0; }
.sfx-env > summary { cursor: pointer; font-size: 0.85rem; opacity: 0.85; }
.sfx-env[open] > summary { margin-bottom: 0.3rem; }
.sfx-dur { opacity: 0.65; font-variant-numeric: tabular-nums; }

.viewport {
  --ox: 0px;   /* camera offset, driven by game.js: the world layer and the ground */
  --oy: 0px;   /* share it so tiles and grid lines stay locked together */
  position: relative;
  flex: 1;
  overflow: hidden;
  touch-action: none; /* swipes drive movement, they don't scroll the page */
  /* The world is a game surface, not a document: a tap/drag moves the player and a
     long-press should never select text or pop iOS's Copy/Search/Share callout over the
     map. Chrome (dialogs, chat, say-box) lives outside #viewport, so it stays selectable. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  /* Contain the whole game layer in ONE stacking context. Without this, the world's
     z-indices (fog 9, veil 3, players, entities) leak into the page's root stacking
     context — where a bare z-index outranks any UI chrome that forgot to set one, and
     the "blocked" toast ends up painted UNDER the fog. Isolating here guarantees nothing
     inside the world can ever rise above a sibling interface element. Page-level chrome
     (toast/coords/version) then simply layers above this whole box. */
  isolation: isolate;
  /* The ground's palette goes through its own custom properties (defaulting to the theme)
     so a level owner's recolor (game.js setLevelGround sets them inline on #viewport) repaints
     ONLY the floor — descendants using var(--bg)/var(--line) for chrome are untouched. */
  --ground-bg: var(--bg);
  --ground-line: var(--line);
  background-color: var(--ground-bg);
  /* Three layers, all sharing the world offset so they scroll together:
     a coarse "boulder field" of soft dots on a 3-tile period gives the eye
     something to track as you move, over the fine tile grid. */
  background-image:
    radial-gradient(circle at 50% 50%, rgba(201, 139, 58, 0.06) 0 3px, transparent 4px),
    radial-gradient(circle at 50% 50%, rgba(216, 199, 168, 0.035) 0 2px, transparent 3px),
    repeating-linear-gradient(0deg, transparent 0 calc(var(--tile) - 1px), var(--ground-line) calc(var(--tile) - 1px) var(--tile)),
    repeating-linear-gradient(90deg, transparent 0 calc(var(--tile) - 1px), var(--ground-line) calc(var(--tile) - 1px) var(--tile));
  background-size:
    calc(var(--tile) * 3) calc(var(--tile) * 3),
    calc(var(--tile) * 5) calc(var(--tile) * 5),
    var(--tile) var(--tile),
    var(--tile) var(--tile);
  background-position:
    var(--ox) var(--oy),
    calc(var(--ox) + var(--tile) * 1.5) calc(var(--oy) + var(--tile) * 0.5),
    var(--ox) var(--oy),
    var(--ox) var(--oy);
}
/* Easing is enabled only after the first render (see game.js) so spawning far
   from origin doesn't animate one long slide. The ground, the world layer, and
   every node then share one 90ms linear timing, so a step pans everything in
   lockstep — no seasick per-element jiggle. */
.viewport.ready { transition: background-position 90ms linear; }
.viewport.ready .grid { transition: transform 90ms linear; }
.viewport.ready .player,
.viewport.ready .entity { transition: left 90ms linear, top 90ms linear; }
/* A zoom rescales every world position (TILE) at once. Easing each element's transform/
   left/top on its own 90ms curve makes them drift out of lockstep for the duration — the
   fog snaps (no transition) while tiles and avatars glide, which reads as the fog "zooming
   at a different rate". While zooming we suppress the movement transitions so the whole
   world — ground, tiles, entities, and fog — rescales as one rigid step. Placed after the
   .ready rules so it wins at equal specificity. Ordinary movement keeps its glide. */
.viewport.zooming .grid,
.viewport.zooming .player,
.viewport.zooming .entity { transition: none; }
/* The world layer: a zero-size origin at world (0,0) that the camera translates.
   All nodes are absolutely positioned within it at their world-tile coordinates. */
.grid { position: absolute; top: 0; left: 0; transform-origin: 0 0; }

/* Player stats HUD — pinned top-left over the world, above every world node but below
   modal dialogs. Server-fed (game.js updateHud); a fixed width keeps the bars from
   twitching as the numbers change. It doesn't intercept clicks so the tile under it is
   still placeable/walkable-to. */
.stats {
  position: absolute;
  top: 0.6rem;
  left: 0.6rem;
  z-index: 15;
  width: 12rem;
  max-width: 55%;
  padding: 0.5rem 0.6rem;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  border: 1px solid var(--line);
  border-radius: 8px;
  pointer-events: none;
  user-select: none;
}
.stat { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; }
.stat-key { color: var(--dim); white-space: nowrap; min-width: 2.6rem; }
.stat-key b { color: var(--accent); font-weight: 700; }
.stat .bar {
  flex: 1;
  height: 0.6rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 999px;
  overflow: hidden;
}
.stat .bar-fill { height: 100%; width: 0; transition: width 180ms ease-out; }
.stat-hp .bar-fill { background: linear-gradient(90deg, #a33, var(--danger)); }
.stat-xp .bar-fill { background: linear-gradient(90deg, #6a89c9, var(--accent)); }
.stat-mp .bar-fill { background: linear-gradient(90deg, #4b6fd6, #7db4ff); }
.stat-mp .stat-key { color: #7db4ff; }
.stat-num { color: var(--dim); font-size: 0.7rem; min-width: 3.2rem; text-align: right; white-space: nowrap; }
/* Connection-quality pill — the stats card's top-right twin. Appears only while the network
   is misbehaving (game.js updateNetPill): amber "slow connection", red "bad connection",
   pulsing red "connection lost?". Doesn't intercept clicks, same as .stats. */
.netpill {
  position: absolute;
  top: 0.6rem;
  right: 0.6rem;
  z-index: 15;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.3rem 0.6rem;
  font-size: 0.75rem;
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  border: 1px solid var(--line);
  border-radius: 999px;
  pointer-events: none;
  user-select: none;
  white-space: nowrap;
}
.netpill[hidden] { display: none; } /* author display:flex beats the UA [hidden] rule */
.netpill.net-slow { color: #e0b45c; border-color: #a5772c; }
.netpill.net-bad { color: var(--danger); border-color: #a5432c; }
.netpill.net-lost { color: var(--danger); border-color: #a5432c; animation: netpulse 1.2s ease-in-out infinite; }
@keyframes netpulse { 50% { opacity: 0.45; } }

/* Petrified (statuses sweep): whoever's stone renders grey and perfectly still — the filter
   rides the node while the inner art's idle animation pauses, so a bobbing slime freezes
   mid-bob. Applies to players and mobs alike (applyStatusClasses toggles it every frame). */
.player.st-petrified, .entity.st-petrified { filter: grayscale(1) brightness(0.8) contrast(1.1); }
.player.st-petrified .avatar-rig, .entity.st-petrified { animation-play-state: paused; }
.player.st-petrified .avatar-rig * , .entity.st-petrified * { animation-play-state: paused; }
/* Invisible (statuses sweep): only YOUR OWN client ever renders you while invisible — the
   server culls you from everyone else's world entirely — so the ghost look only needs the
   .self case: translucent, slightly cool, unmistakably "I'm the one who's hidden". The fade
   rides .avatar-rig so a worn hat ghosts out with the rest of you. */
.player.self.st-invisible .avatar-rig { opacity: 0.45; filter: saturate(0.6); }
/* Intangible (true ghost): everyone sees the ghost — translucent and slightly blurred at the
   edges, clearly present but not solid. */
.player.st-intangible .avatar-rig { opacity: 0.55; filter: saturate(0.5) drop-shadow(0 0 6px rgba(160,180,255,0.5)); }

/* Being dead in a delve (instanced dungeons slice 2). The four status pills say what a ghost
   can't DO; this says where you are — the colour drains out of the world and it goes a little
   cold and far away, so you can tell at a glance that you're on the other side of it and not
   just debuffed. Deliberately gentle: a ghost still has to be able to read the room, follow the
   party and find the exit. The world only — the HUD keeps its colour so the pills stay legible. */
body.is-ghost .grid { filter: saturate(0.35) brightness(0.82) contrast(0.95); transition: filter 900ms ease; }

.player {
  position: absolute;
  width: var(--tile);
  height: var(--tile);
  display: grid;
  place-items: center;
  z-index: 6;
  /* .player is a PURE positioning box — it draws and clips nothing. Every visible part of
     the avatar (the coloured --avatar-border frame, the rounded corners, the clip that
     rounds the art, and the fallback background) lives on the inner .avatar-art wrapper,
     inside the .avatar-rig that carries the idle animation. That is deliberate: the avatar must be a
     SINGLE frame that squashes/spins/rocks as one piece. When the rounded corners + clip
     lived HERE, on the static parent, a squashing .avatar-art grew wider than this box (so
     this clip sheared off the sides of the blue border) and shrank away from its top (so you
     saw straight through this fixed rounded hole) — the "frame in a frame". With no radius
     and no overflow here, there is no static outer rim for the animation to pull away from
     or be clipped by. NB removing overflow:hidden also lets the .name label below the tile
     show instead of being clipped. */
}

/* Placed world objects — art only, non-interactive, drawn beneath players.
   Entities and players are siblings in .grid (a transform stacking context), so a
   single z-index scale orders every occupant of a tile bottom→top:
   decor(1) < door(2) < pickup/text(3) < obstacle/sign/container(4) < mob(5) <
   player(6) < self(7). A door sits just above decor so a carpet/floor tile can be
   placed under it; a container outranks decor so a rug never swallows the chest on
   top of it. The default below is the decor floor, so an unknown kind sinks beneath
   everything. */
.entity {
  position: absolute;
  width: var(--tile);
  height: var(--tile);
  display: grid;
  place-items: center;
  overflow: hidden;
  z-index: 1;
  pointer-events: none;
}
.entity[data-kind="decor"] { z-index: 1; }
.entity[data-kind="door"] { z-index: 2; }
.entity[data-kind="pickup"] { z-index: 3; filter: drop-shadow(0 0 4px rgba(201,139,58,0.5)); }
.entity[data-kind="obstacle"] { z-index: 4; filter: brightness(0.92); }
.entity[data-kind="sign"] { z-index: 4; }
.entity[data-kind="container"] { z-index: 4; }
.entity[data-kind="text"] { z-index: 3; }
.entity[data-kind="mob"] { z-index: 5; }

/* Fog of war. A per-tile overlay expresses the three visibility states the client
   computes each frame (see game.js computeFOV — recursive shadowcasting, so sight
   stops at walls):
   .fog       — a tile that has NEVER been seen: fully opaque, so nothing behind it
                shows through (surprise is preserved — you can't glimpse unexplored
                terrain, walls, or items through the fog).
   .fog.seen  — a tile seen before but not in sight right now ("remembered"): a light
                translucent veil, so the terrain still reads, just greyed and dimmer.
   A currently-visible tile gets NO overlay at all — full brightness.
   Overlays live in the world layer (.grid), positioned by world coordinate like an
   entity, so they pan with the terrain; above every world node (self is 7).
   .fog-veil — a gentle atmospheric vignette pinned over the viewport (the player is
   always centred). It only softens the far rim of the lit circle; the hard occlusion
   and memory come from the per-tile overlays above.
   All are click-through and all lift while an admin is placing/erasing (.building). */
.fog {
  position: absolute;
  width: var(--tile);
  height: var(--tile);
  /* Fully opaque: an unexplored tile must reveal nothing behind it. (The fog layer
     extends a tile past every viewport edge, so this never seams against the ambient
     background on-screen.) */
  background: #06090d;
  z-index: 9;
  pointer-events: none;
}
.fog.seen {
  /* Remembered terrain: dimmed and cool-grey, but far from black — you can make out
     what you've explored. Tune this alpha to taste (lower = brighter memory). */
  background: rgba(10, 14, 20, 0.48);
}
.fog-veil {
  position: absolute;
  inset: 0;
  /* Above the world layer (.grid, painted at z 0) but below the stats HUD (15).
     All of this is now contained by .viewport's stacking context (isolation: isolate),
     so this z-index only orders siblings WITHIN the game layer — it can no longer reach
     the page and paint over interface chrome like the toast. */
  z-index: 3;
  pointer-events: none;
  background: radial-gradient(circle at 50% 50%,
    transparent calc(var(--sight, 384px) * 0.72),
    rgba(8, 10, 14, 0.28) calc(var(--sight, 384px) * 1.05),
    rgba(8, 10, 14, 0.5) calc(var(--sight, 384px) * 1.5));
}
.viewport.building .fog-veil { display: none; }

/* Level weather (the weather spike) — rain/snow/fog over the whole view, seen by everyone on
   the level (unlike miasmas, which are personal). One div (#weatherLayer), pure CSS: each kind
   is 2–3 tiled gradient background layers whose background-position animates by EXACTLY one
   tile per cycle, so the loop is seamless. The wi1–3 intensity classes only set custom props
   (tile sizes, per-cycle shifts, speed), so one keyframes block serves all three. Color and
   opacity ride --weather-color/--weather-opacity, set inline by game.js paintWeather. Above
   the fog veil (weather reads through remembered terrain) but below miasmas (z 12) — a
   blindness blackout still blots out the rain — and below the HUD (z 15). */
.weather-layer {
  position: absolute;
  inset: 0;
  z-index: 11;
  pointer-events: none;
  opacity: var(--weather-opacity, 0);
  will-change: background-position;
  /* Indoor tiles (J4 p1): stepping under a roof fades the sky away rather than snapping it. */
  transition: opacity 0.6s ease;
}
.weather-layer.inside { opacity: 0; }
.viewport.building .weather-layer { display: none; }

/* Rain: two layers of slanted streak dashes at different tile scales — the size difference
   makes the near layer fall visibly faster than the far one (parallax) on one shared clock. */
.weather-layer.weather-rain {
  background-image:
    linear-gradient(105deg, transparent 44%, var(--weather-color, #9ecbff) 46% 52%, transparent 54%),
    linear-gradient(105deg, transparent 46%, var(--weather-color, #9ecbff) 48% 51%, transparent 53%);
  background-size: var(--wx-size1), var(--wx-size2);
  animation: weather-fall var(--wx-speed) linear infinite;
}
.weather-rain.wi1 { --wx-size1: 42px 84px; --wx-size2: 60px 120px; --wx-shift1: -42px 84px; --wx-shift2: -60px 120px; --wx-speed: 0.9s; }
.weather-rain.wi2 { --wx-size1: 30px 60px; --wx-size2: 44px 88px; --wx-shift1: -30px 60px; --wx-shift2: -44px 88px; --wx-speed: 0.7s; }
.weather-rain.wi3 { --wx-size1: 22px 44px; --wx-size2: 32px 64px; --wx-shift1: -22px 44px; --wx-shift2: -32px 64px; --wx-speed: 0.5s; }

/* Snow: three dot layers at different scales — small far flakes, mid flakes, and a sparse
   layer of big near ones — drifting down-right on a slow diagonal. Layers two and three start
   offset inside their tiles so the three grids never phase-lock into one visible lattice. */
.weather-layer.weather-snow {
  background-image:
    radial-gradient(circle, var(--weather-color, #ffffff) 0 1.7px, transparent 2.8px),
    radial-gradient(circle, var(--weather-color, #ffffff) 0 1.2px, transparent 2.1px),
    radial-gradient(circle, var(--weather-color, #ffffff) 0 2.4px, transparent 3.6px);
  background-position: 0 0, 17px 31px, 41px 7px;
  background-size: var(--wx-size1), var(--wx-size2), var(--wx-size3);
  animation: weather-drift var(--wx-speed) linear infinite;
}
/* The staggered layers' end positions = their stagger + one own-tile, precomputed here
   (a keyframe var can't add to the layer's start position). */
.weather-snow.wi1 { --wx-size1: 64px 64px; --wx-size2: 96px 96px; --wx-size3: 140px 140px; --wx-shift1: 64px 64px; --wx-shift2: 113px 127px; --wx-shift3: 181px 147px; --wx-speed: 14s; }
.weather-snow.wi2 { --wx-size1: 44px 44px; --wx-size2: 72px 72px; --wx-size3: 110px 110px; --wx-shift1: 44px 44px; --wx-shift2: 89px 103px; --wx-shift3: 151px 117px; --wx-speed: 10s; }
.weather-snow.wi3 { --wx-size1: 30px 30px; --wx-size2: 52px 52px; --wx-size3: 80px 80px; --wx-shift1: 30px 30px; --wx-shift2: 69px 83px; --wx-shift3: 121px 87px; --wx-speed: 7s; }

/* Fog: three soft blob layers (no flat fill — the gaps between blobs ARE the patchiness)
   crawling sideways in opposing directions, plus a huge slow base swell. Each blob's
   transparent color-stop must land BEFORE its tile edge, or the tile repeats a
   partially-opaque value against a transparent one and you see a hard seam. Keeping
   (transparent% x semi-axis%) <= the distance from the blob's centre to the nearest edge
   guarantees every tile boundary is fully transparent, so the fog only ever feathers out —
   no hard edges. (e.g. layer 1: 0.42*55 = 23% reach, centre 42–45% from each edge: contained.) */
.weather-layer.weather-fog {
  background-image:
    radial-gradient(ellipse 55% 55% at 45% 42%, var(--weather-color, #aab4be) 0 8%, transparent 42%),
    radial-gradient(ellipse 55% 55% at 58% 60%, var(--weather-color, #aab4be) 0 6%, transparent 40%),
    radial-gradient(ellipse 70% 70% at 50% 50%, var(--weather-color, #aab4be) 0 14%, transparent 48%);
  background-size: var(--wx-size1), var(--wx-size2), var(--wx-size3);
  animation: weather-drift var(--wx-speed) linear infinite;
}
.weather-fog.wi1 { --wx-size1: 520px 380px; --wx-size2: 680px 440px; --wx-size3: 1100px 800px; --wx-shift1: 520px 0; --wx-shift2: -680px 0; --wx-shift3: 1100px 0; --wx-speed: 60s; }
.weather-fog.wi2 { --wx-size1: 420px 320px; --wx-size2: 560px 380px; --wx-size3: 900px 660px; --wx-shift1: 420px 0; --wx-shift2: -560px 0; --wx-shift3: 900px 0; --wx-speed: 44s; }
.weather-fog.wi3 { --wx-size1: 320px 260px; --wx-size2: 440px 300px; --wx-size3: 700px 520px; --wx-shift1: 320px 0; --wx-shift2: -440px 0; --wx-shift3: 700px 0; --wx-speed: 30s; }

/* Each animation moves every layer exactly one own-tile per cycle — the custom props above
   are tile-size multiples, so frame N and frame 0 are pixel-identical (seamless loop). */
@keyframes weather-fall  { to { background-position: var(--wx-shift1), var(--wx-shift2); } }
@keyframes weather-drift { to { background-position: var(--wx-shift1), var(--wx-shift2), var(--wx-shift3); } }

/* Miasma overlays — full-view visual hazes painted over the player's OWN view (a blindness
   blackout, a drunken ripple, weather, a magical shroud). Server-fed timed effects; each active
   miasma is one absolutely-pinned child div (game.js renderMiasmas) stacked here. Above the
   world/veil but below the HUD (z 15), so even a full blackout leaves your bars readable; below
   page chrome (dialogs/toasts live above the whole viewport), so a sign is still legible. Never
   intercepts input. Hidden while building so an admin isn't authoring blind. */
.miasma-layer { position: absolute; inset: 0; z-index: 12; pointer-events: none; }
.viewport.building .miasma-layer { display: none; }
.miasma {
  position: absolute;
  inset: 0;
  /* The fill is set inline (background-color OR background-image); opacity rides a custom prop
     so the pulse animation can breathe RELATIVE to the authored level. */
  opacity: var(--miasma-opacity, 1);
  will-change: opacity, transform, background-position;
}
/* A tiled image repeats at a fixed screen size; a stretched one covers the view. Both set inline
   background-image; these only pick the repeat/size so the JS stays declarative. */
.miasma.tile { background-repeat: repeat; background-size: 96px 96px; }
.miasma.stretch { background-repeat: no-repeat; background-size: cover; background-position: center; }

/* Decorative motion. `pulse` breathes the whole overlay's opacity around the authored value.
   `drift`/`sway` pan/rock the fill (great for a tiled fog image). `bubbles`/`ripple` add a
   second animated gradient LAYER via ::before, so the base colour/image stays put while motes
   rise or rings expand over it — this is what makes a faint drunk tint feel alive. */
.miasma.anim-pulse { animation: miasma-pulse 3.2s ease-in-out infinite; }
@keyframes miasma-pulse {
  0%, 100% { opacity: calc(var(--miasma-opacity, 1) * 0.5); }
  50%      { opacity: var(--miasma-opacity, 1); }
}
.miasma.anim-drift { background-position: 0 0; animation: miasma-drift 14s linear infinite; }
@keyframes miasma-drift { to { background-position: 96px 64px; } }
.miasma.anim-sway { animation: miasma-sway 5s ease-in-out infinite; }
@keyframes miasma-sway {
  0%, 100% { transform: translateX(-2.5%); }
  50%      { transform: translateX(2.5%); }
}
.miasma.anim-bubbles::before, .miasma.anim-ripple::before {
  content: "";
  position: absolute;
  inset: -20%;
  pointer-events: none;
}
/* Translucent motes on two co-prime layers, drifting up — reads as rising bubbles/spores. */
.miasma.anim-bubbles::before {
  background-image:
    radial-gradient(circle at 20% 80%, rgba(255,255,255,0.35) 0 5px, transparent 6px),
    radial-gradient(circle at 65% 90%, rgba(255,255,255,0.28) 0 8px, transparent 9px),
    radial-gradient(circle at 85% 75%, rgba(255,255,255,0.3) 0 4px, transparent 5px);
  background-size: 140px 180px, 200px 240px, 90px 160px;
  animation: miasma-bubbles 6s linear infinite;
}
@keyframes miasma-bubbles {
  to { background-position: 0 -180px, 0 -240px, 0 -160px; }
}
/* Concentric expanding rings — a woozy/scrying pulse. */
.miasma.anim-ripple::before {
  background: repeating-radial-gradient(circle at 50% 50%,
    transparent 0 22px, rgba(255,255,255,0.16) 24px 26px, transparent 28px 44px);
  animation: miasma-ripple 4s linear infinite;
}
@keyframes miasma-ripple {
  from { transform: scale(0.85); opacity: 0.9; }
  to   { transform: scale(1.25); opacity: 0.2; }
}

/* An inline checkbox + caption (e.g. decor's "blocks line of sight"), as opposed to
   the block field captions the dialog uses elsewhere. */
.check {
  display: flex;
  align-items: center;
  gap: 0.45em;
  font-weight: normal;
  cursor: pointer;
}
.check input { margin: 0; }

/* ---- codeless render styles (shared by entities + authoring previews) ----
   Any element carrying `.rend` renders one of three ways: an <img> (uploaded art),
   a `.glyph` span (emoji), or a `.prim` CSS shape driven by classes + a --fill var.
   Sizes stay relative (%) so the same rules serve a full tile and a small swatch.
   `.rend` is mixed onto nodes already positioned by their layout class (`.entity`
   is absolute in the world layer; swatches sit in flow) — so it is a *visual-only*
   utility and must NOT set `position`/`top`/`left`/`display` or it would clobber
   that layout by source order (it once set `position: relative`, which overrode
   `.entity`'s `position: absolute` and cascaded every placed tile into a diagonal). */
.rend { overflow: hidden; }
/* Light-effect pseudo-elements (.fx-*::after) are position:absolute and must anchor to
   their own .rend host. Entities already are a containing block (.entity is absolute); the
   in-flow swatches/previews are not, so give only THOSE position:relative — never .entity
   (see the warning above: relative on .entity diagonalises the world). */
.rend:not(.entity) { position: relative; }
.rend img { width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; }
.rend .glyph { line-height: 1; user-select: none; }
.entity .glyph { font-size: calc(var(--tile) * 0.82); }
.rend.prim { background: var(--fill, var(--accent)); }
.rend.prim.shape-rounded { border-radius: 22%; }
.rend.prim.shape-circle { border-radius: 50%; }
.rend.prim.pat-stripes { background-image: repeating-linear-gradient(45deg, rgba(0,0,0,0.22) 0 6px, transparent 6px 12px); }
.rend.prim.pat-dots { background-image: radial-gradient(rgba(0,0,0,0.28) 22%, transparent 24%); background-size: 33% 33%; }
.rend.prim.pat-checker { background-image: linear-gradient(45deg, rgba(0,0,0,0.18) 25%, transparent 25% 75%, rgba(0,0,0,0.18) 75%); background-size: 50% 50%; }
.rend.prim.pat-grid { background-image: repeating-linear-gradient(0deg, rgba(0,0,0,0.22) 0 1.5px, transparent 1.5px 25%), repeating-linear-gradient(90deg, rgba(0,0,0,0.22) 0 1.5px, transparent 1.5px 25%); }
.rend.prim.pat-crosshatch { background-image: repeating-linear-gradient(45deg, rgba(0,0,0,0.2) 0 2px, transparent 2px 9px), repeating-linear-gradient(-45deg, rgba(0,0,0,0.2) 0 2px, transparent 2px 9px); }
.rend.prim.pat-zigzag { background-image: linear-gradient(135deg, rgba(0,0,0,0.22) 25%, transparent 25%), linear-gradient(225deg, rgba(0,0,0,0.22) 25%, transparent 25%); background-size: 33% 33%; }
.rend.prim.pat-vstripes { background-image: repeating-linear-gradient(90deg, rgba(0,0,0,0.22) 0 6px, transparent 6px 12px); }
/* ripple — the one ANIMATED texture (water / lava / ooze). Rob's spec verbatim: an overlaid
   gentle swish-swish, "not by transforming the whole tile but by overlapping a moving texture
   onto it" — the 0039 whole-tile slosh redone (migration 0041). Two translucent wave layers
   (a light crest and a dark trough at slightly different angles) share one tile-sized ::before
   and pan in OPPOSITE directions via background-position, so the tile itself never moves and
   abutting terrain squares stay seam-free by construction. ::before (not ::after) because the
   fx system owns ::after outright; a tile that is both rippling and fx-flicker/sparks loses
   that effect's second ::before layer to the water (its main ::after layer still shows) —
   an acceptable degrade for the rare burning-water tile. background-size 300% gives the
   pattern room to pan; px band widths stay constant regardless (repeating gradients). The
   swish rides the same speed knob as anims/fx (--anim-scale).
   NO position rule here — every .rend host is already a containing block (.entity is
   absolute; .rend:not(.entity) is relative, both above). Redeclaring position on this
   three-class selector out-specifies .entity's position:absolute and dumps rippling tiles
   into document flow — the 0.5.54 world-scrambling regression (tiles drawn at flow
   positions, grid-stretched sizes). Do not add one back. */
.rend.prim.pat-ripple::before {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  border-radius: inherit; /* follow rounded/circle shapes instead of poking out square */
  background-image:
    repeating-linear-gradient(174deg, transparent 0 7px, rgba(255,255,255,0.30) 11px 14px, transparent 18px 30px),
    repeating-linear-gradient(188deg, transparent 0 9px, rgba(0,0,0,0.20) 14px 18px, transparent 23px 38px);
  background-size: 300% 300%, 300% 300%;
  background-position: 0% 0%, 100% 100%;
  animation: pat-ripple calc(5s * var(--anim-scale, 1)) ease-in-out infinite;
}
@keyframes pat-ripple {
  0%, 100% { background-position: 0% 0%, 100% 100%; }
  50%      { background-position: 24% 6%, 74% 92%; }
}
/* Animation applies to any render (art / emoji / primitive) — the class rides the
   .rend container, so an <img> or emoji .glyph animates just like a CSS shape. */
/* Durations ride --anim-scale, a per-tile multiplier the client sets inline on each
   entity node from its definition's animation speed (--anim-scale = 1/speed, so a
   faster speed shortens the duration). The `, 1` fallback keeps the authored duration
   when unset, so a tile with no speed override plays at its authored pace. */
.rend.anim-pulse { animation: rend-pulse calc(1.8s * var(--anim-scale, 1)) ease-in-out infinite; }
.rend.anim-spin { animation: rend-spin calc(3s * var(--anim-scale, 1)) linear infinite; }
.rend.anim-spin-rev { animation: rend-spin calc(3s * var(--anim-scale, 1)) linear infinite reverse; }
.rend.anim-bob { animation: rend-bob calc(1.4s * var(--anim-scale, 1)) ease-in-out infinite; }
@keyframes rend-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
@keyframes rend-spin { to { transform: rotate(360deg); } }
@keyframes rend-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-15%); } }
/* "This thing is alive" idle motions (slice 6). mirror flips back and forth, rock is a
   pendulum tilt, breathe scales up/down, wiggle is a quick nervous rotation, squash is
   squash-and-stretch. rock/squash pivot at the feet (bottom centre) so they plant on the
   ground; the rest pivot centre. All are cheap CSS keyframes — no JS loop. */
.rend.anim-mirror { animation: rend-mirror calc(2.4s * var(--anim-scale, 1)) steps(1, end) infinite; }
.rend.anim-rock { animation: rend-rock calc(1.6s * var(--anim-scale, 1)) ease-in-out infinite; transform-origin: bottom center; }
.rend.anim-breathe { animation: rend-breathe calc(2s * var(--anim-scale, 1)) ease-in-out infinite; }
.rend.anim-wiggle { animation: rend-wiggle calc(0.5s * var(--anim-scale, 1)) ease-in-out infinite; }
.rend.anim-squash { animation: rend-squash calc(1.3s * var(--anim-scale, 1)) ease-in-out infinite; transform-origin: bottom center; }
@keyframes rend-mirror { 0%, 49% { transform: scaleX(1); } 50%, 100% { transform: scaleX(-1); } }
@keyframes rend-rock { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }
@keyframes rend-breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } }
@keyframes rend-wiggle { 0%, 100% { transform: rotate(-4deg); } 50% { transform: rotate(4deg); } }
@keyframes rend-squash { 0%, 100% { transform: scale(1, 1); } 50% { transform: scale(1.12, 0.88); } }

/* Player avatars can carry the same idle animations as object tiles (emoji, PNG, or the
   letter fallback). The motion rides an inner .avatar-rig wrapper, NOT the .player node, so a
   spin/mirror/etc. transforms only the avatar and leaves the name label upright — and so it
   composes with, rather than fights, the node-level combat states (.lunge/.hit/.dying) that
   live on .player. Fixed pace (no per-avatar speed knob); reuses the shared rend-* keyframes.
   The rig holds BOTH .avatar-art and a worn .hat, so the hat squashes/spins/rocks as part of
   the avatar (Rob's ruling: "as if they were part of it") — while .avatar-art keeps the
   overflow:hidden clip to itself, so the brim can still poke above the head. position:
   relative so the hat's absolute perch measures from the tile, not some ancestor. */
.player .avatar-rig { position: relative; width: 100%; height: 100%; }
/* The avatar's coloured frame lives HERE, on .avatar-art inside the animated rig — not on
   the .player node — so it spins/rocks/breathes with the art instead of standing still
   around it. Every player gets one (the client always sets --avatar-border: their chosen
   colour, or a per-id default hue), which is why other players are framed too, not just you.
   box-sizing:border-box (global) keeps the ring inside the tile so it never nudges layout.
   This wrapper owns the WHOLE frame: the coloured border, the 8px rounded corners, and the
   overflow:hidden that clips the art (img/glyph) to that rounded square. Because the border
   + clip + art all live on this one element inside the one animated rig, they squash/spin/
   rock together as a single unit — the parent .player draws nothing, so no second static
   frame can shear the border or leave a gap (see .player above). */
.player .avatar-art {
  width: 100%; height: 100%; display: grid; place-items: center;
  border: 2px solid var(--avatar-border, transparent);
  border-radius: 8px;
  overflow: hidden;
}
.player .glyph { font-size: calc(var(--tile) * 0.82); line-height: 1; user-select: none; }
.player.face-left .glyph { transform: scaleX(-1); }
.avatar-rig.anim-pulse { animation: rend-pulse 1.8s ease-in-out infinite; }
.avatar-rig.anim-spin { animation: rend-spin 3s linear infinite; }
.avatar-rig.anim-spin-rev { animation: rend-spin 3s linear infinite reverse; }
.avatar-rig.anim-bob { animation: rend-bob 1.4s ease-in-out infinite; }
.avatar-rig.anim-mirror { animation: rend-mirror 2.4s steps(1, end) infinite; }
.avatar-rig.anim-rock { animation: rend-rock 1.6s ease-in-out infinite; transform-origin: bottom center; }
.avatar-rig.anim-breathe { animation: rend-breathe 2s ease-in-out infinite; }
.avatar-rig.anim-wiggle { animation: rend-wiggle 0.5s ease-in-out infinite; }
.avatar-rig.anim-squash { animation: rend-squash 1.3s ease-in-out infinite; transform-origin: bottom center; }
@media (prefers-reduced-motion: reduce) {
  .avatar-rig.anim-pulse, .avatar-rig.anim-spin, .avatar-rig.anim-spin-rev, .avatar-rig.anim-bob,
  .avatar-rig.anim-mirror, .avatar-rig.anim-rock, .avatar-rig.anim-breathe,
  .avatar-rig.anim-wiggle, .avatar-rig.anim-squash { animation: none; }
}

/* Decorative light effects (glow / flicker / sparks / laser / rays). Each is drawn on a
   ::after (and, for flicker/sparks, ::before) pseudo-element rather than the entity node
   itself: the node's own `animation` is already claimed by the idle anim above and the
   combat states (.lunge/.hit/.dying), and a pseudo-element carries its OWN independent
   animation, so light and motion coexist without fighting. `mix-blend-mode: screen` makes
   them add light over whatever's behind — a glow, never a smudge. Each pseudo is sized well
   larger than its tile (a negative inset) and, with overflow lifted below, spills its light
   into the world — a halo, not a lamp in a box — while still fading to transparent before
   its own edge, so there's no hard rectangle. The colour rides --fx-color (set inline from
   the definition); each effect names its own warm/cool default for when no colour was
   picked. --fx-scale is a reserved speed hook (default 1); --fx-radius (default 1) scales
   each effect's authored reach — the admin's "light size" knob multiplies the negative
   inset / rise distance below. Purely cosmetic. */
.rend[class*="fx-"]:not(.fx-none)::after { content: ""; position: absolute; pointer-events: none; }
/* Lift the tile's clip for a lit tile so the enlarged pseudo can bleed past the square. The
   node carries both .entity and .rend, so this (higher specificity) overrides both
   overflow:hidden rules. */
.rend[class*="fx-"]:not(.fx-none) { overflow: visible; }
/* Snuff the light effect while the object is fogged (out of sight / only remembered). The
   fx pseudo spills its halo past the tile, so the per-tile fog can't clip it — an object you
   can't currently see would otherwise leak its glow out around the fog. game.js flags the node
   with .fogged whenever its tile isn't currently lit; the dimmed sprite still reads through the
   fog veil, but its glow/flame/sparks/beam vanish until the tile is lit again. */
.rend.fogged[class*="fx-"]::after,
.rend.fogged.fx-flicker::before,
.rend.fogged.fx-sparks::before { display: none; }
/* glow — a soft radial halo that breathes, spilling about a third of a tile past the edge. */
.rend.fx-glow::after {
  inset: calc(-55% * var(--fx-radius, 1));
  background: radial-gradient(circle at 50% 50%, var(--fx-color, #ffd27f) 0%, transparent 58%);
  mix-blend-mode: screen;
  animation: fx-glow calc(2.4s * var(--fx-scale, 1)) ease-in-out infinite;
}
@keyframes fx-glow { 0%, 100% { opacity: 0.4; transform: scale(0.92); } 50% { opacity: 0.85; transform: scale(1.06); } }
/* flicker — firelight. Two warm flame layers on ::after + ::before at co-prime durations
   (2.7s / 1.7s) with irregular, eased keyframes: they beat against each other so the light
   never reads as a mechanical on/off loop — analog-looking flame noise from two cheap
   deterministic animations. */
.rend.fx-flicker::after {
  inset: calc(-50% * var(--fx-radius, 1));
  background: radial-gradient(circle at 50% 60%, var(--fx-color, #ff8a2b) 0%, transparent 54%);
  mix-blend-mode: screen; transform-origin: 50% 72%;
  animation: fx-flicker calc(2.7s * var(--fx-scale, 1)) ease-in-out infinite;
}
.rend.fx-flicker::before {
  content: ""; position: absolute; inset: calc(-50% * var(--fx-radius, 1)); pointer-events: none;
  background: radial-gradient(circle at 50% 58%, var(--fx-color, #ff8a2b) 0%, transparent 46%);
  mix-blend-mode: screen; transform-origin: 50% 72%; opacity: 0.6;
  animation: fx-flicker2 calc(1.7s * var(--fx-scale, 1)) ease-in-out infinite;
}
@keyframes fx-flicker {
  0%   { opacity: 0.90; transform: scale(1.02, 1.04) translateY(0); }
  11%  { opacity: 0.62; transform: scale(0.98, 0.96) translateY(0.6px); }
  19%  { opacity: 1;    transform: scale(1.05, 1.09) translateY(-0.7px); }
  27%  { opacity: 0.74; transform: scale(0.99, 1.00) translateY(0.3px); }
  38%  { opacity: 0.95; transform: scale(1.03, 1.06) translateY(-0.4px); }
  47%  { opacity: 0.66; transform: scale(0.97, 0.95) translateY(0.5px); }
  59%  { opacity: 1;    transform: scale(1.04, 1.08) translateY(-0.6px); }
  68%  { opacity: 0.72; transform: scale(1.00, 0.98) translateY(0.2px); }
  79%  { opacity: 0.90; transform: scale(1.03, 1.05) translateY(-0.3px); }
  88%  { opacity: 0.60; transform: scale(0.98, 0.96) translateY(0.4px); }
  100% { opacity: 0.90; transform: scale(1.02, 1.04) translateY(0); }
}
@keyframes fx-flicker2 {
  0%   { opacity: 0.55; transform: scale(1.00) translateX(0); }
  13%  { opacity: 0.34; transform: scale(1.06) translateX(0.6px); }
  29%  { opacity: 0.62; transform: scale(0.95) translateX(-0.6px); }
  41%  { opacity: 0.40; transform: scale(1.04) translateX(0.3px); }
  57%  { opacity: 0.60; transform: scale(0.98) translateX(-0.5px); }
  73%  { opacity: 0.36; transform: scale(1.05) translateX(0.5px); }
  89%  { opacity: 0.58; transform: scale(0.97) translateX(-0.3px); }
  100% { opacity: 0.55; transform: scale(1.00) translateX(0); }
}
/* sparks — a scatter of little embers rising well past the tile top and fading. Two
   staggered pseudo-elements, each a cluster of box-shadow dots, translate up on a
   compositor-friendly transform. */
.rend.fx-sparks::before, .rend.fx-sparks::after {
  content: ""; position: absolute; left: 50%; top: 64%; width: 2px; height: 2px;
  border-radius: 50%; background: var(--fx-color, #ffd27f); pointer-events: none;
  box-shadow: -9px 3px 0 var(--fx-color, #ffd27f), 8px 1px 0 var(--fx-color, #ffd27f),
              -3px -1px 0 var(--fx-color, #ffd27f), 5px 5px 0 var(--fx-color, #ffd27f);
  mix-blend-mode: screen;
  animation: fx-sparks calc(1.6s * var(--fx-scale, 1)) ease-out infinite;
}
.rend.fx-sparks::before { left: 46%; animation-delay: calc(0.8s * var(--fx-scale, 1)); }
@keyframes fx-sparks {
  0% { opacity: 0; transform: translateY(6px) scale(0.6); }
  25% { opacity: 1; } 100% { opacity: 0; transform: translateY(calc(-26px * var(--fx-radius, 1))) scale(1); }
}
/* laser — a beacon: two opposed beams sweeping around a disc a little wider than the tile. */
.rend.fx-laser::after {
  inset: calc(-14% * var(--fx-radius, 1)); border-radius: 50%;
  background: conic-gradient(from 0deg, transparent 0deg, var(--fx-color, #66ccff) 12deg,
              transparent 24deg 168deg, var(--fx-color, #66ccff) 180deg, transparent 192deg 360deg);
  mix-blend-mode: screen; opacity: 0.85;
  animation: fx-spin calc(1.4s * var(--fx-scale, 1)) linear infinite;
}
/* rays — a calm sunburst, slowly turning; a big disc masked so it fades out well before its
   edge and leaves the tile centre clear. */
.rend.fx-rays::after {
  inset: calc(-50% * var(--fx-radius, 1)); border-radius: 50%;
  background: repeating-conic-gradient(from 0deg, var(--fx-color, #ffe08a) 0deg 6deg, transparent 6deg 30deg);
  mix-blend-mode: screen; opacity: 0.5;
  -webkit-mask: radial-gradient(circle, #000 10%, transparent 55%);
          mask: radial-gradient(circle, #000 10%, transparent 55%);
  animation: fx-spin calc(9s * var(--fx-scale, 1)) linear infinite;
}
@keyframes fx-spin { to { transform: rotate(360deg); } }

/* Static per-art transforms — a mob's directional facing (--face), and a placement's
   rotation (--rot) and mirror (--mir) — compose in one declaration so they don't
   overwrite each other. They ride the inner art only, leaving the node free for the
   idle-anim and lunge transforms above. Facing flips the whole oriented sprite in
   screen space (outermost); the placement mirror flips the art within its rotation. */
.rend img, .rend .glyph { transform: scaleX(var(--face, 1)) rotate(var(--rot, 0deg)) scaleX(var(--mir, 1)); }
/* Directional facing: a mob flips to face the way it moves/strikes (set in JS on move). */
.entity.face-left { --face: -1; }
/* Accessibility: honour a reduced-motion preference by stilling the looping idle
   animations. Facing (a static flip) and the brief, informative combat fx stay. */
@media (prefers-reduced-motion: reduce) {
  .rend.anim-pulse, .rend.anim-spin, .rend.anim-spin-rev, .rend.anim-bob,
  .rend.anim-mirror, .rend.anim-rock, .rend.anim-breathe,
  .rend.anim-wiggle, .rend.anim-squash { animation: none; filter: none; }
  .rend.prim.pat-ripple::before { animation: none; } /* waves hold still; the texture stays */
  /* Still the light effects too. glow/flicker settle to a steady halo (they stay
     decorative, just motionless); sparks/laser/rays freeze at their initial frame. */
  .rend[class*="fx-"]::after, .rend.fx-sparks::before, .rend.fx-flicker::before { animation: none; }
  .rend.fx-glow::after, .rend.fx-flicker::after, .rend.fx-flicker::before { opacity: 0.6; transform: none; }
  /* Miasma overlays hold their look but stop moving — a blackout still blacks out, a tint still
     tints, but nothing breathes/drifts/rises (the informative part is the haze, not the motion). */
  .miasma { animation: none !important; transform: none !important; }
  /* Weather holds still too — a static scattering of streaks/flakes/haze keeps the mood. */
  .weather-layer { animation: none !important; }
  .miasma.anim-bubbles::before, .miasma.anim-ripple::before { animation: none; transform: none; }
}
.player .avatar-art img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; }
/* Directional facing: flip the avatar to face the way you walk, so you read as heading
   "forward". Only the avatar's <img> flips — the name label and the initial-letter fallback
   stay upright (mirrored text/letters look wrong). Composes with the node-level lunge/hit.
   Scoped to .avatar-art: the hat's <img> has its OWN flip on .hat below — an unscoped
   `.player.face-left img` used to hit it too, flipping the hat twice back to un-flipped. */
.player.face-left .avatar-art img { transform: scaleX(-1); }
.player .initial { font-weight: 700; color: #1a1206; }
/* Self no longer wears a distinct orange "this is you" ring — every player now has their
   own coloured --avatar-border, so a second outline just stacked a frame inside a frame.
   You still read as "you" by being centred in the viewport. We keep only the z-index so
   self stacks above other players sharing a tile (see the decor→self scale above). */
.player.self { z-index: 7; }
.player .name {
  position: absolute;
  bottom: -1.1rem;
  font-size: 0.62rem;
  color: var(--fg);
  white-space: nowrap;
  text-shadow: 0 1px 2px #000;
}
/* ---- combat (bump-to-attack, both ways) ---- */
/* All decoration over already-authoritative state. Combat is bidirectional now: the
   attacker (a player OR a mob) lunges toward its target, and the target (a mob OR a player)
   flashes/shakes on a hit then, if slain, fades out. The hit/lunge classes are removed in
   JS once done so a mob's idle anim-* resumes. These rules come after the anim-* block, so
   at equal specificity they win the shared `animation` property; `.dying` is declared last
   so a killing blow beats a mere hit. */
.player.lunge, .entity.lunge { animation: lunge 240ms ease-out; }
@keyframes lunge {
  0% { transform: translate(0, 0); }
  40% { transform: translate(var(--lx, 0), var(--ly, 0)); }
  100% { transform: translate(0, 0); }
}
.player.hit, .entity.hit { animation: hit-shake 300ms ease-in-out; }
@keyframes hit-shake {
  0%, 100% { transform: translate(0, 0); filter: none; }
  20% { transform: translate(-2px, 1px); filter: brightness(2.3) saturate(0.5); }
  55% { transform: translate(2px, -1px); filter: brightness(2.3) saturate(0.5); }
  80% { transform: translate(-1px, 0); }
}
/* A critical hit (a natural 20 on the swing, doubled damage): a louder version of the hit
   flash — a bigger gold-white shake with a glow, declared after .hit so it wins the shared
   `animation` property. Pure decoration; the doubled damage already arrived. */
.player.crit, .entity.crit { animation: crit-hit 380ms ease-in-out; }
@keyframes crit-hit {
  0%, 100% { transform: translate(0, 0) scale(1); filter: none; }
  15% { transform: translate(-3px, 2px) scale(1.09); filter: brightness(2.9) saturate(1.5) drop-shadow(0 0 6px #ffd54a); }
  45% { transform: translate(3px, -2px) scale(1.09); filter: brightness(2.9) saturate(1.5) drop-shadow(0 0 6px #ffd54a); }
  72% { transform: translate(-2px, 1px) scale(1.03); }
}
.player.dying, .entity.dying { animation: die 420ms ease-in forwards; pointer-events: none; }
@keyframes die {
  0% { transform: scale(1); opacity: 1; }
  40% { transform: scale(1.15); opacity: 1; filter: brightness(2.4); }
  100% { transform: scale(0.2) rotate(18deg); opacity: 0; }
}
/* Death theater (Journal 2 p.17): YOUR death is an event, not a teleport. Your avatar keels
   over while blackness closes in (~1.4s), holds a beat, then lifts on the respawn — 2–3s of
   ritual, "pepperoni" on the spice scale. The veil sits above the world + HUD but below the
   upgrade theater (200) and the boot splash (300); the respawn warp reconciles underneath it,
   unseen. Reduced-motion players get the fade without the keel. */
.player.self-dying { animation: selfFall 900ms ease-in forwards; }
@keyframes selfFall {
  0% { transform: rotate(0) scale(1); opacity: 1; }
  55% { transform: rotate(74deg) scale(1); opacity: 1; }
  70% { transform: rotate(90deg) scale(0.98); opacity: 0.9; }
  100% { transform: rotate(90deg) scale(0.92); opacity: 0.3; }
}
@media (prefers-reduced-motion: reduce) {
  .player.self-dying { animation: none; opacity: 0.3; }
}
#deathVeil {
  position: fixed;
  inset: 0;
  background: #000;
  opacity: 0;
  pointer-events: none;
  z-index: 150;
  transition: opacity 1.4s ease-in;
}
#deathVeil.closing { opacity: 1; }
#deathVeil.lifting { transition: opacity 900ms ease-out; opacity: 0; }
/* Mob over-head HP bar. It lives inside the tile (top edge) because .entity is
   overflow:hidden — a bar above the tile would be clipped. Shown only once damaged. */
.hpbar {
  position: absolute;
  top: 2px;
  left: 10%;
  width: 80%;
  height: 4px;
  background: rgba(0, 0, 0, 0.6);
  border-radius: 2px;
  overflow: hidden;
  z-index: 2;
}
.hpbar > i { display: block; height: 100%; background: #d6432f; transition: width 140ms ease-out; }
/* Ground-stack count badge: a small ×N in the corner of a piled-up pickup tile. */
.ent-qty {
  position: absolute;
  right: 1px;
  bottom: 0;
  z-index: 3;
  padding: 0 3px;
  font-size: 11px;
  line-height: 1.3;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: #fff;
  background: rgba(0, 0, 0, 0.7);
  border-radius: 4px;
  pointer-events: none;
}
/* Floating damage number / "miss", drifting up from the mob's tile. In the world layer
   (grid), so it pans with the terrain; spans the tile so its text centres on it. */
.floater {
  position: absolute;
  z-index: 20;
  width: var(--tile);
  text-align: center;
  font-weight: 700;
  font-size: 0.82rem;
  color: #ff6a4d;
  text-shadow: 0 1px 2px #000;
  pointer-events: none;
  animation: floatup 850ms ease-out forwards;
}
.floater.miss { color: var(--dim); font-weight: 600; }
/* Ability fx floaters: a green heal number, a gold grant number (harm reuses .dmg's red), a
   blue mana number (slice D). */
.floater.heal { color: #6bd66b; }
.floater.xp { color: #ffd54a; }
.floater.mana { color: #7db4ff; }
/* The bobber (fishing): a line cast into a waters tile — bobs gently while waiting, shakes
   hard on the bite. World-anchored like a floater, but persistent until the verdict. */
.bobber {
  position: absolute;
  z-index: 19;
  width: var(--tile);
  text-align: center;
  font-size: 0.9rem;
  pointer-events: none;
  animation: bob 1.6s ease-in-out infinite;
  text-shadow: 0 1px 2px #000;
}
.bobber.bite { animation: bite-shake 0.28s linear infinite; }
@keyframes bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(3px); }
}
@keyframes bite-shake {
  0%, 100% { transform: translate(0, 0); }
  25% { transform: translate(-3px, 2px); }
  75% { transform: translate(3px, 2px); }
}

/* Damage-types outcomes: an immune hit reads as a word (steel grey — "it shrugged"), a
   resisted hit floats dim and small, a vulnerable one big and hot so the crit registers. */
.floater.immune { color: #b8c4cf; font-size: 0.72rem; letter-spacing: 0.04em; }
.floater.resist { opacity: 0.65; font-size: 0.72rem; }
.floater.vuln { color: #ff3b1f; font-size: 1.05rem; }
/* A critical hit floats fat and gold with a hot glow, popping in bigger before it drifts up
   (its own keyframes so the "-N!" punches on arrival). */
.floater.crit {
  color: #ffd54a;
  font-size: 1.3rem;
  letter-spacing: 0.01em;
  text-shadow: 0 0 7px #ff8a3d, 0 1px 2px #000;
  animation: floatup-crit 850ms ease-out forwards;
}
@keyframes floatup-crit {
  0% { transform: translateY(0) scale(0.55); opacity: 0; }
  16% { transform: translateY(-0.2rem) scale(1.3); opacity: 1; }
  34% { transform: translateY(-0.5rem) scale(1); opacity: 1; }
  100% { transform: translateY(-2rem) scale(1); opacity: 0; }
}
@keyframes floatup {
  0% { transform: translateY(0); opacity: 0; }
  15% { opacity: 1; }
  100% { transform: translateY(-1.7rem); opacity: 0; }
}
/* Heal/grant pulse — the positive counterpart to .hit's shake. A brief green glow that
   settles, painted on the affected node (a mob or a player) by an fx frame. Like .hit and
   .dying it claims the shared `animation` property for its short life, then JS drops it and
   the idle animation resumes. */
.player.healpulse, .entity.healpulse { animation: healpulse 420ms ease-out; }
@keyframes healpulse {
  0% { filter: brightness(1) drop-shadow(0 0 0 #6bd66b); }
  35% { filter: brightness(1.5) drop-shadow(0 0 6px #7cf07c); transform: scale(1.08); }
  100% { filter: brightness(1); transform: scale(1); }
}

/* Teleport poof — a quick burst of light on the tile a caster blinked from/to (an fx frame
   of kind "blink"). World-anchored like the floaters; a short expand-and-fade ring. */
.poof {
  position: absolute;
  z-index: 20;
  width: var(--tile);
  height: var(--tile);
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(circle, #cfe8ff 0%, #7bb8ff 45%, transparent 70%);
  animation: poof 260ms ease-out forwards;
}
@keyframes poof {
  0% { transform: scale(0.3); opacity: 0; }
  20% { opacity: 0.95; }
  100% { transform: scale(1.6); opacity: 0; }
}

/* Force-lightning bolt (slice D) — a jagged SVG arc between the caster and a struck mob,
   world-anchored in the grid. Two overlaid polylines of the same forked path: a fat electric-
   blue glow underneath and a bright near-white core on top, flickering out fast. Pure light
   show; the authoritative damage already arrived in the fx frame. */
.bolt {
  position: absolute;
  z-index: 21;
  pointer-events: none;
  overflow: visible;
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
  animation: boltflash 360ms steps(1, end) forwards;
}
.bolt .glow { stroke: #4d8bff; stroke-width: 7; opacity: 0.55; filter: blur(2px); }
.bolt .core { stroke: #eaf4ff; stroke-width: 2.2; filter: drop-shadow(0 0 4px #9cc6ff); }
/* Flicker: crackle a couple of times, then vanish — the stutter reads as electric. */
@keyframes boltflash {
  0% { opacity: 1; }
  30% { opacity: 0.45; }
  55% { opacity: 1; }
  80% { opacity: 0.6; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .bolt { animation: boltfade 360ms linear forwards; }
  @keyframes boltfade { 0% { opacity: 0.9; } 100% { opacity: 0; } }
}

/* Mend beam (targeted-effects spike) — the healing counterpart to the lightning bolt: a
   smooth green SVG arc from caster to a healed creature, world-anchored in the grid like the
   bolt. Same two-polyline build (a soft green glow underneath, a pale core on top) but it
   swells in and fades out gently rather than crackling — a flowing stream, not electricity. */
.beam {
  position: absolute;
  z-index: 21;
  pointer-events: none;
  overflow: visible;
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
  animation: beamflow 500ms ease-out forwards;
}
.beam .glow { stroke: #57e08a; stroke-width: 8; opacity: 0.5; filter: blur(3px); }
.beam .core { stroke: #e6ffe9; stroke-width: 2.4; filter: drop-shadow(0 0 5px #86f0a8); }
@keyframes beamflow {
  0% { opacity: 0; }
  25% { opacity: 1; }
  70% { opacity: 1; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .beam { animation: beamfade 500ms linear forwards; }
  @keyframes beamfade { 0% { opacity: 0.9; } 100% { opacity: 0; } }
}

/* Flying projectile (usables-animation spike) — a glowing orb an author can pick for a usable's
   travel look (a fireball, a magic missile). World-anchored in the grid; JS gives it a transform
   transition from caster to target, so it just needs to be a round, softly-glowing dot. Colour +
   size come inline from the author's sliders; this is the default cyan orb. */
.projectile {
  position: absolute;
  z-index: 22;
  pointer-events: none;
  border-radius: 50%;
  background: #9cc6ff;
  box-shadow: 0 0 8px #4d8bff, 0 0 3px #fff inset;
  will-change: transform;
}

/* In-place pulse (usables-animation spike) — an expanding ring that blooms and fades on a tile.
   The one style that needs no origin, so it's what a self-target potion draws (and the fallback
   for a travel style cast on yourself). Centred on its tile via the -50% translate; JS sets the
   duration, and colour/thickness come inline from the author's sliders. */
.fxpulse {
  position: absolute;
  z-index: 21;
  pointer-events: none;
  width: var(--tile, 48px);
  height: var(--tile, 48px);
  margin-left: calc(var(--tile, 48px) / -2);
  margin-top: calc(var(--tile, 48px) / -2);
  border: 3px solid #e6ffe9;
  border-radius: 50%;
  box-shadow: 0 0 12px #86f0a8;
  animation: fxpulse 560ms ease-out forwards;
}
@keyframes fxpulse {
  0% { transform: scale(0.2); opacity: 0.9; }
  100% { transform: scale(2.4); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .fxpulse { animation: fxpulsefade 560ms linear forwards; }
  @keyframes fxpulsefade { 0% { transform: scale(1.4); opacity: 0.8; } 100% { opacity: 0; } }
}

/* Tap-to-inspect label — the touch analogue of the desktop hover tooltip. Anchored to
   a tile centre (JS sets left/top) and translated up so it floats just above the
   entity. Non-interactive; a short fade in/out. */
.inspect {
  position: absolute;
  z-index: 21;
  transform: translate(-50%, calc(-100% - 4px));
  max-width: 12rem;
  padding: 0.2rem 0.45rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--fg);
  font-size: 0.72rem;
  font-weight: 600;
  line-height: 1.1;
  white-space: nowrap;
  text-align: center;
  pointer-events: none;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
  animation: inspectpop 1800ms ease-out forwards;
}
/* Flavortext rides under the name on its own wrapping line — lighter and non-bold so the
   name still reads as the headline. Overrides the label's nowrap so a blurb can wrap. */
.inspect-flavor {
  margin-top: 0.15rem;
  font-weight: 400;
  font-style: italic;
  color: var(--dim);
  white-space: normal;
}
@keyframes inspectpop {
  0% { opacity: 0; }
  8% { opacity: 1; }
  85% { opacity: 1; }
  100% { opacity: 0; }
}

.toast {
  position: fixed;
  left: 50%;
  /* Float clear of the whole bottom-centre stack: above the hotbar AND the placer when
     it's up. Both heights are measured live into --hotbar-h / --brush-h (see game.js's
     relayoutBottomStack) so a wrapped bar or a multi-row placer never lands under this
     nag. --brush-h is 0px when the placer is closed, so the toast drops to just-above-
     the-hotbar. Fallbacks cover the pre-measure first paint. */
  bottom: calc(0.7rem + var(--hotbar-h, 3.3rem) + var(--brush-h, 0px) + 0.9rem);
  /* Interface chrome sits at the page level, above the isolated game layer (.viewport).
     An explicit z-index keeps that intent from ever depending on source order again. */
  z-index: 60;
  transform: translateX(-50%);
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--fg);
  padding: 0.5rem 0.9rem;
  border-radius: 6px;
  font-size: 0.8rem;
  opacity: 0;
  transition: opacity 150ms;
  pointer-events: none;
}
.toast.show { opacity: 1; }

/* ---- upgrade theater ---- */
/* The loud takeover shown for the ~couple seconds between "a new build is live" and the reload.
   It sits above ALL page chrome (z-index 200). The scrim is deliberately translucent so the world
   quaking behind it (body.upgrading, below) reads through — the whole point is to OWN the deploy
   hitch, not hide it. Its candy payoff is the bounce-reward orbs that rain after the reload. */
.upgrade-overlay {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: radial-gradient(circle at 50% 40%, rgba(45, 32, 18, 0.55), rgba(12, 9, 6, 0.82));
  -webkit-backdrop-filter: blur(1.5px); /* iOS Safari still needs the prefix */
  backdrop-filter: blur(1.5px);
  animation: upgradeIn 260ms ease-out;
}
.upgrade-overlay[hidden] { display: none; }
.upgrade-card {
  text-align: center;
  color: var(--fg);
  max-width: min(30rem, 92vw);
  animation: upgradeCardIn 420ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.upgrade-pick {
  font-size: 3.4rem;
  line-height: 1;
  display: inline-block;
  transform-origin: 70% 70%;
  animation: pickSwing 620ms ease-in-out infinite;
  filter: drop-shadow(0 0 10px rgba(201, 139, 58, 0.6));
}
.upgrade-title {
  margin: 0.6rem 0 0.2rem;
  color: var(--accent);
  font-size: 1.5rem;
  letter-spacing: 0.03em;
  text-shadow: 0 0 12px rgba(201, 139, 58, 0.45);
}
.upgrade-sub { margin: 0.2rem 0 0.6rem; color: var(--fg); font-size: 0.9rem; }
.upgrade-sub strong { color: #ffd447; }
.upgrade-ver { margin: 0; color: var(--dim); font-size: 0.85rem; letter-spacing: 0.05em; }
.upgrade-ver strong { color: var(--accent); }
.upgrade-sparks { margin-top: 0.9rem; font-size: 1.5rem; display: flex; gap: 0.5rem; justify-content: center; }
.upgrade-sparks span { display: inline-block; animation: sparkBob 900ms ease-in-out infinite; }
.upgrade-sparks span:nth-child(2) { animation-delay: 120ms; }
.upgrade-sparks span:nth-child(3) { animation-delay: 240ms; }
.upgrade-sparks span:nth-child(4) { animation-delay: 360ms; }
.upgrade-sparks span:nth-child(5) { animation-delay: 480ms; }
.upgrade-sparks span:nth-child(6) { animation-delay: 600ms; }

/* The disruptive part: while the theater is up, the whole world (HUD + viewport) quakes and
   pulses like the halls really are being reforged. It's the sibling .game element that wobbles,
   so the overlay itself stays rock-steady and legible on top. */
body.upgrading .game { animation: worldQuake 520ms ease-in-out infinite; transform-origin: 50% 50%; }

@keyframes upgradeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes upgradeCardIn { from { opacity: 0; transform: scale(0.86) translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes pickSwing {
  0%, 100% { transform: rotate(-28deg); }
  50% { transform: rotate(24deg); }
}
@keyframes sparkBob {
  0%, 100% { transform: translateY(0) rotate(-6deg); }
  50% { transform: translateY(-9px) rotate(6deg); }
}
@keyframes worldQuake {
  0%   { transform: translate(0, 0) rotate(0deg) scale(1); }
  20%  { transform: translate(-6px, 3px) rotate(-0.7deg) scale(1.015); }
  40%  { transform: translate(5px, -4px) rotate(0.8deg) scale(0.99); }
  60%  { transform: translate(-4px, -3px) rotate(-0.5deg) scale(1.02); }
  80%  { transform: translate(6px, 4px) rotate(0.6deg) scale(0.995); }
  100% { transform: translate(0, 0) rotate(0deg) scale(1); }
}

/* Respect reduced-motion: keep the (unmissable, on-purpose) banner but drop the shaking and
   swinging — a still, legible notice instead of a carnival. */
@media (prefers-reduced-motion: reduce) {
  .upgrade-overlay, .upgrade-card { animation: none; }
  .upgrade-pick, .upgrade-sparks span { animation: none; }
  body.upgrading .game { animation: none; }
}

/* ---- boot splash ---- */
/* Opaque cover over the blank/half-built first paint, held until the first world frame renders.
   Deliberately calmer than the upgrade theater (no quake, no sparks): one slowly pulsing pickaxe
   on the same cavern-dark ground as the login page, so the load reads as "descending into the
   mines" rather than "broken". game.js adds .dismissed to fade it, then drops it from the DOM. */
.boot-splash {
  position: fixed;
  inset: 0;
  z-index: 300; /* above even the upgrade theater (200), so a deploy-time reload still boots clean */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  background: radial-gradient(circle at 50% 42%, #1a130b, var(--bg));
  /* Solid underlayer so the cover is cavern-dark from the very first paint, even if the
     gradient composites a frame late (background-color must FOLLOW the shorthand, which
     resets it). Part of the first-paint-flash fix below. */
  background-color: var(--bg);
  transition: opacity 420ms ease-out;
}
.boot-splash.dismissed { opacity: 0; pointer-events: none; }
/* First-paint flash fix, take 2 (Rob, 2026-07-08): before the color-emoji glyph rasterizes,
   drop-shadow() traces the placeholder BOX instead of the pickaxe — a yellow squared flash on
   the one element whose whole job is hiding ugly paints. Take 1 (0.5.67) only delayed the
   fade-in and bet the glyph would be ready in ~400ms; on a cold font cache it isn't, so the
   square still flashed. Now nothing traces the glyph at all: the glow is a round ::before blob
   behind the pickaxe, its shape independent of font rasterization. Worst case on a slow raster
   is a soft round glow with the pickaxe popping in a beat late — never a square. The bootIn
   fade stays as polish; bootPulse scales the whole element, so the glow still breathes. */
.boot-pick {
  position: relative;
  font-size: 4rem;
  line-height: 1;
  transform-origin: 60% 60%;
  animation: bootIn 260ms ease-out 140ms both, bootPulse 1.4s ease-in-out infinite;
}
.boot-pick::before {
  content: "";
  position: absolute;
  inset: -14%;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(201, 139, 58, 0.4), transparent 68%);
}
.boot-label {
  margin: 0;
  color: var(--accent);
  font-size: 1rem;
  letter-spacing: 0.08em;
  animation: bootIn 260ms ease-out 140ms both;
}
@keyframes bootIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes bootPulse {
  0%, 100% { transform: scale(0.9); }
  50%      { transform: scale(1.08); }
}
@media (prefers-reduced-motion: reduce) {
  .boot-splash { transition: none; }
  /* Keep the bootIn flash-hider (it's a fade, not motion) — only the pulse goes. */
  .boot-pick { animation: bootIn 260ms ease-out 140ms both; }
}

/* ---- sign / dialog panel ---- */
/* Shown when a player bumps a sign (keystone #2 — bump→dialog). Page-level chrome,
   above the isolated game layer, like the toast. A warm "plaque" the player reads:
   centred low, out of the way of the top-left HUD. Non-modal — it doesn't dim the
   world or trap focus, so movement keys keep working (walking away closes it). */
.sign-panel {
  position: fixed;
  left: 50%;
  bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px)); /* clear the iPhone home indicator */
  transform: translateX(-50%);
  z-index: 55;
  width: min(30rem, calc(100% - 2rem));
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6rem;
  padding: 0.9rem 1.1rem;
  background: color-mix(in srgb, var(--panel) 96%, var(--accent));
  border: 1px solid var(--accent);
  border-radius: 8px;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
  cursor: pointer; /* click anywhere on it to dismiss */
}
.sign-panel[hidden] { display: none; }
.sign-text {
  margin: 0;
  white-space: pre-wrap; /* keep the admin's line breaks */
  overflow-wrap: anywhere;
  text-align: center;
  line-height: 1.45;
  color: var(--fg);
  /* A long sign (a fat {{bug_inbox}}, a wall of prose) scrolls inside the plaque instead of
     growing off the top of the screen — same treatment as .book-text, with less chrome to
     reserve (title/nav/buttons the sign doesn't have; the ~12rem covers panel padding, the
     dismiss hint, and the bottom offset). */
  max-height: max(40dvh, calc(100dvh - 12rem));
  overflow-y: auto;
  overscroll-behavior: none;
}
.sign-panel .ghost { margin: 0; }

/* ---- book / letter reader (readable text items) ---- */
/* A taller warm plaque than the sign — it holds a page of prose, page arrows, and (for an editable
   text) an inline editor. Not click-anywhere-to-dismiss: the arrows/edit buttons would be eaten.
   Dismiss with the close button, Esc, or by walking away (while just reading). */
.book-panel {
  position: fixed;
  left: 50%;
  /* Sits level with the hotbar and overlaps it (panel z-index 57 beats the hotbar's 40) —
     nobody quick-uses items mid-read or mid-write, and the reclaimed strip is page height. */
  bottom: calc(0.7rem + env(safe-area-inset-bottom, 0px)); /* clear the iPhone home indicator */
  transform: translateX(-50%);
  /* One above the chest panel (56): a book read off a shelf opens OVER the still-open chest —
     close it and you're back browsing. (They share a bottom-center spot, and the chest is later
     in the DOM, so equal z would bury the reader.) */
  z-index: 57;
  width: min(44rem, calc(100% - 2rem));
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0.6rem;
  padding: 0.9rem 1.1rem;
  background: color-mix(in srgb, var(--panel) 96%, var(--accent));
  border: 1px solid var(--accent);
  border-radius: 8px;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
.book-panel[hidden] { display: none; }
/* Keyboard lift (mobile): the bottom anchor parks the editor exactly where the on-screen
   keyboard lands. game.js watches visualViewport and adds .kbd-up with an inline top/height
   that FILLS the still-visible strip — a hard height, so the flex column below has real
   space to hand the page. It may overlap the HUD up there — deliberate: you're writing,
   not fighting. The overscroll-behavior here (and on .book-text/.book-edit) matters on
   that same keyboard: the layout viewport stays full-height under it, so a drag that
   runs out of scroll inside the editor would otherwise chain to the document and pan
   the game world around beneath the panel. */
.book-panel.kbd-up {
  bottom: auto;
  overflow-y: auto;
  overscroll-behavior: none;
}
/* Inside the strip-filling panel the page gives up its vh caps (vh ignores the keyboard)
   and flexes to take ALL the spare height — the writing box is the point — scrolling
   internally; 3rem is only the shrink floor when tools crowd it. */
.book-panel.kbd-up .book-text,
.book-panel.kbd-up .book-edit {
  max-height: none;
  flex: 1 1 auto;
  min-height: 3rem;
}
.book-title {
  margin: 0;
  text-align: center;
  font-weight: 700;
  color: var(--accent);
}
.book-title:empty { display: none; }
.book-text {
  margin: 0;
  min-height: 6rem;
  /* Take the vertical room the screen actually has: the 100dvh term stops just short of the
     top of the window (the ~18rem covers the panel's own chrome — title, nav, buttons, gaps,
     padding — plus the bottom offset); the 40dvh floor keeps short phone viewports readable. */
  max-height: max(40dvh, calc(100dvh - 18rem));
  overflow-y: auto;
  overscroll-behavior: none;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  text-align: left;
  line-height: 1.5;
  color: var(--fg);
}
.book-edit {
  width: 100%;
  min-height: 8rem;
  /* The writing box opens tall — a page of prose, not a chat line. Same viewport math as
     .book-text; resize stays available to pull it shorter. */
  height: max(14rem, calc(100dvh - 20rem));
  max-height: max(40dvh, calc(100dvh - 18rem));
  resize: vertical;
  overscroll-behavior: none;
  box-sizing: border-box;
  font: inherit;
  line-height: 1.5;
  /* Writing happens on the same vellum as drawing (matches .book-canvas / DRAW_BG). */
  background: #efe4c9;
  color: #2a2118;
  border: 1px solid color-mix(in srgb, #efe4c9 70%, #000);
  border-radius: 4px;
}
.book-edit[hidden] { display: none; }
/* Rename-this-copy input: dressed as the title it replaces while editing. */
.book-title-edit {
  box-sizing: border-box;
  width: 100%;
  text-align: center;
  font: inherit;
  font-weight: 700;
  color: var(--accent);
}
.book-title-edit[hidden] { display: none; }
/* A drawing page: a square of vellum. The canvas' 1000×1000 logical space scales to fit; the
   square is kept by sizing width against the viewport height so a phone still shows the tools.
   The dvh term caps the square so canvas + the panel's chrome (~19rem of title, tools, nav,
   page buttons, close, gaps, padding) always fits the viewport — the panel sits down at the
   hotbar, and the top edge must never clip offscreen.
   touch-action: none so a finger draws instead of scrolling the page. */
.book-canvas {
  width: min(100%, 55dvh, calc(100dvh - 20rem));
  aspect-ratio: 1 / 1;
  align-self: center;
  background: #efe4c9; /* vellum — matches DRAW_BG in game.js */
  border: 1px solid color-mix(in srgb, #efe4c9 70%, #000);
  border-radius: 4px;
  touch-action: none;
  cursor: crosshair;
  /* A long-press to draw must not start a native selection/callout. iOS WebKit ignores the
     canvas's pointerdown preventDefault for this — only these suppress the text-selection menu
     (and stop it bleeding onto the tool/nav chrome below). Same fix as the hotbar slots. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.book-canvas[hidden] { display: none; }
.book-draw-tools {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  /* Keep an errant long-press on the brush chrome from selecting these pips/labels. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.book-draw-tools[hidden] { display: none; }
.book-draw-tools .ghost { margin: 0; }
.book-draw-tools input[type="color"] {
  width: 2.2rem;
  height: 2rem;
  padding: 0.1rem;
  cursor: pointer;
}
/* Brush-size pips: a filled dot whose diameter previews the stroke. */
.draw-size { display: inline-flex; align-items: center; justify-content: center; min-width: 2.2rem; min-height: 2rem; }
.draw-size .draw-dot {
  width: var(--d, 8px);
  height: var(--d, 8px);
  border-radius: 50%;
  background: currentColor;
}
.draw-size[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--accent);
}
/* Undo/redo + eraser share the pip footprint so the toolbar reads as one row. */
.draw-op, .draw-erase { min-width: 2.2rem; min-height: 2rem; padding: 0.1rem 0.3rem; }
.draw-op:disabled { opacity: 0.4; }
/* The eraser lights up like the size pips when it's the active tool. */
.draw-erase[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 16%, transparent);
}
/* ---- rich text (signs, NPC lines, book pages) ---- */
/* [[Name]] — game-entity emphasis: bold in the accent, a name that matters. */
.rich-name { color: var(--accent); font-weight: 700; }
.richlink { color: var(--accent); text-decoration: underline; }
/* A {{token}}'s live block: its rows stack inside flowing text (spans, since the book
   page is a <p> — a div inside would be invalid and browsers would hoist it out). */
.tok-block { display: block; margin: 0.4em 0; }
.tok-row { display: block; line-height: 1.5; }
.tok-row.tok-head { font-weight: 700; color: var(--accent); }
.tok-row.tok-dim { opacity: 0.65; }
/* A closed bug report in the {{bug_inbox}} tail — history, not homework. */
.tok-row.tok-strike { text-decoration: line-through; opacity: 0.5; }

/* ---- the 🏆 achievements panel (badge wall) ---- */
.ach-list { list-style: none; margin: 0.5em 0; padding: 0; max-height: 60vh; overflow-y: auto; }
.ach-row {
  display: grid; grid-template-columns: 1.8em 1fr; grid-template-rows: auto auto;
  column-gap: 0.5em; align-items: center; padding: 0.35em 0.2em;
  border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
  opacity: 0.55; /* unearned = waiting in the dark… */
}
.ach-row.ach-earned { opacity: 1; }
.ach-icon { grid-row: 1 / span 2; font-size: 1.3em; text-align: center; filter: grayscale(1); }
.ach-earned .ach-icon { filter: none; }
.ach-name { font-weight: 700; }
.ach-earned .ach-name { color: var(--accent); }
.ach-blurb { grid-column: 2; font-size: 0.85em; opacity: 0.8; }
.ach-secret .ach-name { letter-spacing: 0.15em; }

/* ---- the 🎰 gacha machine ---- */
.gacha-reveal {
  display: flex; align-items: center; gap: 0.6em; margin: 0.6em 0;
  padding: 0.6em; border: 1px dashed currentColor; border-radius: 8px;
}
.gacha-reveal .swatch { width: 3em; height: 3em; }
.gacha-rarity { font-size: 0.8em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; }
/* Rarity accents — the classic ramp. Used on palette chips and the reveal tag. */
.chip-rarity-common    { --rarity: #9e9e9e; }
.chip-rarity-uncommon  { --rarity: #4caf50; }
.chip-rarity-rare      { --rarity: #2196f3; }
.chip-rarity-epic      { --rarity: #9c27b0; }
.chip-rarity-legendary { --rarity: #ff9800; }
.chip[class*="chip-rarity-"] { box-shadow: inset 0 0 0 2px var(--rarity); border-radius: 6px; }
.gacha-rarity[class*="chip-rarity-"] { color: var(--rarity); }
#gachaBtn { white-space: nowrap; }

/* ---- the standalone drawing editor (tiles + avatars) ---- */
/* A modal reusing the book's tool chrome (.book-draw-tools / .draw-size). The canvas is
   painted by JS each frame (checkerboard = transparent background), so no CSS background. */
.draw-dialog { width: min(34rem, 96vw); }
.draw-dlg-canvas {
  width: min(100%, 55dvh);
  aspect-ratio: 1 / 1;
  align-self: center;
  display: block;
  margin: 0 auto 0.6rem;
  border: 1px solid var(--line);
  border-radius: 4px;
  touch-action: none;
  cursor: crosshair;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.draw-dlg-bg {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  margin-top: 0.5rem;
}
.draw-dlg-bg .check { margin: 0; }
.draw-dlg-bg input[type="color"] { width: 2.2rem; height: 2rem; padding: 0.1rem; cursor: pointer; margin: 0; }
/* A written book wearing its first-page drawing as a cover: the thumbnail replaces the
   def's art wherever the item renders — panel rows, hotbar slot art, and the dropped copy
   on the ground (paintCover sets it as a background image on the `rend` element). */
.rend.cover {
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
}

.book-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  /* Same long-press guard — the page arrows sit right under the canvas the selection reaches for. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.book-arrow { margin: 0; font-size: 1.1rem; }
.book-arrow:disabled { opacity: 0.35; cursor: default; }
.book-pageno {
  min-width: 3.5rem;
  text-align: center;
  color: var(--muted, var(--fg));
  font-variant-numeric: tabular-nums;
}
.book-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
}
.book-actions .ghost { margin: 0; }
.book-actions .ghost[hidden] { display: none; }

/* ---- NPC dialogue panel (quests & shops) ---- */
/* Same warm plaque as the sign, but with choice buttons — so NOT click-anywhere-to-dismiss
   (a stray tap would eat a choice). Dismiss with the "leave" button, Esc, or by walking away. */
.npc-panel {
  position: fixed;
  left: 50%;
  bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px)); /* clear the iPhone home indicator */
  transform: translateX(-50%);
  z-index: 56;
  width: min(30rem, calc(100% - 2rem));
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0.6rem;
  padding: 0.9rem 1.1rem;
  background: color-mix(in srgb, var(--panel) 96%, var(--accent));
  border: 1px solid var(--accent);
  border-radius: 8px;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
.npc-panel[hidden] { display: none; }
.npc-name {
  margin: 0;
  text-align: center;
  font-weight: 700;
  color: var(--accent);
}
.npc-name:empty { display: none; }
.npc-panel .sign-text { text-align: left; }
.npc-choices {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.npc-choice {
  width: 100%;
  text-align: left;
  padding: 0.5rem 0.7rem;
  background: color-mix(in srgb, var(--panel) 80%, var(--accent));
  color: var(--fg);
  border: 1px solid color-mix(in srgb, var(--accent) 60%, transparent);
  border-radius: 6px;
  cursor: pointer;
  font: inherit;
}
.npc-choice:hover { background: color-mix(in srgb, var(--panel) 60%, var(--accent)); }
/* The ability-animation preview stage: a positioned strip inside the object editor that the
   fx draw helpers can render into (they take it as a host instead of the world grid). One
   world-tile tall; the travelling styles cross it, a pulse blooms at its centre. */
.fx-stage {
  position: relative;
  height: var(--tile, 48px);
  margin: 0.4rem 0;
  border: 1px dashed var(--line);
  border-radius: 6px;
  overflow: hidden;
  background: color-mix(in srgb, var(--panel) 70%, black);
}

/* The choice's number key, worn as a quiet leading digit — the keyboard path's signpost. */
.npc-key {
  color: var(--dim);
  margin-right: 0.45rem;
  font-variant-numeric: tabular-nums;
  font-size: 0.85rem;
}
/* A priced choice you can't pay: grayed, inert, and wearing a small red "can't afford" tag —
   the same read as the bag's .invcost.short, so unaffordable means one thing everywhere. */
.npc-choice.short {
  opacity: 0.55;
  cursor: not-allowed;
  background: var(--panel);
}
.npc-choice.short:hover { background: var(--panel); }
.npc-short {
  float: right;
  color: #e07a7a;
  font-size: 0.8rem;
  border: 1px solid color-mix(in srgb, #e07a7a 45%, var(--line));
  border-radius: 4px;
  padding: 0 0.3rem;
  margin-left: 0.5rem;
}
.npc-panel .ghost { margin: 0; align-self: center; }

/* ---- chest / container transfer panel ---- */
/* Same warm plaque as the NPC panel, but wider — two side-by-side wells (the chest and your
   bag). The column headers carry the labels; there is no separate panel title, because the left
   header IS the chest's name (a second "Chest" above it just read as a stutter). Each list sits
   in an inset, scrollable well so it's obvious where the contents live and that they scroll. Not
   click-anywhere-to-dismiss (a stray tap would move an item); close with the button or by
   walking away. */
.chest-panel {
  position: fixed;
  left: 50%;
  /* Lift clear of the iPhone home indicator; the inset is 0 on non-notched devices and
     without viewport-fit=cover (see index.html), so this is a no-op there. */
  bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
  transform: translateX(-50%);
  z-index: 56;
  /* Rob (J4): 40rem read too narrow once real chests filled up — item names wrapped
     against the buttons. Wider cap on desktop; mobile still gets 100% - 2rem. */
  width: min(52rem, calc(100% - 2rem));
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0.7rem;
  padding: 0.85rem 1rem;
  /* Never taller than the dynamic viewport (dvh tracks the mobile toolbar), and scroll the
     body if it would be — so the panel can't run its top off-screen with no way back. */
  max-height: calc(100dvh - 6rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  background: color-mix(in srgb, var(--panel) 96%, var(--accent));
  border: 1px solid var(--accent);
  border-radius: 10px;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
.chest-panel[hidden] { display: none; }
.chest-cols { display: flex; gap: 0.8rem; align-items: stretch; }
.chest-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.chest-head {
  margin: 0;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  /* A verb-heavy chest (key/rename/pass-through/take-all…) wraps its buttons onto a second
     line on a phone rather than pushing the last one off the right edge. */
  flex-wrap: wrap;
  gap: 0.3rem 0.5rem;
  font-weight: 700;
  color: var(--accent);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
/* Capacity ("3/8") rides the chest header as quiet, right-aligned metadata. */
.chest-head .count { color: var(--dim); font-weight: 400; letter-spacing: 0; font-variant-numeric: tabular-nums; }
/* An inset well: a sunken, bordered panel that makes each list read as a contained, scrollable
   region. scrollbar-gutter keeps both columns the exact same width whether or not they overflow,
   so there's never a lopsided gap on one side. min-height keeps a near-empty chest from
   collapsing next to a full bag. */
.chest-list {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  min-height: 3.25rem;
  max-height: 42dvh;
  overflow-y: auto;
  scrollbar-gutter: stable;
  padding: 0.4rem;
  border-radius: 8px;
  background: color-mix(in srgb, var(--bg) 65%, #000);
  border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
  scrollbar-width: thin;
  scrollbar-color: color-mix(in srgb, var(--accent) 45%, transparent) transparent;
}
.chest-list::-webkit-scrollbar { width: 8px; }
.chest-list::-webkit-scrollbar-track { background: transparent; }
.chest-list::-webkit-scrollbar-thumb {
  background: color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: 4px;
}
.chest-list .hint { margin: auto 0.2rem; }
/* A chest/bag row: a solid little card inside the well. gap/qty stay put; the NAME is the only
   thing that gives up space (min-width:0 lets it shrink and wrap), and the take/store/all buttons
   never shrink or clip — so a cramped column wraps the name rather than pushing buttons off. */
.chest-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.35rem 0.45rem;
  border-radius: 6px;
  background: color-mix(in srgb, var(--panel) 90%, var(--accent));
  border: 1px solid transparent;
}
.chest-row:hover { border-color: color-mix(in srgb, var(--accent) 35%, transparent); }
.chest-row .swatch { flex-shrink: 0; }
.chest-row .invname { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.chest-row .invqty { flex-shrink: 0; }
/* Tidy take/store/all buttons — small and consistent, never the chunky default button (which
   carries a big margin-top and padding). Matches the inventory panel's "use" button. */
.chest-row .usebtn {
  flex-shrink: 0;
  margin: 0;
  padding: 0.25rem 0.6rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--bg);
  color: var(--accent);
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
}
.chest-row .usebtn:hover { border-color: var(--accent); }
/* The .usebtn rules hard-set background/color, which silently erases the browser's default
   disabled dimming — any disabled .usebtn must gray out explicitly or it looks fully live. */
.usebtn:disabled { opacity: 0.4; cursor: default; }
.usebtn:disabled:hover { border-color: var(--line); }
/* ---- the map view (maps v2) ---- */
/* A recorded map unrolled: the parchment chart canvas. Cells are painted at integer scale;
   pixelated keeps the hand-drawn tile look instead of a blurry upsample. The canvas sizes
   to the viewport but never past its natural crispness. */
/* The dialog sizes to its chart (the .dialog default 30rem is narrower than the well's
   34rem cap, which left the whole modal scrolling sideways on desktop). */
.dialog.map-dialog { text-align: center; width: fit-content; min-width: min(20rem, 92vw); max-width: 96vw; }
/* The renamable title: name + a quiet pencil, centred as one line. */
.map-dialog .map-title { display: flex; align-items: center; justify-content: center; gap: 0.4rem; }
.map-dialog .map-rename { font-size: 0.65em; padding: 0.1rem 0.35rem; }
/* The well wrapper anchors the floating view controls over the scrollable chart. */
.map-well { position: relative; width: fit-content; margin: 0.5rem auto; }
/* The scroll well bounds the view; zoom grows the canvas within it and overflow pans. */
.map-scroll {
  overflow: auto;
  max-width: min(80vw, 34rem);
  max-height: 60vh;
  width: fit-content;
  border: 3px solid color-mix(in srgb, #8a6b3d 70%, var(--line));
  border-radius: 4px;
  scrollbar-width: thin;
}
/* Zoom + pan float at the well's top-left, over the parchment. */
.map-view-tools {
  position: absolute;
  top: 8px;
  left: 8px;
  display: flex;
  gap: 4px;
  z-index: 1;
}
.map-view-tools button {
  background: color-mix(in srgb, var(--panel) 82%, transparent);
  backdrop-filter: blur(2px);
}
/* The ✋ tool must READ as armed — an emoji glyph doesn't tint with `color`, so the
   pressed state leans on background + border, not the glyph. */
.map-view-tools button[aria-pressed="true"] {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 30%, var(--panel));
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent);
}
.map-dialog canvas {
  display: block;
  image-rendering: pixelated; /* crisp cells at any zoom, never a blurry upsample */
  background: #c7b085;
  touch-action: none; /* pointer-drawn annotations — a finger stroke must not scroll */
  cursor: crosshair;
}
.map-dialog canvas.panning { cursor: grab; }
.map-dialog .book-draw-tools { justify-content: center; flex-wrap: wrap; }
/* The wrap row would otherwise stretch the color input into a full-width bar. */
.map-dialog .book-draw-tools input[type="color"] { flex: 0 0 auto; width: 2.6rem; }
.loot-row { align-items: center; }
/* ---- crafting (stations only — the crafting spike) ---- */
/* The craft strip under a station's two columns: the Craft button + a hint, then one card per
   discovered recipe, then the "N undiscovered…" tease. Hidden entirely for a plain chest. */
.chest-craft {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  padding-top: 0.55rem;
  border-top: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
}
.chest-craft[hidden] { display: none; }
.chest-craft .craft-head { display: flex; align-items: center; gap: 0.6rem; }
.chest-craft .craft-head .hint { margin: 0; }
/* Timed crafting: the mid-cook countdown. tabular-nums keeps the ticking digits from
   wobbling; an undiscovered (or doomed) cook's ??:?? breathes instead of ticking. */
.chest-craft .cook-timer { font-weight: 600; font-variant-numeric: tabular-nums; }
.chest-craft .cook-timer.cook-unknown { animation: cookPulse 1.6s ease-in-out infinite; }
@keyframes cookPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
#craftBtn {
  margin: 0;
  padding: 0.35rem 0.9rem;
  border: 1px solid var(--accent);
  border-radius: 6px;
  background: var(--bg);
  color: var(--accent);
  font-weight: 700;
  cursor: pointer;
}
#craftBtn:hover { background: color-mix(in srgb, var(--accent) 18%, var(--bg)); }
/* One discovered recipe: "name  🌿 ＋ 🫚 → 🧪" as quiet inline chips. */
.recipe-card {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.3rem;
  font-size: 0.85rem;
  padding: 0.25rem 0.45rem;
  border-radius: 6px;
  background: color-mix(in srgb, var(--panel) 90%, var(--accent));
}
.recipe-card .invname { margin-right: 0.35rem; }
/* A card the pot + your bag can satisfy right now: the whole card is a one-click craft. */
.recipe-card.craftable {
  cursor: pointer;
  outline: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
}
.recipe-card.craftable:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
.recipe-item { display: inline-flex; align-items: center; gap: 0.15rem; }
.recipe-sep { color: var(--dim); }
/* The batch buttons (×1 / ×all), pushed to the card's trailing edge. */
.recipe-acts { display: inline-flex; gap: 0.25rem; margin-left: auto; }
.recipe-btn {
  font: inherit;
  font-size: 0.78rem;
  line-height: 1;
  padding: 0.2rem 0.4rem;
  border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
  border-radius: 5px;
  background: color-mix(in srgb, var(--panel) 80%, var(--accent));
  color: inherit;
  cursor: pointer;
}
.recipe-btn:hover { background: color-mix(in srgb, var(--accent) 35%, var(--panel)); }
/* Admin recipe editor rows (the recipes dialog): name + summary + edit/delete. */
.recipe-admin-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.3rem 0.2rem;
  border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
}
.recipe-admin-row .invname { flex-shrink: 0; }
.recipe-admin-row .hint { flex: 1 1 auto; min-width: 0; margin: 0; overflow-wrap: anywhere; }
.recipe-row { align-items: center; }
/* Chest-panel action buttons carry both an emoji and their verb (see chestBtn): the word for a
   desktop, the emoji alone on a phone, where the columns already fight for every rem. */
.ebtn .bico { display: none; }
/* On a phone the two side-by-side columns leave each row too narrow — names wrap and buttons
   clip. Stack the columns so every row spans the full panel width instead. */
@media (max-width: 34rem) {
  /* Stacked columns own the whole phone screen: drop the panel nearly to the bottom (it
     covers the fabs — they sit below every panel now) and let the panel's max-height — not a per-list dvh
     cap — be the budget. The lists are the only shrinkable flex items, so a stuffed chest
     grows until the panel hits its cap and then shrinks-to-scroll inside it, while heads,
     craft strip, and the close button keep their height and stay on-screen. A near-empty
     panel still sizes to its content. */
  .chest-panel {
    bottom: calc(0.9rem + env(safe-area-inset-bottom, 0px));
    max-height: calc(100dvh - 5rem);
  }
  /* Dissolve the column wrappers so heads and lists are the panel's own flex items. The
     wrappers can't stay: a shrinkable column (min-height: 0) gets crushed below its
     content — a stuffed bag plus a tall craft strip squeezed a near-empty station column
     to nothing and its list painted over the bag (the 0.6.49 regression) — while an
     unshrinkable one makes the whole panel scroll. As direct flex items, each list
     shrinks-to-scroll but the shrink pass clamps it at its own 3.25rem min-height, so
     both wells always stay visible.
     Fair-split caveat: flex shrink squeezes proportionally to content, so a stuffed bag
     next to a modest shelf crushed the shelf to ~2 rows while the bag kept nine — both
     scrolling, one unusable. basis 0 can't fix it (the panel's intrinsic height then
     ignores list content and everything collapses to min-height — measured, not
     guessed), so splitChestLists() in game.js sets inline min-height floors at each
     list's fair share whenever the two lists overflow this stacked layout. */
  .chest-cols, .chest-col { display: contents; }
  .chest-head { flex-shrink: 0; }
  .chest-list { flex: 1 1 auto; max-height: none; }
  /* The craft strip shrinks-to-scroll like the lists (min-height keeps the bake button and
     a couple of recipe cards visible): a station with a long recipe list must not crush
     YOUR BAG down to its floor while its own recipes stretch the whole panel. */
  .chest-craft { flex: 0 1 auto; min-height: 7.5rem; overflow-y: auto; }
  #chestClose { flex-shrink: 0; }
  .ebtn .bico { display: inline; }
  .ebtn .btxt { display: none; }
}

/* ---- invite dialog ---- */
.dialog {
  width: min(30rem, 92vw);
  /* Cap height to the viewport (dvh tracks mobile browser chrome) and scroll the
     body inside, so a tall form never runs its buttons off the bottom of the screen. */
  max-height: 92vh;
  max-height: 92dvh;
  overflow-y: auto;
  background: var(--panel);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 1.5rem;
}
.dialog::backdrop { background: rgba(0, 0, 0, 0.55); }
/* The bag opens non-modally (dialog.show(), not showModal()) so the chest/book panels —
   plain fixed divs — can layer OVER it: reading a text or opening a backpack from the bag
   leaves the bag open underneath. Non-modal drops the UA's top-layer centering, so restore
   it here, one step under the sign (55) / chest (56) / book (57) panels. */
#inventoryDialog[open] {
  position: fixed;
  inset: 0;
  margin: auto;
  z-index: 54;
}
.dialog h2 { margin: 0 0 0.5rem; color: var(--accent); font-size: 1.1rem; letter-spacing: 0.05em; }
/* The bag dialog's Items/Spells tabs — stand in for its heading. The active one wears the
   accent; the Spells tab is [hidden] entirely until the character knows a spell. */
.invtabs { display: flex; gap: 0.5rem; margin: 0 0 0.5rem; }
/* The sort toggles sit apart from the tabs, on the row's right edge. */
.invtabs .sort { margin-left: auto; padding-inline: 0.5rem; }
.invtabs .sort + .sort { margin-left: 0; }
/* The tab look is shared by the inventory tabs and the admin hub's (editor-cleanup
   slice 1a) — one idiom, so a tab reads the same wherever it appears. */
.invtab,
.admintab {
  margin: 0;
  padding: 0.3rem 0.85rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--bg);
  color: var(--dim);
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  cursor: pointer;
}
.invtab.on,
.admintab.on { color: var(--accent); border-color: var(--accent); }
.invtab[hidden] { display: none; }
.dialog input[type="text"] { margin-top: 0.8rem; }
.dialog-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; flex-wrap: wrap; }
.dialog-row button { margin-top: 0.6rem; }
/* Phone tightening: the admin authoring forms are dense, so on a narrow screen reclaim the
   side padding for content, use nearly the full width, and shrink the long help paragraphs and
   headings so a form's controls aren't buried under a wall of explanatory text. */
@media (max-width: 34rem) {
  .dialog { width: min(30rem, 96vw); padding: 1rem 0.9rem; }
  .dialog h2 { font-size: 1rem; }
  .hint { font-size: 0.72rem; }
  .grant { padding: 0.4rem 0.6rem 0.5rem; }
}
/* Keyboard how-to ("press i", "keys 1–9") is dead weight where there is no keyboard. */
@media (pointer: coarse) {
  .kbd-only { display: none; }
}
/* Phone bag: take the whole screen instead of floating as a card with dead space
   below. The dialog becomes a flex column, the list is the one shrinkable item —
   it grows into the reclaimed height and scrolls inside itself when the bag
   overflows, so tabs, doll, and close never leave the screen. [open] because a
   bare display override would un-hide a closed <dialog>. */
@media (max-width: 34rem) {
  #inventoryDialog[open] {
    display: flex;
    flex-direction: column;
    width: 100vw;
    max-width: 100vw; /* the UA sheet caps <dialog> at 100% − 2em otherwise */
    height: 100dvh;
    max-height: 100dvh;
    margin: 0;
    border: none;
    border-radius: 0;
    padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
    overflow: hidden;
  }
  #inventoryDialog .invlist { flex: 1 1 auto; max-height: none; min-height: 0; }
}
/* Desktop widening: the object-authoring form is the densest dialog we have — and the NPC
   conversation editor inside it especially rewards breathing room (node cards, choice rows,
   outcome pickers all stacked into 30rem). On a real screen let it use real width; every
   other dialog keeps the phone-first column. */
@media (min-width: 60rem) {
  #objectDialog { width: min(52rem, 92vw); }
}

/* ---- door destination picker ---- */
/* The level <select> shares its row with the "＋ new level" button, so it flexes
   rather than taking the full width the generic .dialog select rule gives. */
#doorDialog .dialog-row #doorLevel { flex: 1; width: auto; margin-top: 0.6rem; }
.door-coords { display: flex; gap: 0.8rem; margin-top: 0.8rem; }
.door-coords label { display: flex; align-items: center; gap: 0.4rem; color: var(--dim); font-size: 0.85rem; }
.door-coords input { width: 5rem; margin-top: 0; }
/* The chosen anchor chip: the portal picker's verdict, shown in place of level/tile input. */
.anchor-chip { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.8rem; }
.anchor-chip button { margin: 0; padding: 0 0.5rem; }

/* ---- portal picker (scrying slice 1) ---- */
/* A live miniature of the far side: the same .entity/.player rendering the world uses,
   inside a clipped well. --tile drives all sizing so it matches world art 1:1; the well
   shrinks on narrow phones and simply crops (the anchor stays centered). */
.scry-head { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.6rem; }
.scry-head .ghost { flex: 0 0 auto; font-size: 1.2rem; padding: 0.3rem 0.8rem; }
.scry-label { flex: 1; text-align: center; min-width: 0; }
.scry-label > div:first-child { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scry-view {
  position: relative; overflow: hidden; margin: 0.6rem auto 0;
  width: min(calc(var(--tile) * 7), 100%); height: min(calc(var(--tile) * 7), 60vw);
  border: 1px solid var(--line); border-radius: 10px; background: #07070c;
}
.scry-grid { position: absolute; left: 0; top: 0; transition: transform 0.25s ease; }
.scry-grid .entity, .scry-grid .player { pointer-events: none; }
/* The placement ✕ — where the new door will drop you. Amber = that spot is occupied right
   now (you'd come out at the nearest open tile instead). */
.scry-x {
  position: absolute; width: var(--tile); height: var(--tile);
  display: grid; place-items: center; z-index: 40; pointer-events: none;
  color: #7fff9a; font-size: 1.5rem; font-weight: 700;
  text-shadow: 0 0 6px rgba(0, 0, 0, 0.9), 0 0 12px rgba(127, 255, 154, 0.5);
  animation: scry-x-pulse 1.2s ease-in-out infinite;
}
.scry-x.blocked { color: #ffb347; text-shadow: 0 0 6px rgba(0, 0, 0, 0.9), 0 0 12px rgba(255, 179, 71, 0.5); }
@keyframes scry-x-pulse { 50% { transform: scale(1.3); } }
@media (prefers-reduced-motion: reduce) { .scry-x { animation: none; } }

/* ---- object authoring ---- */
/* Grant fields — shown only for the pickup kind (item effects). Mirrors .door-coords. */
/* min-width: 0 frees the fieldset from its UA `min-inline-size: min-content`, which
   otherwise refuses to shrink below its widest child and drags the whole form wider than
   the 480px dialog — clipping the help text off the right on desktop too, not just phones. */
.grant { border: 1px solid var(--line); border-radius: 8px; margin-top: 0.8rem; padding: 0.4rem 0.8rem 0.6rem; min-width: 0; }
.grant legend { color: var(--dim); font-size: 0.8rem; padding: 0 0.3rem; }
/* Dense authoring rows carry more than fits 480px wide, so let them wrap at every width
   (the phone query below only narrows the number fields on top of this). */
.grant-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
/* JS toggles individual rows via the `hidden` attribute (e.g. the spawner's child/param
   rows show only for a spawner, the ability rows swap by effect/target). The `display:
   flex` above is an author rule, so it beats the UA `[hidden] { display: none }` — spell
   it out or a "hidden" row stays visible (the spawns dropdown leaked onto every mob). */
.grant-row[hidden] { display: none; }
.grant-row label { color: var(--dim); font-size: 0.85rem; }
/* Chore rows are ranked: the badge shows the priority, ▲/▼ shuffle it. There is no global
   button:disabled dimming, so the end-stops have to gray themselves out or they look live. */
.chore-rank { color: var(--dim); font-size: 0.8rem; min-width: 1.1em; text-align: right; }
.chore-row button.icon { padding: 0.15rem 0.4rem; font-size: 0.9rem; line-height: 1; }
.chore-row button.icon:disabled { opacity: 0.3; cursor: default; }
.chore-row button.icon:disabled:hover { border-color: var(--line); }
.grant-row input[type="number"] { width: 5rem; margin-top: 0; }
.grant .hint { margin-top: 0.3rem; }
.dialog-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: 0.4rem; }
.dialog-presets .hint { margin-top: 0; margin-right: 0.1rem; }
.dialog input[type="file"] { margin-top: 0.3rem; color: var(--dim); font-size: 0.85rem; }
.dialog select {
  width: 100%;
  margin-top: 0.3rem;
  padding: 0.5rem 0.6rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--fg);
  font: inherit;
}
.dialog textarea {
  width: 100%;
  margin-top: 0.8rem;
  padding: 0.5rem 0.6rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--fg);
  font: inherit;
  resize: vertical;
}
/* ---- visual NPC-dialog editor ---- */
/* A node graph authored as cards, not raw JSON. The `.dialog select` rule above forces
   selects full-width; the inline reference/item/atom selects here opt back out. */
.dlg-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.5rem; margin-top: 0.6rem; }
.dlg-bar > label,
.dlg-node-head > label,
.dlg-atom-fields label { margin: 0; color: var(--dim); font-size: 0.85rem; }
.dlg-templates { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem; margin-left: auto; }
.dlg-templates .hint { margin: 0 0.1rem 0 0; }
.dlg-nodes { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 0.6rem; }
.dlg-node { border: 1px solid var(--line); border-radius: 8px; padding: 0.5rem 0.6rem; background: color-mix(in srgb, var(--fg) 3%, transparent); }
.dlg-node-head { display: flex; align-items: center; gap: 0.4rem; }
.dlg-node-head > label { color: var(--dim); font-size: 0.85rem; }
.dlg-node-id { width: 9rem; margin-top: 0; font-family: ui-monospace, monospace; }
.dlg-start-badge { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--bg); background: var(--accent); padding: 0.1rem 0.4rem; border-radius: 999px; }
.dlg-node-text { margin-top: 0.4rem !important; min-height: 2.6rem; }
/* The per-line voice-cue row (sound round 2): one compact strip under the node's text. */
.dlg-snd-row { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.4rem; }
.dlg-snd-row > label { color: var(--dim); font-size: 0.9rem; }
.dlg-snd-row select { width: auto; max-width: 11rem; margin-top: 0; padding: 0.35rem 0.5rem; }
.dlg-snd-row button { margin-top: 0; }
.dlg-choices { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; }
.dlg-choice { border: 1px solid var(--line); border-radius: 6px; padding: 0.4rem 0.5rem; background: var(--bg); }
.dlg-choice-main { display: flex; align-items: center; gap: 0.4rem; }
.dlg-ch-label { flex: 1 1 8rem; min-width: 6rem; margin-top: 0; }
.dlg-goto-l { color: var(--dim); }
/* Inline selects opt out of the full-width `.dialog select` rule. */
.dlg-bar .dlg-start,
.dlg-choice select,
.dlg-outcome select { width: auto; max-width: 12rem; margin-top: 0; padding: 0.35rem 0.5rem; }
.dlg-ch-goto { flex: 0 1 9rem; }
.dlg-more { margin-top: 0.4rem; background: none; border: none; color: var(--accent); font: inherit; font-size: 0.8rem; padding: 0; cursor: pointer; }
.dlg-choice-adv { margin-top: 0.4rem; display: flex; flex-direction: column; gap: 0.5rem; }
.dlg-adv-block { display: flex; flex-direction: column; gap: 0.3rem; padding: 0.4rem 0.5rem; border: 1px dashed var(--line); border-radius: 6px; }
.dlg-adv-h { color: var(--dim); font-size: 0.78rem; }
.dlg-adv-block .grant-row { flex-wrap: wrap; }
/* Admin player editor: a stat grid and a wrap of removable flag chips. */
.pe-stats { display: flex; flex-wrap: wrap; gap: 0.5rem 0.9rem; margin: 0.4rem 0 0.6rem; }
.pe-stats label { display: flex; align-items: center; gap: 0.35rem; color: var(--dim); font-size: 0.85rem; }
.pe-stats input { width: 5rem; margin: 0; }
.pe-flags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.4rem 0; min-height: 1.2rem; }
.pe-flag { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.15rem 0.2rem 0.15rem 0.55rem;
  background: var(--panel); border: 1px solid var(--line); border-radius: 999px; font-family: ui-monospace, monospace; font-size: 0.82rem; }
.pe-flag-x { border: none; background: none; color: var(--dim); cursor: pointer; font-size: 0.8rem; line-height: 1; padding: 0.1rem 0.3rem; border-radius: 999px; }
.pe-flag-x:hover { color: var(--fg); background: color-mix(in srgb, var(--line) 60%, transparent); }
.pe-addflag { gap: 0.4rem; }
.pe-addflag input { flex: 1; margin: 0; font-family: ui-monospace, monospace; }
/* The add-status row (statuses sweep): the kind picker takes its own line, then
   duration + clock + magnitude + apply share the next — narrow numbers, no stacking. */
.pe-addstatus { align-items: center; }
.pe-addstatus select, .pe-addstatus input, .pe-addstatus button { margin: 0; }
.pe-addstatus input[type="number"] { flex: 1 1 3.5rem; width: 3.5rem; min-width: 0; }
.pe-addstatus select { flex: 0 1 auto; width: auto; }
#peStatusKind { flex: 1 1 100%; }

/* The per-clause negation toggle: a compact, muted "not"/"doesn't" ahead of each condition. */
.dlg-not { display: inline-flex; align-items: center; gap: 0.25rem; margin: 0; color: var(--dim); font-size: 0.85rem; }
.dlg-not input { width: auto; margin: 0; margin-top: 0; }
/* An unchecked negation is inactive, so grey the "doesn't"/"don't"/"not" word right down and
   let the following verb read positively ("has flag", "holds"); checking it lights the word up
   and swaps to the negated verb ("doesn't have flag", "doesn't hold"). Pure CSS via :has() so
   it just works on the cloned choice rows and the static mob-mood rows alike — no JS wiring. */
.dlg-not:not(:has(:checked)) { opacity: 0.4; }
.verb-swap { color: var(--dim); font-size: 0.85rem; }
.verb-swap .v-neg { display: none; }
.dlg-not:has(:checked) ~ .verb-swap .v-pos { display: none; }
.dlg-not:has(:checked) ~ .verb-swap .v-neg { display: inline; }
.dlg-flag { width: 8rem; margin-top: 0; font-family: ui-monospace, monospace; }
.dlg-adv-block input[type="number"] { width: 4rem; margin-top: 0; }
.dlg-outcomes { display: flex; flex-direction: column; gap: 0.3rem; }
.dlg-outcome { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
.dlg-atom-fields { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
.dlg-atom-fields label { color: var(--dim); font-size: 0.85rem; }
.dlg-atom-fields input[type="number"] { width: 4rem; margin-top: 0; }
/* The curator's taste block inside a buyUnique outcome row: its own wrapped strip under the
   payout fields, dashed off so the optional standards read apart from the sale itself. */
.dlg-taste { flex-basis: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-top: 0.3rem; padding-top: 0.3rem; border-top: 1px dashed var(--line); }
.dlg-taste .taste-head { flex-basis: 100%; color: var(--dim); font-size: 0.75rem; }
.dlg-taste label { color: var(--dim); font-size: 0.85rem; }
.dlg-taste input[type="number"] { width: 4.2rem; margin-top: 0; }
.dlg-taste select { width: auto; margin-top: 0; padding: 0.25rem 0.4rem; }
/* Small circular delete button used on nodes / choices / outcomes. */
.dlg-x { margin-left: auto; flex: 0 0 auto; width: 1.5rem; height: 1.5rem; line-height: 1; padding: 0; border-radius: 50%; border: 1px solid var(--line); background: var(--bg); color: var(--dim); cursor: pointer; }
.dlg-x:hover { border-color: var(--danger, #c0392b); color: var(--danger, #c0392b); }
.dlg-add-choice, .dlg-add-outcome { margin-top: 0.4rem; font-size: 0.8rem; padding: 0.25rem 0.6rem; }
.dlg-adv { margin-top: 0.7rem; }
.dlg-adv summary { color: var(--dim); font-size: 0.8rem; cursor: pointer; }
.dlg-adv textarea { margin-top: 0.4rem; }

/* The tile-palette filters: a name box that flexes wide beside a compact type dropdown.
   The type <select> opts out of the full-width `.dialog select` rule so they share one row. */
.palette-filters { display: flex; gap: 0.4rem; align-items: center; margin-top: 0.4rem; }
.palette-filters #paletteFilter { flex: 1 1 auto; min-width: 0; margin-top: 0; }
.palette-filters #paletteKind { flex: 0 0 auto; width: auto; max-width: 9rem; margin-top: 0; }
.dialog .check { display: flex; align-items: center; gap: 0.45rem; margin: 0.9rem 0 0; color: var(--fg); }
.dialog .check[hidden] { display: none; } /* author display:flex beats the UA [hidden] rule */
.dialog .check input { width: auto; margin: 0; }
.palette {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 1rem;
  padding-top: 1rem;
  border-top: 1px solid var(--line);
  max-height: 9rem;
  overflow: auto;
}
.palette:empty { display: none; }
/* In its own dialog the palette is the main content, not a strip under a form: drop the
   divider and the tight cap so the tile list gets the room (tiles are deployed far more
   than they're created). Lay the dialog out as a flex column so the header/filters and the
   "＋ new tile" button row stay put while ONLY the tile list scrolls — the create button is
   always visible without scrolling the whole dialog to the bottom. Scope to [open]: a bare
   `display: flex` would override the UA `dialog:not([open]) { display: none }` and leak the
   dialog onto the page while closed. */
#paletteDialog[open] {
  display: flex;
  flex-direction: column;
}
#paletteDialog .palette {
  margin-top: 0.5rem;
  padding-top: 0;
  border-top: none;
  /* Grow to fill the space between the filters and the button row, then scroll inside;
     min-height:0 lets a flex item shrink below its content so overflow actually engages. */
  flex: 1 1 auto;
  min-height: 0;
}
/* Keep the button row from ever being squeezed away when the tile list is long. */
#paletteDialog .dialog-row { flex: 0 0 auto; }
.palette .chip {
  display: flex;
  align-items: stretch;
  margin: 0;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
  overflow: hidden;
}
.palette .chip:hover { border-color: var(--accent); }
/* The filter box hides non-matching chips via the `hidden` attribute; our `display:
   flex` above (an author rule) otherwise beats the UA `[hidden] { display: none }`, so
   spell it out or filtering silently does nothing. */
.palette .chip[hidden] { display: none; }
.palette .chip-place,
.palette .chip-edit,
.palette .chip-del {
  margin: 0;
  background: transparent;
  border: none;
  color: var(--fg);
  font: inherit;
  font-size: 0.75rem;
  cursor: pointer;
}
.palette .chip-place { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; }
.palette .chip-edit,
.palette .chip-del { padding: 0.3rem 0.5rem; border-left: 1px solid var(--line); color: var(--dim); }
.palette .chip-place:hover { background: var(--line); }
.palette .chip-edit:hover { background: var(--line); color: var(--accent); }
.palette .chip-del:hover { background: var(--line); color: var(--danger); }
.palette .chip-del:disabled { opacity: 0.5; cursor: default; }

/* A small render-accurate swatch: the object preview and each palette chip. */
.swatch { width: 40px; height: 40px; border-radius: 6px; display: block; }
.swatch .glyph { font-size: calc(40px * 0.82); }
.swatch.sm { width: 24px; height: 24px; }
.swatch.sm .glyph { font-size: calc(24px * 0.82); }
.swatch:not(.prim):not(.sm) { border: 1px solid var(--line); }

/* Render-type picker (segmented) + its show/hide field groups + live preview. */
.segmented { display: flex; gap: 0.3rem; margin-top: 0.3rem; }
.segmented .seg {
  flex: 1; margin: 0; padding: 0.4rem 0.5rem;
  background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
  color: var(--dim); font: inherit; font-size: 0.8rem; font-weight: 400; cursor: pointer;
}
.segmented .seg.on { background: var(--accent); color: #1a1206; border-color: var(--accent); font-weight: 600; }
.rgroup[hidden] { display: none; }
.dialog input[type="color"] {
  width: 100%; height: 2.2rem; margin-top: 0.3rem; padding: 2px;
  background: var(--bg); border: 1px solid var(--line); border-radius: 6px; cursor: pointer;
}
.preview-row { display: flex; align-items: center; gap: 0.6rem; margin-top: 1rem; }
.emoji-picks { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.5rem; }
.emoji-picks .epick {
  margin: 0; padding: 0.15rem 0.3rem; background: var(--bg);
  border: 1px solid var(--line); border-radius: 6px; font-size: 1.1rem; line-height: 1; cursor: pointer;
}
.emoji-picks .epick:hover { border-color: var(--accent); }

/* ---- user management ---- */
/* Wider than the default dialog so a row's name + three actions (role, reset-link,
   disable) sit on one line without wrapping. */
#usersDialog { width: min(34rem, 94vw); }
.userlist { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 1rem; max-height: 20rem; overflow: auto; }
.userlist:empty::after { content: "no users"; color: var(--dim); font-size: 0.85rem; }
.userrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding: 0.4rem 0.6rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
}
.userrow.off { opacity: 0.55; }
.userrow .urwho { font-size: 0.85rem; color: var(--fg); }
.userrow .urwho small { color: var(--dim); }
.userrow .uractions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.userrow .uractions small { color: var(--dim); font-size: 0.75rem; }
.userrow .linkbtn {
  background: transparent; border: 1px solid var(--line); color: var(--fg);
  padding: 0.25rem 0.5rem; margin: 0; border-radius: 6px; font-size: 0.75rem; cursor: pointer;
}
.userrow .linkbtn:hover { border-color: var(--accent); }
/* The player's name doubles as the open-their-stat-editor button (statuses sweep): rendered
   as plain name text, underlined on hover so it reads as clickable without adding chrome. */
.userrow .linkbtn.urname {
  border: none; padding: 0; font-size: 0.85rem; color: var(--fg); text-decoration: none;
}
.userrow .linkbtn.urname:hover { text-decoration: underline; color: var(--accent); }
/* The reset-link button is just its emoji glyph — compact, borderless until hover. */
.userrow .linkbtn.icon { border-color: transparent; padding: 0.25rem 0.35rem; font-size: 0.95rem; line-height: 1; }
.userrow .linkbtn.icon:hover { border-color: var(--accent); }
/* Collapsible action buttons carry both an icon and a text label. On desktop only
   the label shows (unchanged); on phones (see the mobile query) the label hides and
   the icon takes over so the row stays narrow. */
.userrow .linkbtn.collapsible .ic { display: none; }
.userrow .linkbtn:disabled { opacity: 0.5; cursor: default; }
.userrow .linkbtn.danger { color: var(--danger); }
.userrow .linkbtn.danger:hover { border-color: var(--danger); }

/* ---- admin: death log ---- */
.deathlog { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 1rem; max-height: 22rem; overflow: auto; }
.deathlog:empty::after { content: "no deaths yet"; color: var(--dim); font-size: 0.85rem; }
.deathrow {
  padding: 0.4rem 0.6rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
  font-size: 0.85rem;
}
.deathrow b { color: var(--accent); }
.deathrow .dmeta { display: block; margin-top: 0.15rem; color: var(--dim); font-size: 0.75rem; }

/* ---- inventory ---- */
/* The GP readout in the HUD has no bar — just the coin and a count, hugging the glyph. */
.stat-gp .stat-key { min-width: 0; }
.stat-gp .stat-num { min-width: 0; text-align: left; color: var(--accent); font-weight: 700; }

/* Active-status badges (poison/shield/regen) under the stat bars — an icon + turns left,
   tinted by kind. Wraps if several are active. Hidden when there are none. */
.statuses { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.status-badge {
  font-size: 0.7rem;
  font-weight: 700;
  padding: 0.05rem 0.35rem;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: color-mix(in srgb, var(--panel) 70%, transparent);
  white-space: nowrap;
}
.status-poison { color: #b6e36b; border-color: color-mix(in srgb, #b6e36b 45%, var(--line)); }
.status-shield { color: #7ab8ff; border-color: color-mix(in srgb, #7ab8ff 45%, var(--line)); }
.status-regen { color: #6bd66b; border-color: color-mix(in srgb, #6bd66b 45%, var(--line)); }

/* overflow-x is clamped shut: a row's buttons must never make the list a horizontal
   scroller — on phones the dialog's open-focus scrolls it right and clips the item
   names off the left edge. Rows wrap instead (see .invrow). */
.invlist { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 1rem; max-height: 22rem; overflow-y: auto; overflow-x: hidden; }
.invrow {
  display: flex;
  align-items: center;
  /* On a narrow screen the buttons drop to a second line rather than widening the row;
     the name's min-width decides when (it never squishes below a readable width). */
  flex-wrap: wrap;
  gap: 0.25rem 0.6rem;
  padding: 0.35rem 0.5rem;
  border: 1px solid var(--line);
  border-radius: 6px;
}
.invrow .swatch { flex-shrink: 0; }
.invname { flex: 1; min-width: 6rem; font-size: 0.9rem; color: var(--fg); }
/* The row's verb buttons live in one group so a narrow screen wraps them to the next line
   TOGETHER (right-aligned) — never one orphaned "drop" alone under a tall card. */
.invrow .invverbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 0.25rem 0.4rem;
  margin-left: auto;
}
.invqty { color: var(--dim); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
/* "use" button on a usable bag item (a potion/scroll). */
.invrow .usebtn {
  flex-shrink: 0;
  padding: 0.2rem 0.7rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--bg);
  color: var(--accent);
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
}
.invrow .usebtn:hover { border-color: var(--accent); }
/* "drop" button reads as a quieter, secondary action than "use"/"equip". */
.invrow .dropbtn { color: var(--dim); }
/* A priced use wears its cost ("5💰 · 1×bullet"); .short tints it when you can't afford it. */
.invcost { flex-shrink: 0; color: var(--dim); font-size: 0.75rem; font-variant-numeric: tabular-nums; }
.invcost.short { color: var(--danger, #e0645a); }
/* Durability meter (equippable items, 0.8.0 slice 3): a slim wear bar on an item that can wear
   out. Green while healthy, amber when low (≤25%), red+empty when broken. The row dims when
   broken so a used-up blade reads as unusable at a glance. */
.invdur {
  flex-shrink: 0;
  width: 2.6rem;
  height: 0.4rem;
  border-radius: 999px;
  background: var(--line);
  overflow: hidden;
}
.invdurfill { display: block; height: 100%; background: var(--ok, #5fbf7f); border-radius: 999px; }
.invdur.low .invdurfill { background: var(--warn, #e0b04a); }
.invdur.broken { background: var(--danger, #e0645a); }
.invrow.broken { opacity: 0.55; }
/* The same wear bar tucked into a paper-doll chip (worn gear is hidden from the bag, so the doll
   carries the durability read). Slightly narrower to sit inside the pill after the name. */
.equip-slot .eqdur { width: 2rem; flex: none; }

/* ---- hotbar (quick-use slots) ---- */
/* Pinned bottom-centre over the world, above the game layer but below dialogs/toasts. Built
   and kept current by game.js (renderHotbar); slots are number-keyed usable items. */
.hotbar {
  position: fixed;
  left: 50%;
  bottom: calc(0.7rem + env(safe-area-inset-bottom, 0px)); /* clear the iPhone home indicator */
  transform: translateX(-50%);
  display: flex;
  /* A full 9-slot bar is wider than a phone: wrap it (growing upward, still centred) and
     cap it to the viewport so it never runs off both edges or sits under the coords/version. */
  flex-wrap: wrap;
  justify-content: center;
  max-width: calc(100vw - 1rem);
  gap: 0.4rem;
  padding: 0.4rem;
  background: rgba(0, 0, 0, 0.4);
  border: 1px solid var(--line);
  border-radius: 10px;
  z-index: 40;
}
/* Author `display: flex` above beats the UA `[hidden] { display: none }`, so both the strip
   and its slots need the guard spelled out (mirrors .grant-row / .palette .chip). */
.hotbar[hidden] { display: none; }
/* Hotbar position setting: pin the bar down the LEFT or RIGHT edge as a single vertical
   column — in thumb's reach on a phone, with the whole screen height to stretch out in
   instead of bunching into a wrapped stack. Vertically centred; only wraps into a second
   column when the screen is shorter than the bar (max-height guard). relayoutBottomStack
   zeroes --hotbar-h for a side-pinned bar, so the placer and toast reclaim its old
   bottom-centre spot. */
body.hotbar-left .hotbar,
body.hotbar-right .hotbar {
  left: auto;
  right: auto;
  bottom: auto;
  top: 50%;
  transform: translateY(-50%);
  flex-direction: column;
  max-width: none;
  max-height: calc(100vh - 1.4rem);
}
body.hotbar-left .hotbar { left: calc(0.7rem + env(safe-area-inset-left, 0px)); }
/* wrap-reverse: a column flexbox wraps along the horizontal cross-axis, so on the right
   edge the overflow column must grow INTO the screen (leftward), not off the edge. */
body.hotbar-right .hotbar {
  right: calc(0.7rem + env(safe-area-inset-right, 0px));
  flex-wrap: wrap-reverse;
}

/* On-screen movement pad: a 3×3 grid with the four arrows on the plus, pinned bottom-left
   over the world. Off by default; Settings toggles the [hidden] attribute. Author
   `display: grid` beats the UA [hidden] rule, so the guard is spelled out (as with .hotbar). */
.dpad {
  position: fixed;
  left: 0.7rem;
  bottom: calc(0.7rem + env(safe-area-inset-bottom, 0px)); /* clear the iPhone home indicator */
  display: grid;
  grid-template-columns: repeat(3, 3rem);
  grid-template-rows: repeat(3, 3rem);
  gap: 0.3rem;
  z-index: 40;
  touch-action: none; /* the pad's own taps drive movement; don't scroll the page */
  /* Only the pad's buttons (four arrows + the center wait dot) should capture touches.
     The container spans a 3×3 grid whose empty corner cells otherwise sit on top of (and
     block) the hotbar's leftmost slot beneath the pad — let those cells fall through,
     re-arming events on the buttons. */
  pointer-events: none;
}
.dpad[hidden] { display: none; }
/* Swap sides: put the arrow pad on the right and the chat FAB on the left, for
   players who'd rather thumb the pad with their dominant (right) hand. Toggled by
   the "swap control sides" setting, which adds .controls-swapped to <body>. */
body.controls-swapped .dpad { left: auto; right: 0.7rem; }
.dpad-btn {
  pointer-events: auto;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: rgba(0, 0, 0, 0.42);
  color: var(--fg);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
  -webkit-user-select: none;
  user-select: none;
  display: flex;
  align-items: center;
  justify-content: center;
}
.dpad-btn:active { background: rgba(255, 255, 255, 0.18); }
.dpad-up    { grid-area: 1 / 2; }
.dpad-left  { grid-area: 2 / 1; }
.dpad-right { grid-area: 2 / 3; }
.dpad-down  { grid-area: 3 / 2; }
/* The center dot waits a turn in place (the phone's `.`) — dimmer than the arrows so the
   pad still reads as "movement" at a glance. */
.dpad-wait  { grid-area: 2 / 2; color: var(--dim); }
.hotslot {
  position: relative;
  width: 3rem;
  height: 3rem;
  padding: 0;
  margin: 0;
  display: grid;
  place-items: center;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 8px;
  cursor: pointer;
  /* A long-press removes the item; without these iOS WebKit starts a native text selection /
     callout on the slot's number instead of letting our pointer-timer fire. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.hotslot[hidden] { display: none; }
.hotslot:hover { border-color: var(--accent); }
.hotslot.empty { opacity: 0.45; cursor: default; }
.hotslot.empty:hover { border-color: var(--line); }
.hotslot.out { opacity: 0.4; } /* bound but you've run out (or can't afford a spell) — dimmed */
/* A learned spell (slice D) wears a faint electric-blue frame so it reads as magic, not a
   consumable. Its "out" state means "can't afford the mana right now", not "empty". */
.hotslot.spell { border-color: #4d6fbf; box-shadow: inset 0 0 8px rgba(100, 150, 255, 0.25); }
.hotslot.spell:hover { border-color: #7db4ff; }
/* Armed for targeting: glow + pulse so a mobile player can see what's active and knows
   to tap it again (or any chrome) to cancel — there's no Esc key on a phone. */
.hotslot.aiming {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent), 0 0 10px var(--accent);
  animation: hotslot-aim 1s ease-in-out infinite;
}
@keyframes hotslot-aim {
  50% { box-shadow: 0 0 0 2px var(--accent), 0 0 3px var(--accent); }
}
@media (prefers-reduced-motion: reduce) { .hotslot.aiming { animation: none; } }
/* The slot's item art. .swatch is 40px; the slot is 48px, leaving a little inset. */
.hotslot .hotart { pointer-events: none; }
/* The key number (top-left) and carried quantity (bottom-right) overlay the art. */
.hotkey {
  position: absolute;
  top: 2px;
  left: 4px;
  font-size: 0.65rem;
  line-height: 1;
  color: var(--dim);
  pointer-events: none;
}
.hotqty {
  position: absolute;
  bottom: 2px;
  right: 4px;
  font-size: 0.7rem;
  line-height: 1;
  color: var(--fg);
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 2px #000, 0 0 2px #000;
  pointer-events: none;
}
/* Narrow screens: shrink the slots so a full row still fits a phone. */
@media (max-width: 560px) {
  .hotslot { width: 2.5rem; height: 2.5rem; }
  .hotslot .hotart { transform: scale(0.8); }
  /* User-row actions collapse to icon-only — the tooltip carries the meaning. */
  .userrow .linkbtn.collapsible { border-color: transparent; padding: 0.25rem 0.35rem; font-size: 0.95rem; line-height: 1; }
  .userrow .linkbtn.collapsible:hover { border-color: var(--accent); }
  .userrow .linkbtn.collapsible.danger:hover { border-color: var(--danger); }
  .userrow .linkbtn.collapsible .lbl { display: none; }
  .userrow .linkbtn.collapsible .ic { display: inline; }
  /* ...which is exactly what makes the row too wide: collapsing the labels leaves up to six
     icon columns (📍🏃🧲⬆️🚫 + reset-link) that `flex-shrink: 0` then refuses to give up, so a
     long name shoves the whole panel past the screen. Give the actions their own line under
     the name and let them wrap (Rob, 2026-07-16 — "two rows of emojis on mobile"). */
  .userrow { flex-direction: column; align-items: stretch; gap: 0.35rem; }
  .userrow .uractions { flex-wrap: wrap; justify-content: flex-end; }
}

/* ---- placement mode ---- */
.viewport.placing { cursor: crosshair; }
.viewport.erasing { cursor: cell; }
.viewport.picking { cursor: copy; } /* copy/cut: click a tile to pick it into the clipboard */
/* Targeting an ability: the whole viewport becomes a crosshair until a tile is picked. */
body.targeting .viewport { cursor: crosshair; }
.brush {
  position: fixed;
  left: 50%;
  /* Sit just above the hotbar, whatever height it is right now (measured live into
     --hotbar-h; see game.js's relayoutBottomStack). A fixed offset used to work until the
     bar or this placer wrapped to a second row on a phone — then the placer's last row (the
     "done" button) slid behind the hotbar. Fallback covers the pre-measure first paint. */
  bottom: calc(0.7rem + var(--hotbar-h, 3.3rem) + 0.4rem);
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  /* Wrap onto multiple rows and never exceed the viewport, so a narrow phone
     keeps the whole HUD on-screen instead of clipping the ends off both sides. */
  flex-wrap: wrap;
  max-width: calc(100vw - 1rem);
  gap: 0.6rem;
  background: var(--panel);
  border: 1px solid var(--accent);
  color: var(--fg);
  padding: 0.4rem 0.8rem;
  border-radius: 6px;
  font-size: 0.8rem;
  z-index: 5;
}
.brush b { color: var(--accent); }
.brush .brush-lead { white-space: nowrap; }
.brush button { margin: 0; padding: 0.2rem 0.55rem; font-size: 0.75rem; }
/* Orientation cluster in the placing HUD: rotate / mirror the art before dropping it,
   with a live swatch of how it'll land between the two rotate buttons. */
.orient { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0 0.2rem; border-left: 1px solid var(--line); border-right: 1px solid var(--line); }
/* Companion is mandatory: the author `display: inline-flex` above beats the UA `[hidden]`
   sheet, so the conditionally-armed clusters (×qty on a non-collectible, ↻respawn on a
   kind that can't respawn) would otherwise draw an empty control the placer then ignores. */
.orient[hidden] { display: none; }
.brush #brushQty { width: 5em; }
.brush button.icon { padding: 0.15rem 0.4rem; font-size: 0.95rem; line-height: 1; }
.brush button.icon.on { background: var(--accent); color: #1a1206; border-color: var(--accent); }
/* "done" dismisses the placer — the one action a mobile thumb reaches for most, so give it a
   real tap target instead of the teeny shared button size, and lift it so it reads as the
   primary out. On a phone it takes its own full-width row (see the 760px block below). */
.brush #brushDone {
  padding: 0.4rem 1.1rem;
  font-size: 0.85rem;
  font-weight: 600;
  border-color: var(--accent);
  color: var(--accent);
}
.brush #brushDone:hover { background: var(--accent); color: #1a1206; }
/* `display: grid` (not inline-block) is load-bearing: it blockifies the child, so an
   emoji `.glyph` span becomes transformable. A non-replaced inline element ignores
   `transform` entirely, so an inline glyph would silently drop the rotate/mirror (an
   <img> is replaced, hence transformable either way). This mirrors how world entities
   and player nodes blockify their glyph by being grid containers. place-items centres
   whichever art lands inside. */
.orient-preview { display: grid; place-items: center; width: 1.5rem; height: 1.5rem; flex: none; border-radius: 3px; background: rgba(255,255,255,0.04); }
.orient-preview .glyph { font-size: 1.15rem; }
/* The `hidden` attribute must win over the display above, or the chip shows for
   everyone with no live placement state (and can't be dismissed). */
.brush[hidden] { display: none; }

/* ---- version badge (subtle, bottom-right) ---- */
.version {
  position: fixed;
  right: 0.5rem;
  bottom: 0.4rem;
  color: var(--dim);
  font-size: 0.65rem;
  opacity: 0.5;
  user-select: none;
  z-index: 4;
  /* It's a <button> now (taps open the changelog) but should read as the same
     unobtrusive badge. Strip the native chrome and grow the hit target a touch. */
  background: none;
  border: 0;
  padding: 0.15rem 0.25rem;
  margin: 0;
  font: inherit;
  font-size: 0.65rem;
  cursor: pointer;
  transition: opacity 0.15s ease;
}
.version:hover,
.version:focus-visible {
  opacity: 0.95;
  color: var(--fg);
  outline: none;
}

/* ---- Changelog / "what's new" overlay ---- */
.changelog-overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: rgba(0, 0, 0, 0.6);
  -webkit-backdrop-filter: blur(2px); /* iOS Safari still needs the prefix */
  backdrop-filter: blur(2px);
}
.changelog-overlay[hidden] { display: none; }

.changelog-card {
  display: flex;
  flex-direction: column;
  width: min(640px, 100%);
  max-height: min(80dvh, 720px);
  background: var(--panel);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
  overflow: hidden;
}
.changelog-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.75rem 1rem;
  border-bottom: 1px solid var(--line);
}
.changelog-head h2 {
  margin: 0;
  font-size: 1rem;
  font-weight: 600;
}
.changelog-close {
  background: none;
  border: 0;
  color: var(--dim);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  padding: 0.25rem 0.4rem;
  border-radius: 6px;
}
.changelog-close:hover,
.changelog-close:focus-visible { color: var(--fg); background: rgba(255, 255, 255, 0.08); outline: none; }

.changelog-body {
  padding: 0.5rem 1.15rem 1rem;
  overflow-y: auto;
  line-height: 1.5;
  font-size: 0.85rem;
}
.changelog-body h3 { /* a version heading, e.g. "Latest (v0.4.12)" */
  margin: 1.1rem 0 0.1rem;
  font-size: 0.95rem;
  color: var(--accent);
  border-bottom: 1px solid var(--line);
  padding-bottom: 0.2rem;
}
.changelog-body h3:first-child { margin-top: 0.3rem; }
.changelog-body h4 { /* an Added/Fixed/Changed group */
  margin: 0.7rem 0 0.2rem;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--dim);
}
.changelog-body ul { margin: 0.1rem 0 0.4rem; padding-left: 1.1rem; }
.changelog-body li { margin: 0.28rem 0; }
.changelog-body strong { color: var(--fg); }
.changelog-body em { color: var(--dim); font-style: italic; }
.changelog-body code {
  font-family: ui-monospace, monospace;
  font-size: 0.85em;
  background: rgba(255, 255, 255, 0.08);
  padding: 0.05em 0.3em;
  border-radius: 4px;
}
.changelog-empty { color: var(--dim); padding: 1rem 0; }

.changelog-foot {
  padding: 0.6rem 1rem;
  border-top: 1px solid var(--line);
  text-align: center;
  font-size: 0.75rem;
  color: var(--dim);
}
.changelog-foot strong { color: var(--accent); font-weight: 600; }

/* Admin-only tile-coordinate readout, tucked just above the version badge. Kept
   in the same subtle key as .version but with tabular figures so the digits don't
   jitter as the player moves. */
.coords {
  position: fixed;
  right: 0.5rem;
  bottom: 1.3rem;
  color: var(--dim);
  font-size: 0.7rem;
  font-variant-numeric: tabular-nums;
  opacity: 0.6;
  pointer-events: none;
  user-select: none;
  z-index: 4;
}

/* ---- equipment (worn gear + hats) ---- */
/* A worn head item perched on the avatar (the equipment spike). It rides the animated
   .avatar-rig (NOT .avatar-art, whose overflow:hidden would clip the brim poking above
   the head), so it bobs/spins/squashes as part of the avatar, and gets the same face-left
   flip the glyph does, so the hat turns with the face it sits on. pointer-events off —
   a hat is never a click target. */
.player .hat {
  position: absolute;
  top: calc(var(--tile) * -0.28);
  left: 50%;
  transform: translateX(-50%);
  width: calc(var(--tile) * 0.55);
  height: calc(var(--tile) * 0.55);
  display: grid;
  place-items: center;
  font-size: calc(var(--tile) * 0.45);
  line-height: 1;
  pointer-events: none;
  z-index: 7;
}
.player .hat img { max-width: 100%; max-height: 100%; }
.player .hat.shape { width: calc(var(--tile) * 0.4); height: calc(var(--tile) * 0.28); border-radius: 3px; }
.player.face-left .hat { transform: translateX(-50%) scaleX(-1); }

/* The paper-doll: one row of worn-slot chips at the top of the bag (weapon included —
   Rob's one-row ruling). A filled chip is a button that takes the piece off. */
.equip-doll { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.8rem; }
.equip-slot {
  display: inline-flex; align-items: center; gap: 0.3rem;
  padding: 0.25rem 0.55rem;
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.04);
  color: inherit;
  font: inherit;
  font-size: 0.82rem;
  cursor: default;
  opacity: 0.55;
}
.equip-slot.filled { opacity: 1; cursor: pointer; border-color: rgba(140, 200, 140, 0.55); background: rgba(120, 190, 120, 0.12); }
.equip-slot.filled:hover { border-color: rgba(220, 150, 130, 0.7); background: rgba(220, 150, 130, 0.12); }
.equip-slot .eqglyph { font-size: 1rem; line-height: 1; }
.equip-slot .eqname { max-width: 9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ---- volatile per-level chat ---- */
/* A speech bubble over a speaker's head. It lives in the world layer (.grid) and is placed
   over the speaker's tile by render() (left/top in px) — NOT parented to the .player node,
   which is overflow:hidden and would clip a bubble reaching above the head (the same reason
   floaters and inspect labels live in .grid). The transform centres it on the tile and
   floats it just above the tile's top edge; it still pans with the world for free. */
.bubble {
  position: absolute;
  transform: translate(-50%, calc(-100% - 10px));
  width: max-content;
  max-width: calc(var(--tile) * 4);
  padding: 0.28rem 0.5rem;
  background: color-mix(in srgb, var(--panel) 92%, var(--accent));
  border: 1px solid var(--accent);
  border-radius: 9px;
  font-size: 0.62rem;
  line-height: 1.3;
  color: var(--fg);
  text-align: center;
  overflow-wrap: anywhere;
  box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5);
  opacity: 0;
  pointer-events: none;
  z-index: 30;
  transition: opacity 140ms ease, transform 140ms ease;
}
.bubble.show { opacity: 1; transform: translate(-50%, calc(-100% - 4px)); }
/* A little downward tail so the bubble reads as belonging to the head below it. */
.bubble::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 5px solid transparent;
  border-top-color: var(--accent);
}

/* The per-level log, low-left and out of the way. Lines fade in, linger, then fade out;
   the whole thing clears on a level change (chat is volatile — see game.js clearChat). */
.chatlog {
  position: fixed;
  left: 0.6rem;
  bottom: 3.4rem;
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  width: min(20rem, calc(100% - 1.2rem));
  pointer-events: none;
}
.chatline {
  align-self: flex-start;
  max-width: 100%;
  padding: 0.22rem 0.5rem;
  background: color-mix(in srgb, var(--panel) 82%, transparent);
  border: 1px solid var(--line);
  border-radius: 6px;
  font-size: 0.72rem;
  line-height: 1.35;
  color: var(--fg);
  overflow-wrap: anywhere;
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 250ms ease, transform 250ms ease;
}
.chatline.show { opacity: 0.92; transform: translateY(0); }
.chatline b { color: var(--accent); }
/* A server notice (a death announcement) — italic, dimmer, no accent name, so it reads as the
   world speaking rather than a player. */
.chatline.sys { font-style: italic; color: var(--dim); border-style: dashed; }
/* Admin-command output — private lines from the /api/say response body (never broadcast).
   Monospace + solid border so the console's answer reads apart from world chatter. */
.chatline.sys.cmd { font-style: normal; font-family: ui-monospace, monospace; border-style: solid; }

/* The !select region rectangle — an overlay in the world layer (so it pans with the
   camera), marching-dash border over a faint accent wash. Decoration only. */
#selrect {
  position: absolute;
  pointer-events: none;
  border: 2px dashed var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  z-index: 40;
}
/* While !select is armed, the map cursor says "draw here". */
#viewport.selecting { cursor: crosshair; }

/* The say box — summoned by T / the 🗨️ button, centred low like the sign panel. */
.chatbar {
  position: fixed;
  left: 50%;
  bottom: calc(1rem + env(safe-area-inset-bottom, 0px)); /* clear the iPhone home indicator */
  transform: translateX(-50%);
  z-index: 58;
  width: min(28rem, calc(100% - 1.5rem));
  margin: 0;
  display: flex;
  gap: 0.4rem;
}
.chatbar[hidden] { display: none; }
/* The ➤ send button: only where there's no Enter/Esc to lean on (see the hover:none query
   below) — on desktop the bar stays a bare input. */
.chat-send {
  display: none;
  margin: 0;
  padding: 0 0.9rem;
  border: 1px solid var(--accent);
  border-radius: 8px;
  background: var(--panel);
  color: var(--accent);
  font-size: 1rem;
  cursor: pointer;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
@media (hover: none) {
  .chat-send { display: inline-flex; align-items: center; justify-content: center; }
  /* The soft keyboard's top edge shaves the bar's bottom few pixels at 1rem (Rob, twice) —
     ride a bit higher where a keyboard exists. Desktop keeps the low seat. */
  .chatbar { bottom: calc(2.25rem + env(safe-area-inset-bottom, 0px)); }
}
.chatbar input {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  padding: 0.55rem 0.75rem;
  background: var(--panel);
  border: 1px solid var(--accent);
  border-radius: 8px;
  color: var(--fg);
  font-family: inherit;
  font-size: 0.85rem;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
.chatbar input:focus { outline: none; border-color: var(--accent); }

/* The touch affordance for the say box. Hidden on desktop (T covers it); shown on
   narrow screens by the media query below. */
.chat-fab {
  position: fixed;
  right: calc(0.6rem + env(safe-area-inset-right, 0px));
  bottom: calc(2.4rem + env(safe-area-inset-bottom, 0px)); /* clear the iPhone home indicator */
  /* BELOW the whole panel/dialog family (bag 54, sign 55, chest/NPC 56, reader 57): a fab
     poking through an open panel reads as broken chrome (Rob, 2026-07-13 — the bag fab was
     floating over the full-screen inventory). Close the panel and the fab is back. Still
     above the hotbar (40) and the chat log (50), and under the say box (58), which replaces
     the chat fab while open. */
  z-index: 53;
  display: none;
  width: 2.6rem;
  height: 2.6rem;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--panel);
  color: var(--fg);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
}
body.controls-swapped .chat-fab { right: auto; left: 0.6rem; }
/* The bag fab rides one slot above the chat fab (same size/skin via .chat-fab). */
.bag-fab { bottom: calc(5.4rem + env(safe-area-inset-bottom, 0px)); }
/* With the fabs swapped to the left, the chat log's home column is occupied — the fabs
   (0.6rem in, 2.6rem wide, stacked right where the log's lines appear) would sit on top
   of the text. Shift the log right of the fab column into the open space. Scoped to the
   fab-visible breakpoint below; on desktop the fabs are hidden and the log stays put. */
@media (max-width: 760px) {
  body.controls-swapped .chatlog {
    left: 3.7rem;
    width: min(20rem, calc(100% - 4.3rem));
  }
}

/* ---- mobile / narrow screens ----
   The HUD's action buttons don't fit in one row on a phone, so they collapse behind
   the hamburger (#hudToggle) into a dropdown; and dialogs go near-full-width so their
   controls stop spilling off the edge. */
@media (max-width: 760px) {
  .chat-fab { display: inline-flex; align-items: center; justify-content: center; }
  body { overflow-x: hidden; } /* belt-and-suspenders against any stray sideways scroll */

  .hud { gap: 0.6rem; }
  .hud-toggle { display: inline-flex; align-items: center; justify-content: center; }
  .hud-actions {
    position: absolute;
    top: calc(100% + 1px);
    right: 0.4rem;
    display: none;
    flex-direction: column;
    align-items: stretch;
    gap: 0.4rem;
    min-width: 12rem;
    max-width: calc(100vw - 0.8rem);
    padding: 0.5rem;
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: 0 0 8px 8px;
    box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45);
    z-index: 20;
  }
  .hud.menu-open .hud-actions { display: flex; }
  /* On a phone there's one menu, not a menu inside a menu: both groups go `display: contents`
     so their buttons flatten into the single ☰ column. .hud-more drops its desktop dropdown
     framing here — .hud-actions is already the dropdown. */
  .hud-primary,
  .hud-more,
  .hud.menu-open .hud-more {
    display: contents;
  }
  /* Every visible item in the open menu is a full-width, left-aligned tap target.
     The :not([hidden]) guard is load-bearing: forcing display here would otherwise
     override the `hidden` attribute and reveal admin-only buttons to everyone.
     NOTE: descendant, not child — `display: contents` flattens the RENDERED tree, but
     selectors still match the DOM, where the buttons are grandchildren of .hud-actions. */
  .hud-actions .linkbtn:not([hidden]),
  .hud-actions label.linkbtn {
    display: block;
    width: 100%;
    margin: 0;
    text-align: left;
    padding: 0.5rem 0.6rem;
  }
  .hud-actions label.linkbtn {
    border: 1px solid var(--line);
    border-radius: 6px;
    color: var(--fg);
  }
  /* The 🔨 tools flatten in too — they're already gated by updateBuildUI + the toggle. */
  .build-tools { display: contents; }
  .build-tools[hidden] { display: none; }

  /* Dialogs use nearly the whole width so nothing clips off the side. */
  .dialog {
    width: calc(100vw - 1rem);
    max-width: calc(100vw - 1rem);
    padding: 1.1rem;
  }
  .dialog-row { flex-wrap: wrap; }

  /* Placing HUD on a phone: tighter gaps for the wrapped rows, and a slimmer
     stack-count field so a row of controls fits without spilling. The verbose
     "— click a tile" hint is dropped — the crosshair cursor + toast already say so —
     and the orient/qty/respawn clusters shed their side borders to claw back width. */
  .brush { gap: 0.4rem 0.5rem; padding: 0.4rem 0.6rem; }
  .brush #brushQty { width: 3.5em; }
  .brush-hint { display: none; }
  .orient { border-left: none; border-right: none; padding: 0; }
  /* Give "done" its own full-width row at the bottom of the wrapped placer so it's a fat,
     unmissable bar — no more hunting for a teeny button crammed next to the orient cluster. */
  .brush #brushDone { flex-basis: 100%; padding: 0.5rem; }

  /* Dense authoring rows already wrap at every width (see base .grant-row); on a phone,
     also give their fixed-width number fields a smaller footprint so a row of them fits
     within ~360px. This is the recurring "goes off the side" culprit. */
  .door-coords,
  .dlg-choice-main,
  .dlg-node-head { flex-wrap: wrap; }
  .door-coords input,
  .grant-row input[type="number"],
  .pe-stats input { width: 4.25rem; }
  .dlg-node-id { width: 100%; }

  .dialog input,
  .dialog textarea { max-width: 100%; }
  /* The tile-palette filter row: let the type dropdown wrap under the name box rather
     than squeeze both onto one line. */
  .palette-filters { flex-wrap: wrap; }
  .palette-filters #paletteKind { max-width: none; flex: 1 1 100%; }
}

/* ---- the admin hub (docs/plans/editor-cleanup.md, slice 1a) ----
   One dialog, one tab per authoring lane. It's the widest thing in the app (the user list
   and the recipe editor both want room), so it gets a roomier ceiling than a stock dialog
   and scrolls internally rather than growing the page. */
.admin-dialog { max-width: min(56rem, 94vw); }
.admin-tabs { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin: 0 0 0.75rem; }
.admin-tabs .spacer { flex: 1; }
/* The panes carry `hidden` and are toggled by showAdminTab. Do NOT give .admin-pane a
   `display` here without this companion: an author rule beats the UA `[hidden]` sheet, which
   is exactly how #indoorRow drew on all 8 kinds for a month (fixed 0.7.18). */
.admin-pane[hidden] { display: none; }
.admin-pane > h2:first-child { margin-top: 0; } /* the tabstrip is the pane's real heading */

/* ---- the prizes tab (docs/plans/editor-cleanup.md, slice 2) ----
   The whole drop table as one list. Rarity colours reuse the gacha palette (--rarity above). */
.prize-list { display: flex; flex-direction: column; gap: 0.35rem; margin: 0.5rem 0 1rem; max-height: 20rem; overflow: auto; }
.prize-row {
  display: flex; align-items: center; gap: 0.6rem; width: 100%;
  padding: 0.4rem 0.6rem; margin: 0; text-align: left;
  background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
  color: var(--fg); font: inherit; cursor: pointer;
}
.prize-row:hover { border-color: var(--accent); }
.prize-glyph { font-size: 1.2rem; line-height: 1; width: 1.4rem; text-align: center; flex: 0 0 auto; }
.prize-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prize-tags { display: flex; gap: 0.4rem; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; }
.prize-rarity, .prize-award {
  font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em;
  padding: 0.12rem 0.4rem; border-radius: 999px; white-space: nowrap;
}
.prize-award { color: var(--dim); border: 1px solid var(--line); }
.prize-rarity { color: #fff; }
.prize-rarity.r-common    { background: #9e9e9e; }
.prize-rarity.r-uncommon  { background: #4caf50; }
.prize-rarity.r-rare      { background: #2196f3; }
.prize-rarity.r-epic      { background: #9c27b0; }
.prize-rarity.r-legendary { background: #ff9800; }
/* On a phone the tags drop under the name so a long name never shoves them off the panel. */
@media (max-width: 560px) {
  .prize-row { flex-wrap: wrap; }
  .prize-name { flex-basis: calc(100% - 2rem); }
  .prize-tags { justify-content: flex-start; }
}

/* ---- sound picker + designer (docs/plans/editor-cleanup.md, slice 3) ---- */
/* A sound field is a name dropdown + a ▶ preview, in one row. */
.sound-pick { display: flex; gap: 0.4rem; align-items: center; }
.sound-pick select { flex: 1 1 auto; min-width: 0; }
.sound-preview { flex: 0 0 auto; padding: 0.25rem 0.55rem; }
/* The designer's library list: name (click to edit) + preview + delete per row. */
.sound-list { display: flex; flex-direction: column; gap: 0.35rem; margin: 0.5rem 0 1rem; max-height: 16rem; overflow: auto; }
.sound-row { display: flex; align-items: center; gap: 0.4rem; }
.sound-row .sound-name {
  flex: 1 1 auto; min-width: 0; text-align: left; margin: 0;
  padding: 0.4rem 0.6rem; background: var(--bg); border: 1px solid var(--line);
  border-radius: 6px; color: var(--fg); font: inherit; cursor: pointer;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sound-row .sound-name:hover { border-color: var(--accent); }
.sound-row .sound-play, .sound-row .sound-del { flex: 0 0 auto; padding: 0.35rem 0.5rem; }
