/* ═══ Variables ═══════════════════════════════════════════════════════════════ */
:root {
  --u:            calc(min(100vw, 1000px) / 1000);
  --panel-dark:   #643e08;
  --panel-bg:     #d6b067;
  --panel-border: #ab6e15;
  --panel-rim:    #391e01;
  --paper-bg:     #f2e3c6;
  --text:         #2e1b10;
  --text-muted:   rgba(46,27,16,.6);
  --btn-grad:     linear-gradient(180deg, rgba(250,235,200,.9), rgba(214,176,103,.9));
  --btn-border:   rgba(80,46,2,.7);
  --hair:         1px;
  --ui-font:      clamp(7px, 2.8vw, 28px);
  --msg-info:     #2c5f8a;
  --msg-success:  #1f5f2e;
  --msg-warning:  #7a4a0c;
  --msg-error:    #7a1b0c;
  --font-fantasy: 'Philosopher', Georgia, serif;
}
@media (-webkit-min-device-pixel-ratio:2),(min-resolution:2dppx) { :root { --hair:.5px } }

/* ═══ Base ════════════════════════════════════════════════════════════════════ */
/* touch-action:manipulation suppresses double-tap-zoom and pinch-zoom at the
   browser level (Chrome/Android honors this fully; iOS Safari's double-tap
   gesture needs the JS touchend guard in main.js as a backstop — see there).
   `manipulation` (not `none`) so ordinary taps/clicks stay snappy everywhere
   that doesn't already opt out of native touch handling itself. */
html, body { touch-action: manipulation; }
body {
  /* `height`, not just `min-height`. min-height alone leaves the flex chain's
     main size INDEFINITE: flex:1 then has nothing finite to distribute, so
     .game-wrapper/#main-wrapper grow to fit their content instead of being
     bounded by the window — and their content includes the explicit px square
     map.js's sizeMapViewport() writes onto #room-map-wrap. That is how a
     720px window ended up with a 1438px page, the header and footer scrolled
     off, and a map whose size depended on whether the first measurement
     happened before or after layout settled (fresh login 1000px, reload
     334px). min-height:0 on the two wrappers is necessary but not sufficient:
     it lets them shrink, this is what gives them something to shrink TO.
     Stated as dvh only — a browser without it keeps the old behaviour rather
     than getting 100vh, which is the mobile URL-bar trap the dvh unit exists
     to avoid. body already sets overflow:hidden, so the page was never meant
     to scroll in the first place. */
  display:flex; flex-direction:column; min-height:100vh; min-height:100dvh; height:100dvh; overflow:hidden;
  font-family:Georgia,serif; margin:0;
  background:url('img/ui/background.webp') center/cover no-repeat;
}
/* Mobile portrait: the header + square map are opaque and together occupy
   almost the whole viewport, so a full-height background-size:cover zooms
   the image in drastically to fill 100dvh and only a tiny, oddly-cropped
   sliver shows through the gap between them. Bound the image to the top
   half of the screen instead — from the very top (behind the header, which
   covers it) down to the vertical mid-point — well short of the map, which
   starts much lower (.room-map-viewport's top sits around 100vh - 111.91vw,
   see prior revisions of this comment for that derivation). */
@media (max-width:767px) and (orientation:portrait) {
  body {
    background-size: auto 50vh;
    background-position: top center;
    /* background-size only shrinks the IMAGE layer — below it (past 50vh)
       there's no pixel left to darken at all, so body::before's
       semi-transparent gradient was compositing over the page's blank
       white canvas there instead of something dark, reading as a washed-
       out, muddy brown instead of a solid one. A solid background-color
       fills that gap; the image layer still paints over it wherever it's
       actually present (CSS draws background-image above background-color
       within the same element), so this only ever shows below the image.
       Matches the gradient's own darkest stop (rgb(35,18,1) below) so the
       two read as one continuous color, not two different browns. */
    background-color: rgb(35,18,1);
  }
  /* Fades the image into darkness instead of cutting off with a hard edge,
     staying dark the rest of the way to the bottom of the screen. The fade
     itself is a short, snappy 50px transition anchored right at the
     image's own bottom edge (50vh, matching background-size above) rather
     than a long gradual blend — transparent up to 50px before that edge,
     then rgb(35,18,1) (a shade darker than --panel-rim/#391e01, the
     footer's own darkest tone, so this reads clearly darker than the
     footer rather than matching or falling short of it; still a dark brown
     rather than black/near-black, to avoid the flat, ugly look pure black
     read as earlier) for the long flat stretch after. A second, much
     shorter 5px transition right at the very bottom edge of the screen
     deepens it slightly further, for a subtle extra vignette at the edge
     rather than a hard-stopped flat color.
     Deliberately NO z-index here (stays default auto) — this sits BEHIND
     the map, not over it: `.room-map-wrap`/`#main-wrapper` are
     `position:relative` with z-index:auto, and any positioned element later
     in DOM tree order paints above an earlier-in-tree z-index:auto sibling
     regardless of this overlay's own alpha, so a fixed overlay placed as
     body's first child (no explicit z-index) naturally loses to the map —
     it only shows through wherever the map/header/footer are actually
     transparent (the header-to-map gap, and the map's own transparent
     picture-frame margin — .room-map-wrap/.room-map-viewport/.room-map are
     all background:transparent), leaving the map's own tiles and the
     footer at full, undarkened brightness. pointer-events:none since it's
     purely decorative. */
  body::before {
    content:"";
    position:fixed;
    inset:0;
    pointer-events:none;
    background:linear-gradient(to bottom,
      transparent 0,
      transparent calc(50vh - 50px),
      rgba(35,18,1,.9) 50vh,
      rgba(35,18,1,.9) calc(100% - 5px),
      rgba(25,13,1,.95) 100%
    );
  }
}
/* min-height:0 is load-bearing, not tidiness. A column flex item defaults to
   min-height:auto, i.e. "never shrink below my content" — and map.js's
   sizeMapViewport() writes an explicit px square onto #room-map-wrap. Without
   this, that written square becomes content this chain refuses to shrink
   below, so it pushes `body` past 100dvh, the header and footer scroll off
   screen, and the next sizeMapViewport() measures the inflated container and
   grows again. Which end of that loop you land on depends on whether the
   first measurement happened before or after the layout settled: a fresh
   login came out at a 1000px map on a 1438px page, a reload at a 334px map on
   a correct 720px page. Same DOM, same CSS, two different answers.
   .room-map-wrap's own comment describes dodging exactly this circularity by
   giving the wrap no CSS size — that works only if the chain above it can
   actually shrink. */
.game-wrapper { display:flex; flex-direction:column; flex:1; min-height:0; width:100%; max-width:1000px; margin:calc(6*var(--u)) auto }
.hidden { display:none !important }

/* ═══ Utilities ═══════════════════════════════════════════════════════════════ */
.text-truncate { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.flex-center   { display:flex; align-items:center; justify-content:center }
.flex-col      { display:flex; flex-direction:column }
.text-muted    { color:var(--text-muted) }
.btn-gold      { background:var(--btn-grad); color:var(--text); font-weight:600; cursor:pointer }

/* ═══ Panel system — two visual shells, everything reuses these ═══════════════ */

/* Dark outer shell — wraps header columns, footer */
.panel-wrap {
  position:relative; min-width:0; background:var(--panel-dark); color:var(--text);
  border:var(--hair) solid var(--panel-border); outline:calc(5*var(--u)) solid var(--panel-rim);
  border-radius:calc(5*var(--u)); padding:calc(8*var(--u));
}
/* Golden parchment — every content box: header grid, footer nav, inventory,
   shop, dialogs, activity panel, game screen, roster cards, auth cards … */
.panel-grid {
  background:var(--panel-bg); color:var(--text); overflow:hidden;
  border:calc(5*var(--u)) solid var(--panel-rim); outline:var(--hair) solid var(--panel-border);
  border-radius:calc(20*var(--u)); box-shadow:inset calc(2*var(--u)) calc(5*var(--u)) var(--hair) var(--hair) rgba(0,0,0,.3);
}
/* Reusable fix for .panel-wrap placed inside an overflow:hidden ancestor
   (its outline would get clipped invisible there) — swap the outline for an
   equivalent border. Any component in that situation can reuse this. */
.panel-wrap--overflow-safe { outline:none; border:calc(5*var(--u)) solid var(--panel-rim) }

/* ═══ Header ══════════════════════════════════════════════════════════════════ */
header { display:flex; flex-direction:column; font-size:var(--ui-font); width:100% }
.header-top  { display:grid; grid-template-columns:minmax(0,4.5fr) 1fr; align-items:start; gap:calc(12*var(--u)) }
.header-left { display:flex; flex-direction:column; gap:calc(6*var(--u)); min-width:0 }
.header-stats-row { display:flex; align-items:stretch; gap:calc(12*var(--u)); min-width:0 }
.header-stats-row > .panel-wrap:first-child { flex:1 1 auto; min-width:0 }
/* Fixed width, stretched to the stats-grid height (align-items:stretch on the row). */
.avatar-wrap { flex:0 0 auto; width:calc(160*var(--u)); align-self:stretch }

/* Grid / cell system — shared by header stats and footer nav */
.stats-grid    { display:grid; grid-template-columns:repeat(3,1fr) }
.resource-grid { display:flex; flex-direction:column }
.resource-grid.grid .cell { border-width:0 0 var(--hair) 0 }
.grid .cell {
  aspect-ratio:5/2; display:flex; align-items:center; justify-content:center; overflow:hidden;
  border:var(--hair) solid var(--panel-border); border-width:0 var(--hair) var(--hair) 0;
}
.grid .cell:nth-child(3n)  { border-right:0 }
.grid .cell:nth-child(n+4) { border-bottom:0 }
.grid .cell img { width:1.1rem; height:1.1rem; border-radius:50% }
.cell__stat { max-width:100%; padding:0 calc(4*var(--u)) }

.avatar {
  position:relative; width:100%; height:100%; background:#39431b; border-radius:calc(24*var(--u)); margin:0;
  box-shadow:
    inset calc(2*var(--u)) calc(5*var(--u)) var(--hair) var(--hair) rgba(0,0,0,.3),
    0 0 0 calc(2*var(--u)) var(--panel-border), 0 0 0 calc(4*var(--u)) #502e02,
    0 0 0 calc(5*var(--u)) #000, 0 0 0 calc(6*var(--u)) var(--panel-border);
}
/* Absolutely positioned so the (large) image never inflates the row height —
   the avatar conforms to the stats-grid height via height:100% + aspect-ratio. */
.avatar img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; border-radius:calc(24*var(--u)); border:var(--hair) solid #000 }

/* ═══ Layout helpers ══════════════════════════════════════════════════════════ */
/* min-height:0 for the same reason as .game-wrapper above — both links of the
   chain need it or the one without it still refuses to shrink.
   align-items:center centres the JS-sized square on the cross (horizontal)
   axis: the square is width-bounded only on tall/narrow viewports, so on a
   wide short one it is much narrower than this 1000px column and was sitting
   flush left with all the slack dumped on one side. (An explicit inline width
   from sizeMapViewport defeats the default align-items:stretch, so this has
   to be said out loud.) */
#main-wrapper { display:flex; flex:1; min-height:0; flex-direction:column; overflow:hidden; position:relative; justify-content:flex-end; align-items:center }
/* The map is now the only persistent content between header and footer —
   activity panels render into the anchored #work-popover instead (see
   work-popover.js), and the personal-room screen renders into modals
   (see #personal-room-modal, updateUIForRoom in main.js).
   #main stays in the DOM (harmless — nothing populates it anymore) but takes
   no layout space (display:none below), so #room-map-wrap's flex-grow claims
   the full #main-wrapper height with no sibling left to share it with. */
#main         { display:none }
.push-to-bottom { margin:auto }
.route-spacer   { flex:1 1 auto; min-height:0; overflow:auto }

/* ═══ Room map — persistent, sits above the footer on every room screen ══════ */
.room-map-wrap {
  /* --map-viewport-inset: picture-frame margin left around
     .room-map-viewport (below) before the tile grid starts — remeasured off
     the reference mockup (map-example.png): ~30px margin on an ~850px-wide
     view ≈ 3.53% (corrects an earlier ~50px/5.9% mismeasurement). Applied on
     the *viewport* child, not here, because this element hosts .room-map's
     position:absolute children (padding here wouldn't inset them — see
     .room-map-viewport's own comment). */
  --map-viewport-inset: 3.53%;
  /* Fixed square viewport (see map.js's computeTileSize comment), sized
     entirely in JS (map.js's sizeMapViewport sets explicit
     inline width/height in px) rather than via CSS aspect-ratio/flex-grow —
     both were tried and both fought this layout back when `body` used only
     min-height:100vh, so nothing down the flex chain had a definite height
     for a CSS percentage max-height to resolve against; body now sets
     height:100dvh (see there for why), so that particular objection no longer
     holds — the JS sizing is kept because it is what is here and works, not
     because CSS could not do it;
     and a `width:100%;aspect-ratio:1` pre-JS default, while waiting for the
     first JS layout pass, is itself tall enough to inflate that same
     min-height:100vh body/chain before JS ever gets to measure it — so JS's
     "available space" reading becomes circular, matching whatever the CSS
     default already inflated things to. Deliberately no size here at all —
     defaults to auto/content-sized (~0, its only children are
     position:absolute) so #main-wrapper's real, unpadded flex:1 height is
     what JS measures. #main-wrapper's justify-content:center centers the
     (JS-sized) square in the leftover header-to-footer space. */
  display:flex; align-items:center; justify-content:center;
  /* Without this, the grid child's JS-set explicit width/height (fitMapGrid,
     map.js) could feed back into the wrap's own size (flex-basis + a sized
     child isn't fully decoupled from content in this nested-flex chain),
     which combined with the ResizeObserver watching this element created a
     runaway resize loop that grew the map to fill the viewport. contain:size
     makes the wrap's size strictly independent of its children, so it can
     only ever come from its own flex-basis. */
  contain:size;
  background:transparent;
  /* Viewport onto a grid that's sized/positioned in JS (map.js,
     computeMapBase/applyMapTransform) so the character's tile always
     renders dead-center — anything beyond the fixed MAP_CORE_TILES
     neighborhood (plus its thin MAP_PEEK_FRACTION peek margin) around it is
     cropped here, reachable by drag/pinch panning. The actual crop bounds
     live on .room-map-viewport below, inset from this element's own edges. */
  position:relative; overflow:hidden;
  /* Pan (drag/touch) is hand-rolled in map.js for both mouse and touch, not
     native touch scrolling — see .room-map-viewport's touch-action for why. */
  cursor:grab;
}
.room-map-wrap--panning { cursor:grabbing }
.room-map-viewport {
  /* The actual crop/pan window — everything map.js measures for tile sizing
     and character-centering (computeTileSize, centerViewportOnCharacter,
     layoutFocusedGrid) reads THIS element's box, not #room-map-wrap's.
     Inset from the wrap's edges by --map-viewport-inset on every side,
     giving the picture-frame margin seen in the mockup. Not
     position:absolute, so #room-map-wrap's own
     display:flex;align-items:center;justify-content:center centers it with
     no extra math needed.
     Scrolling itself is native (overflow:auto — .room-map below is
     position:absolute and larger than this box, which is enough for it to
     become this element's scrollable overflow), but WHEN it scrolls is
     driven entirely by map.js's pointer/touch handlers writing
     scrollLeft/scrollTop directly, not by the browser's own touch-drag
     gesture recognizer — touch-action:none opts out of that (tried letting
     native touch scrolling own single-finger panning first, but mobile
     browsers axis-lock a native pan gesture to whichever direction moved
     more initially, which felt fine straight but rough/sticky on a
     diagonal drag; computing the pan ourselves avoids that). Programmatic
     scrollLeft/scrollTop writes are still auto-clamped by the browser
     regardless of touch-action, so map.js still doesn't need any manual
     pan-bounds math. The scrollbar itself is hidden below (cosmetic only)
     since this reads as a game viewport, not a document. */
  position:relative; overflow:auto;
  touch-action:none;
  scrollbar-width:none;
  width:calc(100% - 2 * var(--map-viewport-inset));
  height:calc(100% - 2 * var(--map-viewport-inset));
}
.room-map-viewport::-webkit-scrollbar { display:none; }
.room-map {
  display:grid;
  /* Fixed pixel tracks (map.js sets --tile-size from the inset viewport's
     measured width, times the current zoom factor, so MAP_CORE_TILES
     columns + gaps fill most of it, minus a small peek margin — see
     computeTileSize/applyTileSize), not 1fr — 1fr sizes tracks to fill the
     wrap, which is exactly what must NOT happen here: the grid needs to be
     free to be larger than the viewport so tiles beyond the centered core
     are cropped (now: scrolled past) instead of shrunk. Zoom growing/
     shrinking --tile-size is also what grows/shrinks this element's real
     box size, which is what makes it natively scrollable in the viewport
     above — a CSS transform:scale() would not do that (transform doesn't
     affect layout size), which is why zoom is applied this way instead. */
  grid-template-columns:repeat(var(--map-cols,3),var(--tile-size,1fr));
  grid-template-rows:repeat(var(--map-rows,3),var(--tile-size,1fr));
  gap:var(--map-gap, 1px);
  position:absolute; top:0; left:0;
  /* Fully transparent — the room/world background shows straight through;
     only the tiles themselves carry a background. */
  background:transparent;
}
/* Applied only during a live pinch (map.js onMapTouchStart/End), while the
   grid is scaled via transform instead of --tile-size (see the comment
   above). Confirmed via CDP tracing that a transform:scale() animation over
   a large range (up to MAP_MAX_ZOOM=6x) makes Chromium's compositor
   continuously re-rasterize each tile's painted content — background image,
   border, box-shadow, text/emoji — at every new effective scale to keep it
   sharp, the same way it would for a real page-zoom; this is independent of
   *what* the content is (image-rendering:pixelated does NOT suppress it —
   tried first, measured no change) and costs real worker-thread time every
   frame regardless of the --tile-size fix. Stripping every tile down to its
   flat background-color fallback (already declared on .room-map__node) for
   the gesture's duration removes everything Chromium would otherwise treat
   as needing re-sharpening — cut raster time ~60% and eliminated image-decode
   entirely in testing. `!important` is needed because map.js sets
   background-image/border/box-shadow inline per tile. Full detail snaps back
   the instant the class is removed at commit (onMapTouchEnd), so this never
   affects the resting view — only the ~1s of active pinching itself. */
.room-map--zoom-live .room-map__node {
  background-image:none !important;
  border-color:transparent !important;
  box-shadow:none !important;
}
.room-map--zoom-live .room-map__node > * { visibility:hidden !important }
/* The character avatar isn't part of what the rule above optimizes for
   (it's a small already-decoded <img>, not a re-rasterized tile background)
   — hiding it just reads as the character vanishing mid-gesture, so keep it
   visible through the pinch. */
.room-map--zoom-live .room-map__node > .room-map__avatar-slot {
  visibility: visible !important;
}

/* "Far zoom" overview mode — toggled by map.js's updateZoomTierClasses once
   the effective tile size drops below MAP_ZOOM_FAR_TILE_PX (96px). Below that
   size the marker/avatar clamp() min-size floors (32-36px, see
   .room-map__marker and .room-map__avatar below) would otherwise overlap,
   dominate a tile far smaller than they are, and — for markers, which are
   anchored at 17% of the tile — spill over the tile edge, where the next
   grid item paints over them. Instead of shrinking further past legibility
   they simply hide, leaving only each tile's background color as a
   simplified overview swatch. Snaps back the instant the class is removed,
   same as .room-map--zoom-live above. */
.room-map--zoom-far .room-map__markers,
.room-map--zoom-far .room-map__avatar-slot,
.room-map--zoom-far .room-map__node-body {
  display:none;
}

/* Past MAP_ZOOM_NEAR_TILE_PX effective tile size (map.js), every tile swaps
   from its thumbnail to its full-resolution source in one go — see the base
   .room-map__node rule below for --bg-thumb/--bg-full, set per node by
   buildNodeElement. */
.room-map--zoom-near .room-map__node {
  background-image:var(--bg-full, none);
}

.room-map__node {
  position:relative; display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
  padding-top:calc(8*var(--u)); gap:calc(2*var(--u));
  min-width:0; border-radius:calc(2*var(--u));
  /* Neutral fallback for kinds with no dedicated fill below (watch/dungeon).
     background-color, not the background shorthand — see the per-kind rules
     below for why (shorthand would reset background-size/position too). */
  background-color:rgba(90,72,50,.4);
  background-image:var(--bg-thumb, none);
  background-size:cover;
  background-position:center;
  border:.5px solid rgba(255,217,97,.35);
  box-shadow:inset 0 0 calc(6*var(--u)) rgba(0,0,0,.25);
  transition:opacity .15s ease;
}
/* Every node renders at exactly one grid cell (map.js pins grid-column/row
   span to 1 regardless of the node's logical footprint), so all tiles read
   as the same size no matter how much of the underlying grid a room
   actually occupies. */

/* Per-kind flat-color fallback, shown until/unless the node's --bg-thumb/
   --bg-full custom properties resolve to a real image (see buildNodeElement)
   — e.g. field tiles use one of 4 grass textures, chosen per cell
   deterministically by world address, falling back to this flat green for
   reserved/placeholder tiles that have no background yet. */
/* background-color, not the background shorthand — the shorthand resets
   background-size/position/repeat to their initial values too, which would
   silently undo the base rule's background-size:cover and break the
   background-image the custom properties above resolve to (texture would
   tile at its native resolution instead of filling the tile). */
.room-map__node--field {
  background-color:#6fa050;
}
.room-map__node--city {
  background-color:#8a7d6a;
}
.room-map__node--mine {
  background-color:#574f45;
}
.room-map__node--cellar {
  background-color:#3d4a30;
}
.room-map__node--forest {
  background-color:#3a5a2e;
}
.room-map__node--home {
  background-color:#6b4429;
}
.room-map__node--watch {
  background-color:#3a4452;
}

/* background-color:transparent wins over the base rule and the per-kind
   rules above (equal specificity, declared later) — a true grid hole (no
   background-image set) then renders as nothing at all, while a fogged-but-
   real room (also --reserved, but always carrying a real inline
   background-image from map.js) is unaffected, since the image paints over
   this regardless. */
.room-map__node--reserved { opacity:.65; filter:grayscale(.6); background-color:transparent }

/* Background-only tiles (template-map-node-minimal): reserved/empty cells,
   and live rooms beyond distance 1 of the player (see map.js's
   isMinimalNode). No icon/label/marker/avatar children at all, so none of
   the base rule's padding/border/box-shadow/gap read as anything but a flat
   swatch. */
/* Undetailed, but not inert: a live minimal tile still opens its popup and
   can be walked to (js/map.js's isTravelableNode), hence the pointer. */
.room-map__node--minimal { padding:0; gap:0; border:none; box-shadow:none }
.room-map__node--minimal:not(.room-map__node--reserved) { cursor:pointer }

/* CSS Grid paints/orders grid items by their OWN z-index (not a nested
   descendant's), so anything a tile draws outside its own box is painted over
   by whichever sibling tile comes later in the grid. At rest nothing should
   overflow — MAP_ZOOM_FAR_TILE_PX (map.js) hides markers before they can —
   but these states deliberately draw past the tile edge: the city tile stacks
   several entrance markers, hover grows a marker by scale(1.12), and the
   --current/--target-selected rims and the selected marker's glow all sit on
   the border itself. Lift those tiles so the overflow stays visible, and
   keeps its clicks.
   Note this is the tile's own z-index, not .room-map__markers' — a
   descendant's z-index cannot lift the marker past a sibling grid item. */
.room-map__node--city,
.room-map__node--current,
.room-map__node--target-selected,
.room-map__node:hover,
.room-map__node:focus-within { z-index:5 }

.room-map__node--reachable, .room-map__node--toggle { cursor:pointer }
/* "Locked" means "not next door", not "not available": since every visible
   tile can be walked to in one tap (js/map.js's tile click → «Идти сюда»),
   it keeps the pointer and only reads as further away. */
.room-map__node--locked { opacity:.75; filter:grayscale(.35); cursor:pointer }
/* Safe zone (node.safe — the server's resolved isRoomSafe): a green rim with a
   soft inner glow. A border and an inset shadow, never an outline or outer
   shadow — #main-wrapper's overflow:hidden clips anything past the border box.
   Declared after --minimal and before --current/--target-selected, so it shows
   on far tiles too (the zone reads as an area) but the "you are here" and
   "destination" rims still win on the tiles they mark. */
.room-map__node--safe {
  border:calc(3*var(--u)) solid rgba(196,255,184,.9);
  box-shadow:inset 0 0 calc(14*var(--u)) calc(3*var(--u)) rgba(210,255,200,.55);
}
.room-map__node--current { border:calc(2*var(--u)) solid #ffd961 }
/* The room a clicked far-away object lives in — deliberately subtler than
   --current (thin bronze, matching .room-map__marker--current's weight)
   so "you are here" and "you selected this as a destination" stay
   visually distinct. */
.room-map__node--target-selected { border:var(--hair) solid #b87333 }

/* Focused sub-view — a single "you are here" tile, shown while inside a
   pocket location with no grid tile of its own (mine/watch/forest). Square,
   same as every other tile — no separate rectangular sizing here; the base
   .room-map rule already computes 1/1 from --map-cols/--map-rows (both set
   to 1 by buildFocusedGrid). */
.room-map__marker--exit { background:#b87333; border-color:#eed593 }

.room-map__node-body {
  display:flex; flex-direction:column; align-items:center; gap:calc(1*var(--u)); pointer-events:none;
  /* Tiles now carry real (often dark) textures instead of transparent —
     override the page's dark-brown body text so icon/label stay legible. */
  color:#f5ead0; text-shadow:0 1px 2px rgba(0,0,0,.6);
}
.room-map__node-icon { display:none }
/* Room name caption, e.g. "Шахта"/"Дозор" — hidden, same as
   .room-map__node-icon above: the tile's own texture already reads as what
   kind of place it is, and the caption crowded the tile. JS still populates
   node-label's textContent (map.js's buildNodeElement) — kept for a
   possible future tooltip/aria use, just not rendered visibly. Sized off
   --tile-size the same way .room-map__marker-icon is, so it shrinks/grows
   with zoom but never the emoji-scale marker icons, in case it comes back. */
.room-map__node-label {
  display:none;
  font-family:var(--font-fantasy); font-weight:700; letter-spacing:.01em;
  font-size:clamp(9px, calc(var(--tile-size, 96px) * 0.11), 14px);
  line-height:1.15; text-align:center;
  /* Absolute, tile-relative cap (not a % of the parent flex box, which is
     itself shrink-to-fit and would let unwrapped text push past the tile
     edge) — this is what actually forces multi-word labels to wrap. */
  max-width:calc(var(--tile-size, 96px) * 0.9); overflow-wrap:break-word;
}

/* Markers are individually positioned (left/top set inline per-marker by
   map.js's MARKER_SLOTS — fixed, non-overlapping N/E/S/W(/diagonal) anchor
   points; which marker gets which slot is a deterministic per-room shuffle,
   so a tile's icons sit in the same spot every visit). The wrapper just
   needs to span the tile so those percentages resolve correctly and stay
   clickable above the avatar underneath. */
.room-map__markers { position:absolute; inset:0; pointer-events:none; z-index:2 }
.room-map__marker {
  position:absolute; transform:translate(-50%,-50%);
  display:flex; align-items:center; justify-content:center; border-radius:calc(4*var(--u));
  /* No fill/border — just the icon glyph. Fixed (not min-) width/height +
     border-box + overflow:hidden keeps the box exactly square regardless of
     icon glyph metrics. Image markers opt out of the square below
     (.room-map__marker--image) — their artwork has its own proportions. */
  /* Proportional to the tile size (--tile-size, set by map.js), matching the
     ~19.2% ratio measured off the mockup (48px marker / 250px tile) — with
     min/max floors so markers stay tappable on the narrowest phones and
     don't blow up in the (much bigger) focused single-tile view.
     The 32px floor is load-bearing in both directions: a marker only stays
     inside its tile while its box is <= 0.34 * --tile-size (MARKER_SLOTS'
     tightest anchor sits at 17% of the tile and the box is centred on it),
     which is why map.js pins MAP_MIN_TILE_PX (100) and MAP_ZOOM_FAR_TILE_PX
     (96) above 32/0.34. Change any one of the three and markers start poking
     into the neighbouring tile, which — tiles being grid items — paints
     straight over them. */
  --marker-size:clamp(32px, calc(var(--tile-size, 96px) * 0.192), 64px);
  width:var(--marker-size);
  height:var(--marker-size);
  box-sizing:border-box; overflow:hidden;
  background:none; border:none; padding:0;
  cursor:pointer;
  pointer-events:auto;
  transition:transform .12s ease, box-shadow .12s ease;
}
.room-map__marker:disabled { cursor:default; opacity:.7 }
.room-map__marker:not(:disabled):hover, .room-map__marker:not(:disabled):focus-visible { transform:translate(-50%,-50%) scale(1.12); border:var(--hair) solid rgba(238,213,147,.9) }
.room-map__marker--current { border:var(--hair) solid #b87333 }
/* The object behind the currently-open popup — a brighter gold glow rather
   than --current's flat border, so it reads as "selected" distinctly from
   the "you are standing here" bronze ring above. */
.room-map__marker--object-selected {
  border:var(--hair) solid #ffd961;
  box-shadow:0 0 calc(6*var(--u)) rgba(255,217,97,.75);
}
.room-map__marker-icon { font-size:clamp(18px, calc(var(--tile-size, 96px) * 0.136), 44px) }
/* Image markers drop the square box: the <img> is fitted inside a
   --marker-size square (both max-* constraints, intrinsic ratio preserved)
   and the button then shrink-wraps whatever that comes out as. So the
   artwork's LONG side is a full --marker-size instead of the 80% a
   background-size:contain span used to manage — a ~25% bigger icon — while
   the box still never exceeds the square the tile-fit maths above assumes.
   The location art runs ~0.84-1.37 wide and is never square (see
   scripts/cut-location-icons.mjs), so a fixed square box could only ever
   letterbox it. overflow:visible because there is nothing left to clip once
   the box matches the art. */
.room-map__marker--image { width:auto; height:auto; overflow:visible }
.room-map__marker-image {
  display:block; width:auto; height:auto;
  max-width:var(--marker-size); max-height:var(--marker-size);
}

.room-map__avatar-slot {
  position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
  pointer-events:none; z-index:1;
}
.room-map__avatar {
  display:block;
  /* Proportional to tile size, matching the ~25.6% ratio measured off the
     mockup (64px avatar / 250px tile) — clamped for legibility on small
     phones and to stay reasonable in the focused single-tile view. */
  width:clamp(36px, calc(var(--tile-size, 96px) * 0.256), 88px);
  height:clamp(36px, calc(var(--tile-size, 96px) * 0.256), 88px);
  border-radius:calc(4*var(--u)); object-fit:cover;
  /* Deliberately no transition — position changes are instant, no movement animation. */
}

/* The static avatar while js/work-sprite.js is showing the animated one in
   its place. `visibility`, not `display`: the <img> keeps its box, and that
   box is what the sprite's pixel height is measured from. */
.room-map__avatar--replaced { visibility:hidden }

/* The working animation, sized and driven entirely by mountSprite()'s custom
   properties (see the .sprite block further down) — nothing here may set
   width/height. Positioned absolutely rather than laid out in the slot's flex
   flow, so the frame's own empty margins overhang without shifting anything:
   left/top are written by positionWorkSprite() to stand the character next to
   the object he is working at, on its own ground line. The slot sits under
   .room-map__markers (z-index 1 vs 2), so where the two overlap the character
   is behind the object — which is what the transparent frames are for. The
   centred variant is the fallback for an activity with no marker of its own.

   The mirrored variant is the character working the other way round: the art
   is drawn facing one direction (see `facing` in the atlas manifest) and the
   map puts him on either side of the object, so on the far side the whole
   sprite is flipped rather than the sheet being drawn twice. `transform` is
   free for it because left/top do the positioning — except on --centred,
   which owns the transform and is never mirrored. */
.room-map__work-sprite { position:absolute; pointer-events:none }
.room-map__work-sprite--centred { left:50%; top:50%; transform:translate(-50%,-50%) }
.room-map__work-sprite--mirrored { transform: scaleX(-1) }

/* Traveling marker — position:absolute, left/top driven every animation
   frame by map.js's interpolation loop (see updateTravelAvatarPosition),
   lerping between the current hop's origin/destination tile centers using
   character.travelStartTime/travelEndTime. Falls back to the old static
   midpoint placement when either tile isn't in the loaded window. Unlike
   .room-map__avatar above, this one IS animated — it's showing an
   in-progress trip, not an instant position jump. */
.room-map__travel-marker {
  position:absolute; left:0; top:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:calc(2*var(--u)); pointer-events:none; z-index:5;
  transform:translate(-50%,-50%);
}
.room-map__travel-countdown {
  padding:calc(1*var(--u)) calc(5*var(--u)); border-radius:calc(8*var(--u));
  background:rgba(20,12,2,.8); color:#eed593; font-weight:700; white-space:nowrap;
  font-size:clamp(.6rem,2.6vw,.85rem); line-height:1.6;
  border:var(--hair) solid rgba(238,213,147,.55);
  box-shadow:0 calc(2*var(--u)) calc(5*var(--u)) rgba(0,0,0,.5);
}
.room-map__travel-avatar-wrap { animation:room-map-travel-bob .9s ease-in-out infinite }
.room-map__travel-avatar-wrap .room-map__avatar {
  /* ~28.8% ratio measured off the mockup (72px travel avatar / 250px tile). */
  width:clamp(40px, calc(var(--tile-size, 96px) * 0.288), 96px);
  height:clamp(40px, calc(var(--tile-size, 96px) * 0.288), 96px);
}
@keyframes room-map-travel-bob {
  0%, 100% { transform:translateY(0) }
  50%      { transform:translateY(calc(-4*var(--u))) }
}
/* An infinite bob is exactly what the query is for, unlike .sprite (see the
   note on @keyframes sprite-run). Same treatment as the connection dot. */
@media (prefers-reduced-motion: reduce) {
  .room-map__travel-avatar-wrap { animation:none }
}

/* Multi-hop route preview — position:absolute over #room-map-wrap's own
   (clipped) box, sized/positioned in JS from the tile elements' own
   getBoundingClientRect() (map.js's drawRouteLine/repositionRouteLine),
   same technique work-popover.js uses for anchor positioning. Only ever
   drawn for the segment(s) whose rooms are in the currently loaded map
   window — gaps around an off-window hop are expected, not a bug. */
.room-map__route-line-overlay {
  position:absolute; inset:0; width:100%; height:100%;
  pointer-events:none; z-index:4; overflow:visible;
}
.room-map__route-line {
  fill:none; stroke:#eed593; stroke-width:calc(2.5*var(--u));
  stroke-linecap:round; stroke-linejoin:round;
  stroke-dasharray:calc(3*var(--u)) calc(4*var(--u));
  filter:drop-shadow(0 0 calc(2*var(--u)) rgba(0,0,0,.55));
}

/* ═══ Connection status ═══════════════════════════════════════════════════════
   Only rendered while the socket is not verified live. Uses `border`, never
   `outline`/`box-shadow` for its rim — #main-wrapper has overflow:hidden, which
   clips anything drawn outside the border box. */
.connection-status {
  display:flex; align-items:center; justify-content:center; gap:.5rem; flex-wrap:wrap;
  margin:.5rem auto; max-width:28rem; padding:.45rem .75rem; text-align:center;
  background:var(--paper-bg); color:var(--text); font-size:clamp(.8rem,1.8vw,.95rem);
  border:calc(3*var(--u)) solid var(--panel-border); border-radius:calc(8*var(--u));
}
.connection-status.hidden { display:none }

.connection-status__dot {
  flex:0 0 auto; width:.55rem; height:.55rem; border-radius:50%; background:currentColor;
}
.connection-status__text { flex:1 1 auto; min-width:0 }

.connection-status__retry {
  flex:0 0 auto; padding:.25rem .7rem; border-radius:999px; cursor:pointer;
  border:calc(2*var(--u)) solid var(--btn-border); background:var(--btn-grad);
  color:var(--text); font:inherit; font-weight:600;
}
.connection-status__retry:hover { box-shadow:0 2px 8px rgba(0,0,0,.35) }

.connection-status--reconnecting { color:#7a4a0c; border-color:#c9833f }
.connection-status--reconnecting .connection-status__dot { animation:connection-pulse 1.1s ease-in-out infinite }
.connection-status--offline      { color:#4a4a4a; border-color:#8a8a8a }

@keyframes connection-pulse {
  0%, 100% { opacity:1 }
  50%      { opacity:.25 }
}

/* Dim the world while it isn't live, so a stale render never reads as current.
   Pointer events stay on: taps still reach sendToRoom(), which refuses them
   and explains why — silently swallowing input is what this whole change fixes. */
.main-wrapper.is-disconnected > main { opacity:.55; transition:opacity .2s ease }

@media (prefers-reduced-motion: reduce) {
  .connection-status--reconnecting .connection-status__dot { animation:none }
}

/* ═══ Message bar ═════════════════════════════════════════════════════════════ */
.message-bar {
  display:none; width:100%; box-sizing:border-box; margin:.5rem 0; text-align:center; padding:.5rem;
  background:var(--paper-bg); border:var(--hair) solid var(--panel-border); outline:calc(3*var(--u)) solid var(--panel-rim); border-radius:calc(8*var(--u));
}
/* Out of the flex flow, not a sibling that pushes the map down.
   #main-wrapper distributes its height between this bar and the map, and
   map.js's sizeMapViewport() writes an EXACT px square onto #room-map-wrap —
   which, being an ordinary flex item, then gets shrunk. A 334x334 map became
   334x282 with a 97px tile for the ten seconds a message was on screen, and
   whichever tile size was computed in that window stuck until something else
   forced a relayout. It is a transient toast; it must not resize the world.
   #main-wrapper is already position:relative, so this anchors to it. */
#main-wrapper > .message-bar {
  position:absolute; top:0; left:50%; transform:translateX(-50%);
  width:min(100%,42rem); margin:.5rem 0; z-index:5;
}
.message-bar--info    { color:var(--msg-info) }
.message-bar--success { color:var(--msg-success) }
.message-bar--warning { color:var(--msg-warning) }
.message-bar--error   { color:var(--msg-error) }

/* ═══ Buttons ═════════════════════════════════════════════════════════════════ */
.ui-button {
  display:inline-flex; align-items:center; justify-content:center; gap:.35rem; padding:.6rem 1.25rem; border-radius:999px; border:calc(2*var(--u)) solid var(--btn-border); background:var(--btn-grad); color:var(--text); font-weight:600; cursor:pointer;
}
.ui-button:hover    { box-shadow:0 2px 8px rgba(0,0,0,.35) }
.ui-button:disabled { opacity:.5; cursor:not-allowed; transform:none; box-shadow:none }
.ui-button--primary { background:linear-gradient(135deg,#eed593,#b87333); border-color:rgba(107,57,17,.85); color:#2c1606 }
.ui-button--primary:hover { background:linear-gradient(135deg,#f6e3aa,#c9833f) }
.ui-button--accent  { background:linear-gradient(135deg,#fbbf24,#b87333); border-color:rgba(107,57,17,.8); color:#1f1309 }
.ui-button--outline { background:rgba(255,255,255,.3); border-color:rgba(80,46,2,.5) }
.ui-button--ghost   { background:transparent; border-color:rgba(80,46,2,.35) }
.ui-button--sm      { padding:.45rem 1rem; font-size:.9rem }
.ui-button--xs, .ui-button--compact { padding:.35rem .85rem; font-size:.8rem }
.ui-button--block   { width:100% }

/* ═══ Shared lift-on-hover — transition + translateY ═════════════════════════ */
.ui-button,
.footer-nav__item {
  transition:transform .15s ease, box-shadow .2s ease, background .2s ease;
}
.ui-button:hover,
.footer-nav__item:hover { transform:translateY(-1px) }

/* ═══ Modals ══════════════════════════════════════════════════════════════════ */
.ui-modal, .ui-dialog {
  position:fixed; inset:0; display:flex; align-items:center; justify-content:center;
}
.ui-modal { z-index:50; align-items:stretch }
.ui-dialog { z-index:100 }
.ui-modal__backdrop, .ui-dialog__backdrop { position:absolute; inset:0; background:rgba(0,0,0,.55) }
.ui-modal__frame {
  position:relative; z-index:1; display:flex; flex-direction:column; gap:calc(16*var(--u));
  width:100%; height:100%; max-width:1000px; padding:calc(20*var(--u));
  align-items:stretch; justify-content:flex-end; box-sizing:border-box;
}
@media(min-width:768px) { .ui-modal__frame { flex-direction:row; flex-wrap:wrap; align-items:center; justify-content:center; height:auto; margin:auto } }
/* Mobile: shop (top half) / inventory (bottom half) are fixed 50/50 splits of
   the modal frame, independent of whether the sibling panel is shown — so
   inventory never grows to fill the screen when the shop is hidden, and the
   pair never together exceeds the viewport (which used to push the shop
   panel off-screen). Desktop keeps its existing side-by-side layout above. */
@media(max-width:767px) {
  #inventory-panel, #shop-panel { flex:0 0 auto; height:calc(50% - 8*var(--u)); min-height:0 }
  #shop-panel { order:-1 }
}
/* Shared base for both dialog containers */
.ui-dialog__container,
.effects-modal__container { position:relative; z-index:1; padding:calc(20*var(--u)); display:flex; box-sizing:border-box }
.ui-dialog__container    { width:min(100%,28rem); max-height:85vh; overflow-y:auto; justify-content:center; align-items:center }
.effects-modal__container { width:min(100%,36rem); max-height:85vh; flex-direction:column; margin:auto; overflow-y:auto }

/* Shared dialog shell — panel visual comes from .panel-grid in HTML.
   flex-shrink:0 is required: panel-grid sets overflow:hidden, which per the
   flexbox spec makes this flex item's automatic minimum size 0 instead of
   content-based — without this, tall content (e.g. the personal room hub)
   gets silently squashed/clipped by the flex algorithm instead of properly
   overflowing so .effects-modal__container's own overflow-y:auto can scroll it. */
.dialog-panel { position:relative; display:flex; flex-direction:column; gap:calc(16*var(--u)); padding:calc(24*var(--u)); width:100%; box-sizing:border-box; flex-shrink:0 }
/* padding-inline clears the absolutely-positioned .dialog-close (✕) corner
   button on both sides, including when the title wraps to a 2nd line — a
   plain centered block doesn't reserve that space on its own. Fixed rem
   (not --u-scaled) so it stays wide enough as the dialog shrinks. */
.dialog-title { margin:0; text-align:center; font-size:clamp(1.2rem,2.2vw,1.6rem); font-weight:700; letter-spacing:.02em; padding-inline:2.25rem }
.dialog-close { position:absolute; top:calc(18*var(--u)); right:calc(18*var(--u)) }
/* Item detail dialog actions */
.ui-dialog__actions { display:flex; flex-direction:column; gap:calc(12*var(--u)) }
/* Item detail dialog — icon / description / effects */
.item-modal__icon-wrap { position:relative; margin:0 auto; width:min(40%, 6rem) }
.item-modal__icon { display:block; width:100%; aspect-ratio:1/1; object-fit:contain }
.item-modal__icon--upgrading { animation:item-glow-pulse .7s ease-out }
.item-modal__description { margin:0; text-align:center; color:var(--text-muted); font-size:clamp(.8rem,1.8vw,.95rem) }
.item-modal__effects { display:flex; flex-direction:column; gap:calc(6*var(--u)) }
.item-effect-row {
  display:flex; align-items:flex-start; gap:calc(8*var(--u)); padding:calc(4*var(--u)) calc(8*var(--u));
  border-radius:calc(8*var(--u)); background:rgba(0,0,0,.06);
}
.item-effect-row__icon { flex:0 0 auto; font-size:1rem; line-height:1 }
.item-effect-row__name { flex:1 1 auto; min-width:0; font-size:clamp(.8rem,1.8vw,.9rem); white-space:normal; word-break:break-word }
.item-effect-row__value { flex:0 0 auto }

@keyframes item-glow-pulse {
  0%   { filter:brightness(1) drop-shadow(0 0 0 transparent); transform:scale(1) }
  40%  { filter:brightness(1.6) drop-shadow(0 0 1.2rem var(--panel-border)); transform:scale(1.18) }
  100% { filter:brightness(1) drop-shadow(0 0 0 transparent); transform:scale(1) }
}
.item-fx-spark {
  position:absolute; top:50%; left:50%; width:.35rem; height:.35rem; margin:-.175rem 0 0 -.175rem;
  border-radius:50%; background:var(--panel-border); box-shadow:0 0 .5rem var(--panel-border);
  pointer-events:none; animation:item-spark .7s ease-out forwards;
}
@keyframes item-spark {
  0%   { transform:translate(0,0) scale(1); opacity:1 }
  100% { transform:translate(var(--spark-x), var(--spark-y)) scale(0); opacity:0 }
}

/* ═══ Reward flight FX (gather → HUD juice, js/reward-fx.js) ════════════════
   One shared fixed full-viewport layer that the flying "+N" badges, the
   landing flash, its sparks and the fallback "+N" popup all render into.
   Appended to document.body (a sibling of #main-wrapper, like #work-popover)
   so it's never clipped by #main-wrapper's overflow:hidden. pointer-events:
   none throughout so FX never eats a click mid-flight. */
#reward-fx-layer { position:fixed; inset:0; z-index:300; pointer-events:none; overflow:visible }

/* The "+N <icon>" badge: pops in above the gather button, holds, then flies to
   its header cell. JS owns transform/opacity for all three phases (see
   playRewardBadge) — never animate those from here or the two will fight. */
.reward-fx-badge {
  position:absolute; top:0; left:0; pointer-events:none; will-change:transform, opacity;
  display:inline-flex; align-items:center; gap:calc(5*var(--u));
  padding:calc(4*var(--u)) calc(9*var(--u)); border-radius:calc(14*var(--u));
  background:linear-gradient(180deg, rgba(255,248,225,.97), rgba(240,206,132,.97));
  border:var(--hair) solid var(--panel-border);
  box-shadow:0 calc(3*var(--u)) calc(10*var(--u)) rgba(0,0,0,.3), 0 0 calc(16*var(--u)) rgba(255,214,120,.8);
  color:var(--text); font-weight:800; line-height:1; white-space:nowrap;
  font-size:clamp(.82rem, calc(15*var(--u)), 1rem);
}
.reward-fx-badge__icon--image   { width:1.35em; height:1.35em; object-fit:contain; border-radius:50% }
.reward-fx-badge__icon--unicode { font-size:1.2em; line-height:1 }

/* .cell has overflow:hidden — box-shadow on this same element would be
   silently clipped invisible (see #main-wrapper's box-shadow-clipping note
   above), so the "glow" here is transform+background-color only, both of
   which stay within the box's own bounds. */
.reward-fx-pulse { animation:reward-fx-pulse .6s ease-out }
@keyframes reward-fx-pulse {
  0%   { transform:scale(1);    background-color:transparent }
  35%  { transform:scale(1.18); background-color:rgba(255,215,110,.45) }
  100% { transform:scale(1);    background-color:transparent }
}

.reward-fx-spark-origin { position:fixed; width:0; height:0; pointer-events:none; z-index:300 }

/* The landing flash. Same clipping problem as .reward-fx-pulse above, one step
   worse: a glow is nothing BUT overflow, so it can't live on the cell at all.
   Drawn instead as its own element in the body-level fixed layer, centered on
   the cell's rect. */
.reward-fx-flash {
  position:fixed; pointer-events:none; z-index:300; border-radius:50%;
  background:radial-gradient(circle, rgba(255,255,255,.95) 0%, rgba(255,226,152,.72) 34%, rgba(255,200,90,0) 70%);
  mix-blend-mode:screen; animation:reward-fx-flash .55s ease-out forwards;
}
@keyframes reward-fx-flash {
  0%   { opacity:0; transform:translate(-50%,-50%) scale(.35) }
  25%  { opacity:1; transform:translate(-50%,-50%) scale(1) }
  100% { opacity:0; transform:translate(-50%,-50%) scale(1.7) }
}

/* These two DO run on elements inside the clipped cell, so they stay within
   the box: a brightness/scale kick on the resource icon, and a size+color kick
   on its number, together reading as "there is more of this now". */
.reward-fx-icon-flash { animation:reward-fx-icon-flash .55s ease-out }
@keyframes reward-fx-icon-flash {
  0%   { filter:brightness(1); transform:scale(1) }
  30%  { filter:brightness(2.4) drop-shadow(0 0 .3rem rgba(255,236,170,.95)); transform:scale(1.3) }
  100% { filter:brightness(1); transform:scale(1) }
}
.reward-fx-count-bump { animation:reward-fx-count-bump .5s ease-out }
@keyframes reward-fx-count-bump {
  0%   { transform:scale(1) }
  30%  { transform:scale(1.4); color:#5a3200; text-shadow:0 0 .45rem rgba(255,214,110,.95) }
  100% { transform:scale(1) }
}
/* Effects modal list */
.effects-modal__list  { display:flex; flex-direction:column; gap:calc(10*var(--u)); max-height:55vh; overflow-y:auto; padding-right:calc(4*var(--u)) }
.effects-modal__empty { text-align:center; font-style:italic; margin:calc(12*var(--u)) 0 }

/* Message log rows — reuse .message-bar--{severity} for color */
.message-log__row { padding:calc(4*var(--u)) calc(8*var(--u)); border-radius:calc(8*var(--u)); background:rgba(0,0,0,.06); font-size:clamp(.8rem,1.8vw,.9rem) }

/* ═══ Panel layouts — visual from .panel-grid ════════════════════════════════ */

/* Inventory / Shop panels */
.item-panel {
  display:flex; flex-direction:column; gap:calc(16*var(--u)); padding:calc(20*var(--u));
  width:min(100%,28rem); flex:1 1 24rem; min-height:clamp(260px,55vh,420px); box-sizing:border-box;
}
.item-panel__header { display:flex; align-items:center; justify-content:space-between; gap:calc(12*var(--u)) }
.item-panel__title  { margin:0; font-size:clamp(1.25rem,2.2vw,1.6rem); letter-spacing:.02em }
.item-panel__close  { align-self:flex-start }
.item-panel__grid {
  display:grid; grid-template-columns:repeat(auto-fit,minmax(80px,1fr)); gap:calc(14*var(--u)); justify-items:center; align-items:start; flex:1 1 auto; min-height:0;
  padding:calc(8*var(--u)); border-radius:calc(14*var(--u)); background:rgba(255,255,255,.22); box-shadow:inset 0 2px 8px rgba(0,0,0,.18); overflow:auto;
}

/* Shop merchant strip — portrait + name + blurb, above the shop's item grid */
.shop-merchant  { display:flex; align-items:center; gap:calc(12*var(--u)); flex:0 0 auto }
.shop-merchant__icon {
  flex:0 0 auto; width:clamp(48px,14vw,72px); aspect-ratio:1/1; object-fit:cover;
  border-radius:50%; border:var(--hair) solid var(--panel-border); box-shadow:0 0 0 calc(2*var(--u)) var(--panel-dark);
}
.shop-merchant__icon--placeholder { background:var(--panel-dark) }
.shop-merchant__info        { min-width:0 }
.shop-merchant__name        { margin:0; font-size:clamp(1.05rem,2vw,1.3rem); font-weight:700; letter-spacing:.02em }
.shop-merchant__description { margin:calc(4*var(--u)) 0 0; color:var(--text-muted); font-size:clamp(.8rem,1.8vw,.9rem) }
@media(min-width:768px) { .item-panel__grid { grid-template-columns:repeat(auto-fit,minmax(96px,1fr)) } }
.inventory-item     { position:relative; width:100%; max-width:calc(96*var(--u)); cursor:pointer; display:flex; flex-direction:column; align-items:center; gap:calc(4*var(--u)) }
.inventory-item img { width:100%; aspect-ratio:1/1; object-fit:contain; display:block }
.item-price         { font-size:clamp(.75rem,1.6vw,.9rem); font-weight:600; text-align:center }
.item-level-badge {
  position:absolute; top:-.35rem; right:-.35rem; z-index:1; display:grid; place-items:center;
  min-width:1.2rem; height:1.2rem; padding:0 .2rem; border:1px solid var(--panel-border); border-radius:50%;
  color:var(--paper-bg); background:var(--panel-rim);
  box-shadow:0 0 .5rem var(--panel-border); font-weight:800; font-size:.7rem; line-height:1;
}
/* Stack count ("x47"). Bottom-right so it never collides with the level badge —
   an item can in principle carry both. Pill-shaped, since it grows to 3 digits. */
.item-quantity-badge {
  position:absolute; bottom:-.35rem; right:-.35rem; z-index:1; display:grid; place-items:center;
  min-width:1.2rem; height:1.2rem; padding:0 .3rem; border:1px solid var(--panel-border); border-radius:999px;
  color:var(--paper-bg); background:var(--panel-rim);
  box-shadow:0 0 .5rem var(--panel-border); font-weight:800; font-size:.7rem; line-height:1;
}

.activity-panel { display:flex; flex-direction:column; gap:calc(14*var(--u)); padding:calc(22*var(--u)) calc(24*var(--u)); align-items:center; text-align:center; width:min(100%,26rem); align-self:center; margin:auto auto calc(16*var(--u)); overflow:visible }
.activity-panel__title       { margin:0; font-size:clamp(1.4rem,2.4vw,1.8rem); font-weight:700 }
.activity-panel__header {
  display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:.5rem;
  align-items:start; width:100%; box-sizing:border-box; text-align:left;
}
.activity-panel__header--compact { grid-template-columns:minmax(0,1fr) auto }
.activity-panel__body { width:100%; text-align:left; overflow-wrap:break-word }
.activity-panel__body::after { content:""; display:block; clear:both }
.activity-deposit-info { float:left; display:grid; justify-items:center }
.activity-panel__description { margin:0; font-size:clamp(.95rem,2vw,1.05rem) }
.activity-panel__result { white-space:pre-line; font-weight:bold }
.activity-panel__result--success { color:var(--msg-success) }
.activity-panel__result--fail    { color:var(--msg-error) }
.activity-panel__controls    { display:grid; grid-auto-flow:column; grid-auto-columns:1fr; gap:calc(16*var(--u)) }
/* "Повысить шанс" (time / stamina) reuse .activity-panel__controls itself
   (nested here) so they get the exact same grid sizing as the start-work
   button pair; "Собрать" is a lone flex child, sized to its own content and
   centered under them by align-items. */
.activity-panel__choices     { display:flex; flex-direction:column; align-items:center; gap:calc(12*var(--u)) }
.activity-panel__choices .activity-panel__controls { width:100% }
.activity-panel__status {
  width:100%; padding:calc(12*var(--u)) calc(16*var(--u)); border-radius:calc(18*var(--u)); background:rgba(255,255,255,.65);
  border:var(--hair) solid rgba(80,46,2,.5); outline:calc(3*var(--u)) solid rgba(57,30,1,.8);
  font-weight:600; display:flex; flex-direction:column; gap:calc(8*var(--u)); text-align:center; align-items:center;
}
.activity-countdown__label { font-size:clamp(.9rem,2vw,1rem); letter-spacing:.03em }
.deposit-remaining { display:inline-flex; align-items:center; gap:calc(6*var(--u)); font-weight:700 }
.deposit-remaining__icon-image { width:calc(16*var(--u)); height:calc(16*var(--u)); object-fit:contain; border-radius:50% }
.deposit-remaining__icon-unicode { font-size:calc(14*var(--u)); line-height:1 }
.activity-countdown__progress {
  position:relative; width:100%; height:calc(16*var(--u)); border-radius:calc(14*var(--u)); overflow:hidden;
  background:rgba(0,0,0,.28); border:var(--hair) solid rgba(80,46,2,.7); outline:calc(2*var(--u)) solid rgba(57,30,1,.8);
  box-shadow:inset 0 1px 6px rgba(0,0,0,.35);
}
/* Warm gold→bronze, matching the panel palette. Deliberately VERTICAL: the fill
   drains via scaleX(), which would squash a horizontal gradient's stops as the
   bar shrinks — a 180deg one looks identical at every width. */
.activity-countdown__progress-fill {
  position:absolute; inset:0; transform-origin:left center; transform:scaleX(1);
  background:linear-gradient(180deg,#ffdf95,#f0a93c 55%,#b8690f);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.5), inset 0 -1px 2px rgba(94,45,2,.45);
}
@keyframes countdown-progress { from{transform:scaleX(1)} to{transform:scaleX(0)} }

#work-popover {
  /* 59: just under .battle-overlay (60) — the card opens by itself while the
     character works (work-popover.js syncAutoWorkPopover), and a battle has to
     cover it. Still above .ui-modal (50); below .ui-dialog (100). */
  position:fixed; z-index:59;
  width:clamp(13.5rem, calc(300*var(--u)), 15rem);
  max-width:92vw;
  transform-origin: top left;
}
#work-popover, #work-popover * { box-sizing:border-box }
/* Activity-button hover highlight — a soft warm glow instead of the base
   .ui-button:hover dark drop-shadow (which read as a heavy shadow under the
   button on lift). To turn it off quickly, set this to `none`. */
#work-popover { --activity-btn-hover-glow: 0 calc(4*var(--u)) calc(16*var(--u)) rgba(255,217,97,.55); }
#work-popover .activity-panel {
  width:100%; margin:0;
  padding:clamp(4px, calc(6*var(--u)), 8px) clamp(8px, calc(10*var(--u)), 16px);
  gap:clamp(3px, calc(4*var(--u)), 6px);
}
#work-popover .activity-panel__title       { font-size:clamp(.82rem, calc(15*var(--u)), .95rem); font-weight:800; line-height:1.05 }
.activity-panel__safe { margin:0; color:#3b7a32; font-size:clamp(.68rem, calc(13*var(--u)), .82rem) }
#work-popover .activity-panel__description { font-size:clamp(.68rem, calc(13*var(--u)), .82rem); line-height:1.16 }
#work-popover .activity-panel__status {
  font-size:clamp(.66rem, 2.1vw, .76rem); padding:0; line-height:1.15;
  background:none; border:none; outline:none;
  gap:calc(2*var(--u));
}
#work-popover .activity-panel__header { gap:clamp(3px, calc(3*var(--u)), 5px); margin-bottom:clamp(2px, calc(5*var(--u)), 6px) }
#work-popover .icon-btn {
  width:clamp(1.1rem, calc(22*var(--u)), 1.4rem);
  height:clamp(1.1rem, calc(22*var(--u)), 1.4rem);
  padding:0; border-radius:clamp(5px, calc(6*var(--u)), 7px);
  font-size:clamp(.68rem, calc(13*var(--u)), .8rem);
  display:grid; place-items:center; line-height:1;
  background:var(--paper-bg); border:clamp(1.5px, calc(1.5*var(--u)), 2px) solid var(--panel-rim); color:var(--text);
}
#work-popover .icon-btn:hover { background:#fff3da; }
/* .dialog-close is position:absolute everywhere else (a single corner-pinned
   button on item modals, roster cards, ...), but here it shares the header
   with .stat-info-icon as a normal grid item — taking it out of flow left its
   grid column collapsed to 0 width, so it landed almost exactly on top of the
   "?" button instead of beside it. */
#work-popover .activity-panel__header .dialog-close { position:static; top:auto; right:auto }
#work-popover .activity-deposit-info {
  width:clamp(2.875rem, calc(51*var(--u)), 3.2rem);
  margin:0 clamp(6px, calc(7*var(--u)), 8px) clamp(2px, calc(2*var(--u)), 3px) 0;
  gap:clamp(2px, calc(2*var(--u)), 3px);
}
#work-popover .deposit-remaining {
  justify-content:center;
  width:clamp(2.625rem, calc(48*var(--u)), 3rem); aspect-ratio:1/1; padding:clamp(3px, calc(3*var(--u)), 4px);
  border:clamp(2px, calc(2*var(--u)), 2.5px) solid var(--panel-border); border-radius:clamp(8px, calc(9*var(--u)), 10px);
  background:rgba(255,255,255,.35);
}
#work-popover .deposit-remaining__icon-image { width:80%; height:80%; object-fit:contain }
#work-popover .deposit-remaining__icon-unicode { font-size:clamp(1.5rem, calc(30*var(--u)), 1.9rem); line-height:1 }
#work-popover .deposit-remaining__count { font-size:clamp(10px, calc(11*var(--u)), 12px); font-weight:800 }
#work-popover .activity-countdown__label { font-size:clamp(.64rem, 2vw, .72rem) }
#work-popover .activity-btn {
  display:inline-flex; flex-direction:column; align-items:center; justify-content:center;
  gap:.05rem; min-width:0; padding:clamp(3px, calc(5*var(--u)), 6px) clamp(3px, calc(3*var(--u)), 5px);
  border-radius:clamp(7px, calc(8*var(--u)), 9px);
  font-size:clamp(.72rem, calc(11*var(--u)), .78rem); line-height:1.02;
  min-height:clamp(1.85rem, calc(36*var(--u)), 2.3rem);
  text-align:center;
}
#work-popover .activity-btn:hover { box-shadow:var(--activity-btn-hover-glow); }
#work-popover .activity-btn__label {
  display:inline-flex; align-items:center; justify-content:center; gap:.2rem;
  font-weight:800; white-space:nowrap;
}
#work-popover .activity-btn__label-icon-image { width:1em; height:1em; object-fit:contain }
#work-popover .activity-btn__label-icon-unicode { font-size:1em; line-height:1 }
#work-popover .activity-btn__meta { font-weight:500; opacity:.85; font-size:.92em }
#work-popover .ui-button--outline,
#work-popover .ui-button--accent,
#work-popover .ui-button--primary {
  background:var(--btn-grad); border-color:var(--btn-border); color:var(--text);
}
#work-popover .ui-button--outline:hover,
#work-popover .ui-button--accent:hover,
#work-popover .ui-button--primary:hover {
  background:linear-gradient(180deg, rgba(255,245,220,.95), rgba(230,190,120,.95));
}
#work-popover .activity-panel__controls { gap:clamp(3px, calc(5*var(--u)), 6px) }
#work-popover .activity-panel__choices { gap:clamp(2px, calc(3*var(--u)), 4px) }
/* Set by updateSingleActivityUI while this activity is counting down or
   awaiting the player's choice — see the note there. */
#work-popover .activity-panel--busy .activity-panel__body { display:none }
/* .activity-panel already carries its own full padding — this outer shell
   only needs enough to keep the panel-grid border/shadow from looking
   flush-cut, not a second full padding layer stacked on top of it. */
#work-popover .dialog-panel { padding:clamp(2px, calc(3*var(--u)), 5px) }
.work-popover__arrow {
  position:absolute; left:var(--arrow-x, 50%); width:calc(20*var(--u)); height:calc(20*var(--u));
  background:var(--panel-bg); border-right:var(--hair) solid var(--panel-border); border-bottom:var(--hair) solid var(--panel-border);
  transform:translateX(-50%) rotate(45deg);
}
#work-popover[data-placement="top"] .work-popover__arrow    { bottom:calc(-10*var(--u)) }
#work-popover[data-placement="bottom"] .work-popover__arrow { top:calc(-10*var(--u)); border-right:none; border-bottom:none; border-left:var(--hair) solid var(--panel-border); border-top:var(--hair) solid var(--panel-border) }

/* ═══ Footer nav ══════════════════════════════════════════════════════════════ */
.footer-nav { width:100%; display:grid; grid-auto-flow:column; grid-auto-columns:1fr }
.footer-nav .cell { aspect-ratio:unset; border-right:var(--hair) solid var(--panel-border); border-bottom:0 }
.footer-nav .cell:last-child { border-right:0 }
.footer-nav__item {
  gap:calc(6*var(--u)); padding:calc(6*var(--u)); text-transform:uppercase;
  letter-spacing:.04em; text-align:center; outline:none; flex:1 1 0;
}
.footer-nav__item:hover, .footer-nav__item:focus-visible {
  background:linear-gradient(180deg,rgba(255,240,210,.95),rgba(214,176,103,.95));
  box-shadow:inset 0 calc(3*var(--u)) calc(6*var(--u)) rgba(255,255,255,.4);
}
.footer-nav__item--active {
  background:linear-gradient(180deg,rgba(255,239,208,.95),rgba(230,193,122,.95));
  box-shadow:inset 0 calc(5*var(--u)) calc(10*var(--u)) rgba(0,0,0,.2);
}
.footer-nav__icon  { font-size:clamp(1.4rem,5vw,2rem) }
.footer-nav__icon--img { width:clamp(1.4rem,5vw,2rem); height:clamp(1.4rem,5vw,2rem); object-fit:contain }
.footer-nav__label { font-size:clamp(.7rem,2.1vw,.95rem) }

/* ═══ Status effects ══════════════════════════════════════════════════════════ */
#status-panel {
  position:absolute; top:calc(10*var(--u)); right:calc(10*var(--u)); z-index:10; cursor:pointer;
  display:grid; grid-template-columns:1fr; gap:calc(6*var(--u));
  padding:calc(6*var(--u)); border-radius:calc(14*var(--u)); transition:background .2s ease;
  --effect-size:calc(30*var(--u));
}
#status-panel:empty { display:none }
#status-panel:hover { background:rgba(0,0,0,.15) }
/* --emoji-font / --count-font cascade to children; fallbacks = xs/default sizes */
#status-panel.effects--lg { --effect-size:calc(72*var(--u)); --emoji-font:calc(34*var(--u)); --count-font:calc(16*var(--u)) }
#status-panel.effects--md { --effect-size:calc(54*var(--u)); --emoji-font:calc(26*var(--u)); --count-font:calc(13*var(--u)) }
#status-panel.effects--sm { --effect-size:calc(40*var(--u)); --emoji-font:calc(19*var(--u)); --count-font:calc(10*var(--u)); grid-template-columns:1fr 1fr }
#status-panel.effects--xs { --effect-size:calc(30*var(--u)); grid-template-columns:1fr 1fr }
.status-effect { position:relative; width:var(--effect-size); height:var(--effect-size); border-radius:50%; transition:width .3s ease,height .3s ease }
.status-effect__emoji {
  position:absolute; inset:0; display:flex; align-items:center; justify-content:center; border-radius:50%;
  background:rgba(100,62,8,.25); border:calc(2*var(--u)) solid rgba(171,110,21,.6);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.5), 0 4px 8px rgba(0,0,0,.3); font-size:var(--emoji-font, calc(14*var(--u)));
}
.status-effect svg { position:absolute; inset:0; width:100%; height:100%; transform:rotate(-90deg); pointer-events:none }
.status-effect .countdown {
  position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
  font-weight:bold; color:#fff; text-shadow:0 1px 3px rgba(0,0,0,.8); pointer-events:none; font-size:var(--count-font, calc(8*var(--u)));
}
.progress-ring__circle { stroke:rgba(255,255,255,.85); stroke-width:2; fill:transparent; stroke-linecap:round }

/* ═══ Auth screen ═════════════════════════════════════════════════════════════
   backgrounds-welcome-screen.png is landscape (1456×1052).
   background-size:cover + position:center keeps the scene centered on every
   viewport. */
.auth-screen {
  height:100vh; height:100dvh; width:100%; overflow:hidden; box-sizing:border-box;
  display:flex; flex-direction:column; justify-content:space-between; align-items:center;
  padding:calc(10*var(--u)) calc(10*var(--u)) calc(14*var(--u));
  background-color:#8fb8d8;
  background-image:url('img/ui/backgrounds-welcome-screen.png');
  background-repeat:no-repeat; background-size:cover; background-position:center center;
}

.auth-title {
  margin:0; text-align:center; color:#fff8e6; font-weight:700;
  font-size:clamp(1rem, 4.2vw, 1.8rem);
  text-shadow:0 calc(2*var(--u)) calc(6*var(--u)) rgba(0,0,0,.65), 0 0 calc(14*var(--u)) rgba(0,0,0,.4);
}

.auth-dock { width:100%; max-width:calc(640*var(--u)); display:flex; flex-direction:column; gap:calc(8*var(--u)); flex-shrink:0 }

/* auth-note overrides panel-grid bg to lighter paper */
.auth-note { background:var(--paper-bg); margin:0; padding:calc(6*var(--u)) calc(12*var(--u)); text-align:center; font-size:clamp(.7rem,2.2vw,.9rem) }

/* Tab visuals come from the reused .footer-nav/.footer-nav__item rules;
   only these auth-specific overrides needed. */
.auth-tabs__item { text-transform:none; letter-spacing:normal }
.auth-tabs .footer-nav__label { font-size:clamp(.85rem,2.6vw,1.05rem) }

/* auth-card visual from .panel-grid, layout here */
.auth-card { padding:calc(10*var(--u)) calc(14*var(--u)); display:flex; flex-direction:column; gap:calc(6*var(--u)); box-sizing:border-box }
.auth-card label { font-weight:bold; font-size:clamp(.75rem,2.2vw,.9rem) }
/* Flat 16px (not a sub-16px clamp floor): iOS Safari auto-zooms the page on
   focusing an input under 16px, which would break the no-scroll layout the
   instant a phone user taps a field. */
.auth-card input { padding:calc(6*var(--u)) calc(10*var(--u)); border:var(--hair) solid var(--panel-rim); border-radius:calc(8*var(--u)); font-size:16px; box-sizing:border-box }
/* auth-button extends .ui-button — only overrides that differ */
.auth-button { margin-top:calc(4*var(--u)); padding:calc(8*var(--u)); font-size:clamp(.9rem,2.4vw,1rem); border-radius:calc(10*var(--u)); border:var(--hair) solid var(--panel-border); width:100% }
.form-message         { min-height:1rem; margin:0; text-align:center; font-size:clamp(.7rem,2vw,.85rem) }
.form-message.error   { color:#7a1b0c }
.form-message.success { color:#1f5f2e }

/* ═══ Game screen (personal room) ═══════════════════════════════════════════ */
.game-screen { display:flex; flex-direction:column; gap:1.5rem; padding:1.5rem; max-width:42rem; width:min(100%,42rem); margin:1rem auto; box-sizing:border-box }
/* padding-right clears .dialog-close (✕) when this header sits inside a
   dialog-panel and pins its own trailing control (e.g. a "?" button) to the
   row's right edge via justify-content — otherwise the two overlap. */
.game-screen__header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; padding-right:2.25rem }
.ui-heading          { font-weight:700; margin:0 }
.ui-heading--section { font-size:clamp(1.4rem,2.2vw,2rem) }
.ui-subheading       { font-size:1.1rem; font-weight:600; margin:0 }

/* Inset panel within game-screen — same warm bronze/brown tint used by
   .avatar-preview/.autoplay-step's inset panels, not a cool white card. */
.ui-panel { background:rgba(100,62,8,.14); border:var(--hair) solid rgba(171,110,21,.45); border-radius:12px; padding:1rem; display:flex; flex-direction:column; gap:.75rem }
.ui-panel--stack { gap:.85rem }
.ui-field  { display:flex; flex-direction:column; gap:.5rem }
.ui-label  { font-size:.95rem; font-weight:600 }
.ui-input, .ui-select {
  width:100%; padding:.6rem .75rem; border-radius:10px; font-size:.95rem; box-sizing:border-box;
  border:1px solid rgba(16,42,37,.7); background:rgba(255,255,255,.92); color:#0f172a; transition:box-shadow .15s ease, border-color .15s ease;
}
.ui-input:focus, .ui-select:focus { outline:none; border-color:#fbbf24; box-shadow:0 0 0 2px rgba(251,191,36,.4) }

/* ═══ Character selector ══════════════════════════════════════════════════════ */
.character-selector { display:flex; flex-direction:column; gap:.75rem }
@media(min-width:640px) { .character-selector { flex-direction:row; align-items:center } }
.character-selector .ui-select    { flex:1 }
.character-selector__button       { flex:0 0 auto; white-space:nowrap }
.character-selector__feedback     { min-height:1.25rem; margin:0; font-size:.9rem }
.character-selector__feedback--success { color:#1f5f2e }

/* ═══ Avatar picker ═══════════════════════════════════════════════════════════ */
.avatar-preview { padding:1rem; background:rgba(0,0,0,.12); border-radius:12px }
.avatar-preview img { width:9.5rem; height:9.5rem; object-fit:contain; border-radius:12px; border:3px solid rgba(171,110,21,.85); box-shadow:0 0 0 3px rgba(171,110,21,.25) }
.avatar-grid   { display:grid; grid-template-columns:repeat(auto-fit,minmax(64px,1fr)); gap:.65rem }
.avatar-option {
  padding:.3rem; border-radius:10px; border:2px solid transparent;
  background:rgba(0,0,0,.12); cursor:pointer;
  transition:border-color .2s ease, transform .2s ease, box-shadow .2s ease;
}
.avatar-option:hover { transform:translateY(-1px); box-shadow:0 4px 12px rgba(0,0,0,.2) }
.avatar-option--selected { border-color:rgba(171,110,21,.85); box-shadow:0 0 0 2px rgba(171,110,21,.3) }
.avatar-image  { width:2.75rem; height:2.75rem; object-fit:contain; transition:transform .2s ease }
.avatar-option--selected .avatar-image { transform:scale(1.05) }

/* ═══ Character roster ════════════════════════════════════════════════════════ */
.roster-panel  { display:flex; flex-direction:column; margin-top:calc(6*var(--u)); position:relative }
.roster-panel__heading { margin:0 0 calc(4*var(--u)); font-size:1em; font-weight:600; color:var(--text) }
.roster-panel__toggle {
  position:absolute; top:calc(8*var(--u)); right:calc(8*var(--u)); z-index:2;
  background:rgba(0,0,0,.55); border-color:rgba(200,130,30,.6);
  color:rgba(255,225,140,1); padding:.4rem 1.2rem; font-size:1rem; font-weight:600;
}
.roster-panel__toggle:hover { background:rgba(0,0,0,.75); color:#fff; border-color:rgba(220,160,50,.8) }
.roster {
  display:grid; gap:.65rem; padding:.75rem;
  background:rgba(100,62,8,.15);
  border:calc(5*var(--u)) solid var(--panel-rim);
  box-shadow:inset 0 0 0 var(--hair) var(--panel-border), inset calc(2*var(--u)) calc(5*var(--u)) var(--hair) var(--hair) rgba(0,0,0,.3);
  border-radius:14px; max-height:45vh; overflow-y:auto;
}
.roster[data-collapsed="true"] {
  display:flex; flex-wrap:wrap; gap:.4rem; padding:.4rem .5rem;
  background:transparent; border-color:transparent; box-shadow:none; max-height:none;
}
.roster[data-collapsed="true"] .roster-card {
  min-height:0; padding:0; background:transparent; border:none; box-shadow:none; width:auto;
}
.roster[data-collapsed="true"] .roster-card:hover { transform:translateY(-1px) }
.roster[data-collapsed="true"] .roster-card__body { display:none }
.roster[data-collapsed="true"] .roster-card__avatar { width:2.5rem; height:2.5rem }
.roster[data-collapsed="true"] .roster-card--empty { display:none }
.roster[data-variant="expanded"] { grid-template-columns:repeat(auto-fill,minmax(200px,260px)); max-height:none }
/* Compact: a short list of a few players, not a filled-out grid — flex-column
   hugs content width without grid auto-fill's fit-content circular sizing. */
.roster[data-variant="compact"] { display:flex; flex-direction:column; width:fit-content; max-width:100%; background:rgba(100,62,8,.08) }

/* roster-card visual from .panel-grid, layout here */
.roster-card { display:flex; flex-direction:column; gap:.6rem; padding:.75rem; min-height:8rem; transition:transform .2s ease, box-shadow .2s ease }
.roster-card:hover    { transform:translateY(-2px); box-shadow:0 8px 18px rgba(0,0,0,.22) }
.roster-card--compact { flex-direction:row; align-items:center; min-height:0 }
.roster-card--active  { box-shadow:0 0 0 3px var(--panel-border) }
.roster-card--empty   { background:rgba(100,62,8,.08); border-style:dashed; align-items:center; justify-content:center; text-align:center }

.roster[data-variant="compact"] .roster-card__avatar   { width:2.75rem; height:2.75rem; border-width:1px }
.roster[data-variant="compact"] .roster-card__location { display:none }
.roster[data-variant="compact"] .roster-card__gauges   { flex-direction:row }
.roster[data-variant="compact"] .roster-card__gauge    { flex:1 }
.roster[data-variant="compact"] .roster-card__name     { font-size:.95rem }

.roster-card__avatar  { width:4.5rem; height:4.5rem; border-radius:50%; overflow:hidden; flex-shrink:0; border:2px solid var(--panel-border); background:rgba(0,0,0,.12) }
.roster-card__avatar img { width:100%; height:100%; object-fit:cover }
.roster-card__body    { display:flex; flex-direction:column; gap:.4rem }
.roster-card__name    { font-weight:700 }
.roster-card__status  { display:flex; align-items:center; gap:.5rem; font-size:.9rem }
.roster-card__status-text { flex:1 }
.roster-card__location    { font-size:.85rem; opacity:.75 }
.roster-card__placeholder { font-style:italic; opacity:.75 }
.roster-card__gauges  { display:flex; flex-direction:column; gap:.35rem }
.roster-card__gauge   { display:flex; flex-direction:column; gap:.2rem; font-size:.8rem }
.roster-card__gauge-label { display:flex; justify-content:space-between }
.roster-card__gauge-meter { position:relative; height:.5rem; border-radius:999px; background:rgba(0,0,0,.2); overflow:hidden }
.roster-card__gauge-fill  { position:absolute; inset-block:0; left:0; border-radius:inherit; width:0 }
.roster-card__gauge--health  .roster-card__gauge-fill { background:linear-gradient(90deg,#14532d,#22c55e) }
.roster-card__gauge--stamina .roster-card__gauge-fill { background:linear-gradient(90deg,#1d4ed8,#38bdf8) }

/* ═══ Attribute rows ══════════════════════════════════════════════════════════ */
.attribute-row { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.4rem 1rem; padding:.6rem .4rem; border-bottom:var(--hair) solid rgba(46,27,16,.2) }
.attribute-row__info     { display:flex; gap:.5rem; align-items:center; min-width:0 }
.attribute-row__label    { font-weight:600 }
.attribute-row__controls { display:flex; gap:.5rem; flex-shrink:0 }
/* Narrow phones: the longest label ("Стремительность") + icon + value leaves
   too little room on the same line for both +/- buttons — they'd either
   overlap column-wise across rows (misaligned) or clip past the dialog's
   edge. Stack info above controls instead of squeezing them side by side. */
@media(max-width:480px) {
  .attribute-row { flex-direction:column; align-items:stretch }
  .attribute-row__controls { justify-content:space-between }
  .attribute-row__button { flex:1 1 0; padding:.35rem .5rem }
}
/* Extends .ui-button's bronze/gold gradient (kept, not overridden) — only the
   border is tinted so +/- stay visually distinguishable without looking like
   a different button system. */
.attribute-row__button { gap:.3rem; padding:.35rem .9rem }
.attribute-row__button--increase { border-color:rgba(34,197,94,.7) }
.attribute-row__button--decrease { border-color:rgba(220,38,38,.7) }
.attribute-row__button--disabled { opacity:.5; cursor:not-allowed; transform:none }
.attribute-row__cost   { font-size:.8rem }

/* ═══ Stat info icon + tooltip ═══════════════════════════════════════════════
   #stat-tooltip lives at the end of <body> (not nested in either stats
   modal), position:fixed, positioned in JS — both stat modals' containers
   (.dialog-panel.panel-grid, .effects-modal__container) clip overflow, so a
   tooltip nested inside a row would get cut off. */
.stat-info-icon {
  width:1.1rem; height:1.1rem; flex-shrink:0; border-radius:50%;
  border:var(--hair) solid var(--panel-border); background:rgba(0,0,0,.15);
  color:inherit; font-size:.7rem; font-style:italic; line-height:1;
  display:inline-flex; align-items:center; justify-content:center; cursor:pointer;
}
.stat-tooltip {
  position:fixed; z-index:200; max-width:min(80vw, 20rem);
  background:var(--panel-bg); border:calc(3*var(--u)) solid var(--panel-border);
  border-radius:calc(8*var(--u)); padding:calc(10*var(--u)) calc(14*var(--u));
  box-shadow:0 4px 16px rgba(0,0,0,.4);
}
.stat-tooltip.hidden { display:none }
.stat-tooltip__close {
  position:absolute; top:.25rem; right:.25rem; width:1.4rem; height:1.4rem;
  border:none; background:transparent; cursor:pointer; font-size:.8rem; color:inherit;
}
.stat-tooltip__name { margin:0 1.4rem .3rem 0; font-weight:700 }
.stat-tooltip__desc { margin:0; font-size:.85rem; line-height:1.4 }

/* ═══ Effect cards (effects modal) ═══════════════════════════════════════════ */
.effect-card {
  display:flex; align-items:center; gap:calc(14*var(--u)); padding:calc(12*var(--u)) calc(14*var(--u));
  border-radius:calc(12*var(--u)); background:rgba(255,255,255,.4); border:var(--hair) solid rgba(80,46,2,.3);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.6), 0 2px 6px rgba(0,0,0,.1); transition:background .15s ease;
}
.effect-card:hover { background:rgba(255,255,255,.58) }
.effect-card__icon { width:calc(44*var(--u)); height:calc(44*var(--u)); min-width:calc(44*var(--u)); flex-shrink:0; border-radius:50%; font-size:calc(22*var(--u)); background:rgba(100,62,8,.15); border:calc(2*var(--u)) solid rgba(171,110,21,.55) }
.effect-card__body  { flex:1; min-width:0; display:flex; flex-direction:column; gap:calc(3*var(--u)) }
.effect-card__name  { font-weight:700; font-size:clamp(.85rem,2vw,1rem) }
.effect-card__type  { font-size:clamp(.72rem,1.7vw,.85rem) }
.effect-card__meta  { display:flex; flex-direction:column; align-items:flex-end; gap:calc(4*var(--u)); flex-shrink:0 }
.effect-card__value { font-weight:700; font-size:clamp(.85rem,2vw,1rem); white-space:nowrap; padding:calc(2*var(--u)) calc(10*var(--u)); border-radius:999px; background:rgba(100,62,8,.1) }
.effect-card__value--negative { color:#7a1b0c }
.effect-card__value--positive { color:#1f5f2e }
.effect-card__value--neutral  { color:#2c4a7a }
.effect-card__duration { font-size:clamp(.68rem,1.5vw,.78rem); white-space:nowrap }

/* ═══ Resource chips (Inventory panel, under Items) ══════════════════════════ */
.item-panel__resources { display:flex; flex-direction:column; gap:calc(6*var(--u)); flex:0 0 auto }
.item-panel__resources-title { margin:0; font-size:clamp(.85rem,1.8vw,1rem) }
.resource-chip-list {
  display:flex; flex-wrap:wrap; gap:calc(6*var(--u)); padding:calc(8*var(--u));
  border-radius:calc(10*var(--u)); background:rgba(255,255,255,.22); box-shadow:inset 0 2px 8px rgba(0,0,0,.12);
}
.resource-chip {
  display:flex; align-items:center; gap:calc(4*var(--u)); padding:calc(3*var(--u)) calc(8*var(--u));
  border-radius:999px; background:rgba(255,255,255,.4); border:var(--hair) solid rgba(80,46,2,.3);
  font-size:clamp(.68rem,1.5vw,.8rem);
}
.resource-chip__icon-image { width:calc(16*var(--u)); height:calc(16*var(--u)); object-fit:contain; border-radius:50% }
.resource-chip__icon-unicode { font-size:calc(14*var(--u)); line-height:1 }
.resource-chip__name   { color:var(--text-muted) }
.resource-chip__amount { font-weight:700 }
/* Shown only on resources the server marks consumable (mushrooms heal). */
.resource-chip__use {
  margin-left:calc(2*var(--u)); padding:calc(1*var(--u)) calc(6*var(--u));
  border-radius:999px; border:var(--hair) solid var(--panel-border);
  background:var(--panel-rim); color:var(--paper-bg);
  font:inherit; font-size:.9em; font-weight:700; line-height:1.4; cursor:pointer;
}
.resource-chip__use:hover { filter:brightness(1.15) }

/* ═══ Autoplay ════════════════════════════════════════════════════════════════ */
.autoplay-slots-list { list-style:none; padding:0; margin:0 0 calc(10*var(--u)); display:flex; flex-direction:column; gap:calc(6*var(--u)) }
.autoplay-slot { display:flex; align-items:center; gap:calc(8*var(--u)); padding:calc(6*var(--u)) calc(10*var(--u)); background:rgba(100,62,8,.06); border-radius:calc(6*var(--u)); font-size:clamp(.8rem,1.9vw,.9rem) }
/* The slot the rotation is currently on. A left border rather than an outline
   or box-shadow: the modal's ancestors clip anything past the border box. */
.autoplay-slot[data-running="true"] { border-left:calc(3*var(--u)) solid var(--panel-border); background:rgba(100,62,8,.14) }
.autoplay-slot__label { flex:1 }
.autoplay-slot__status { font-size:.85em; white-space:nowrap }
.autoplay-slot__remove { background:none; border:none; cursor:pointer; color:var(--panel-dark); opacity:.6; font-size:1em; padding:0 calc(4*var(--u)) }
.autoplay-slot__remove:hover { opacity:1 }
.autoplay-add-row { display:flex; gap:calc(8*var(--u)); align-items:center; flex-wrap:wrap }
.autoplay-prob-input { width:calc(80*var(--u)); min-width:60px }
.autoplay-minutes-input { width:calc(70*var(--u)); min-width:56px }

/* ═══ Responsive ══════════════════════════════════════════════════════════════ */
@media(max-width:768px) {
  .game-screen { padding:1.25rem; gap:1.25rem }
  .roster[data-variant="expanded"] { grid-template-columns:repeat(auto-fit,minmax(160px,1fr)) }
  .roster-card--compact { flex-direction:column; align-items:flex-start }
}
@media(max-width:640px) {
  .roster-panel__toggle { align-self:stretch }
  .roster[data-variant="compact"] { grid-template-columns:repeat(auto-fit,minmax(140px,1fr)) }
}

/* ═══ Battle ══════════════════════════════════════════════════════════════════ */
.battle-overlay {
  /* ───────────────────────────────────────────────────────────────────────────
     BATTLE THEME — edit these ~15 vars to re-skin the whole battle UI.
     Default = "darkened bronze": dark + warm so the neon threads glow, but
     keeping Furnado's gold/bronze accents + Georgia serif. Colours never live in
     JS — battle.js only sets which GROUP a thread/card belongs to.
     ─────────────────────────────────────────────────────────────────────────── */
  --bt-bg:            #160c02;                 /* overlay / arena backdrop */
  --bt-arena-glow:    rgb(171 110 21 / .16);   /* faint central ritual glow */
  --bt-panel:         rgb(40 26 8 / .82);      /* card / log glass */
  --bt-panel-border:  rgb(171 110 21 / .55);
  --bt-accent:        #eed593;                 /* gold — titles, self, sigils */
  --bt-text:          #f5e3bd;
  --bt-text-muted:    rgb(245 227 189 / .68);
  --bt-thread-ours:   hsl(190 88% 66%);        /* OUR attack lines (cyan) */
  --bt-thread-theirs: hsl(354 86% 62%);        /* THEIR attack lines (red) */
  --bt-glow:          .85rem;                  /* base neon glow radius */
  --bt-card-min:      calc(44*var(--u));       /* min card height when many (1v8); few → cards fill the column */

  position:fixed; inset:0; z-index:60; display:flex; align-items:center; justify-content:center;
  padding:calc(12*var(--u)); color:var(--bt-text);
  background:
    radial-gradient(circle at 50% 46%, var(--bt-arena-glow), transparent 60%),
    color-mix(in srgb, var(--bt-bg), transparent 8%);
}
.battle-overlay__frame { width:100%; max-width:calc(960*var(--u)); max-height:96vh; display:flex }
.battle-screen {
  flex:1; display:flex; flex-direction:column; gap:calc(10*var(--u));
  padding:calc(14*var(--u)); max-height:96vh; overflow:auto;
}
/* Dark-theme the shared panel chrome — scoped so the rest of the game is untouched */
.battle-overlay .panel-wrap,
.battle-overlay .panel-grid { background:var(--bt-panel); border-color:var(--bt-panel-border); color:var(--bt-text) }

.battle-header { display:flex; align-items:center; justify-content:space-between; gap:1rem }
.battle-header__title { margin:0; font-size:clamp(1.1rem,3vw,1.6rem); color:var(--bt-accent) }

/* Timer — value centred over its track so the lone number reads intentionally */
.battle-timer { display:flex; flex-direction:column; align-items:center; gap:.2rem; min-width:calc(140*var(--u)) }
.battle-timer__value { font-weight:600; text-align:center; font-variant-numeric:tabular-nums; color:var(--bt-accent) }
.battle-timer__track { position:relative; width:100%; height:.5rem; border-radius:999px; background:rgba(0,0,0,.35); overflow:hidden }
.battle-timer__fill { position:absolute; inset-block:0; left:0; width:100%; border-radius:inherit; background:linear-gradient(90deg,var(--bt-accent),#b87333); transition:width .25s linear }

.battlefield {
  position:relative; display:grid; grid-template-columns:1fr 1fr;
  gap:clamp(calc(80*var(--u)),10vw,calc(160*var(--u)));
  align-items:stretch; block-size:min(70vh,calc(560*var(--u)));
}
.battle-side__title { margin:0 0 .5rem; text-align:center; font-size:.9rem; color:var(--bt-text-muted) }
.battle-side { display:flex; flex-direction:column; min-block-size:0 }
/* Each side sets its own --count (its own headcount) from JS, so the two columns
   size independently: few on a side → tall cards, many → short cards. */
.combatant-list {
  flex:1; display:grid; gap:calc(6*var(--u)); min-block-size:0;
  grid-template-rows:repeat(var(--count,1), minmax(var(--bt-card-min), 1fr));
  align-content:stretch;
}

/* Magic threads overlay — soft-curve SVG lines from attacker to target (lab v8).
   Colour comes from the group class below; never set inline by JS. */
.battle-threads {
  position:absolute; inset:0; width:100%; height:100%;
  overflow:visible; pointer-events:none; z-index:1; mix-blend-mode:screen;
}
.svg-thread-group--ours   { --thread-color:var(--bt-thread-ours) }
.svg-thread-group--theirs { --thread-color:var(--bt-thread-theirs) }
.svg-thread { fill:none; stroke-linecap:round; stroke-linejoin:round; vector-effect:non-scaling-stroke }
.svg-thread--aura {
  stroke:var(--thread-color); stroke-width:10; stroke-opacity:.18;
  filter: blur(4px)
    drop-shadow(0 0 calc(.55*var(--bt-glow)) var(--thread-color))
    drop-shadow(0 0 calc(1.2*var(--bt-glow)) color-mix(in srgb, var(--thread-color), transparent 35%));
}
.svg-thread--body {
  stroke:var(--thread-color); stroke-width:3; stroke-opacity:.62;
  filter: drop-shadow(0 0 calc(.4*var(--bt-glow)) var(--thread-color))
    drop-shadow(0 0 var(--bt-glow) color-mix(in srgb, var(--thread-color), transparent 42%));
}
.svg-thread--core {
  stroke:color-mix(in srgb, var(--thread-color), white 28%); stroke-width:1.2; stroke-opacity:.96;
  filter: drop-shadow(0 0 .18rem var(--thread-color));
}

/* Cards: each fills its grid cell (block-size:100%) so both sides match height.
   3 columns — portrait | copy | marks. container-type lets dense battles drop
   secondary text as cards shrink. */
.combatant-card {
  position:relative; z-index:2; block-size:100%; min-block-size:0;
  border-width:calc(3*var(--u));
  background:var(--bt-panel); border-color:var(--bt-panel-border); color:var(--bt-text);
  container:bt-card / size; overflow:hidden;
}
/* Card body holds the layout so the card itself can act as the query container:
   a container query can't restyle its own container, so the body flips between
   the compact row (default) and the tall vertical column (few combatants). */
/* Pinned to the card box (not block-size:100%) so its height is always definite:
   the percentage-height chain through the flex/grid ancestors doesn't resolve,
   which let the body size to content and overflow the card (clipping the intent). */
.combatant-card__body {
  position:absolute; inset:0; display:grid; grid-template-columns:auto minmax(0,1fr);
  align-items:center; gap:.6rem; padding:calc(6*var(--u)) calc(8*var(--u));
}
.combatant-card--enemy { cursor:pointer }
.combatant-card--enemy:hover { filter:brightness(1.12) }
/* Self: same card as everyone else, just a subtle gold rim to find yourself.
   Doubled class beats `.battle-overlay .panel-grid` which sets the base border. */
.combatant-card.combatant-card--self { border-color:var(--bt-accent) }
.combatant-card--targeted { border-color:var(--bt-accent); box-shadow:0 0 var(--bt-glow) color-mix(in srgb, var(--bt-accent), transparent 55%) }
.combatant-card--attacking { box-shadow:0 0 calc(10*var(--u)) color-mix(in srgb, var(--bt-thread-ours), transparent 60%) }
.combatant-card--down { opacity:.45; filter:grayscale(.6) }
.combatant-card__portrait {
  position:relative; aspect-ratio:1; block-size:min(80%, calc(112*var(--u)));
  min-block-size:1.8rem; border-radius:50%; overflow:visible; flex-shrink:0;
  border:2px solid var(--bt-panel-border); background:rgba(0,0,0,.25); justify-self:center;
}
.combatant-card__portrait img { width:100%; height:100%; border-radius:50%; object-fit:cover }
.combatant-card__sigil {
  position:absolute; top:-.35rem; right:-.35rem; display:grid; place-items:center;
  width:1.2rem; height:1.2rem; border:1px solid currentColor; border-radius:50%;
  color:var(--bt-accent); background:rgba(5,4,10,.84);
  box-shadow:0 0 .7rem currentColor; font-weight:800; font-size:.72rem; line-height:1;
}
.combatant-card__main { min-width:0; display:flex; flex-direction:column; justify-content:center; gap:.25rem }
.combatant-card__name { font-weight:600; font-size:.85rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
/* Marks overlay a corner (out of grid flow) so attack marks popping in/out at
   round start never squeeze the main column and shift the avatar/name/HP. */
.combatant-card__marks { position:absolute; top:calc(6*var(--u)); right:calc(6*var(--u)); z-index:1; display:flex; flex-wrap:wrap; gap:.16rem; min-height:0; justify-content:flex-end; align-content:center; max-width:3.4rem }
.combatant-card__marks:empty { display:none }
.combatant-card__mark {
  display:inline-grid; place-items:center; width:1.05rem; height:1.05rem;
  border:1px solid currentColor; border-radius:50%; background:rgba(5,4,10,.75);
  color:var(--bt-thread-ours); font-size:.7rem; line-height:1;
  text-shadow:0 0 .7rem currentColor; box-shadow:0 0 .5rem currentColor;
}
/* Intent line always reserves its row (even when empty) so picking an action
   never reflows the card or shifts the portrait. */
.combatant-card__intent { font-size:.72rem; color:var(--bt-text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-height:1.2em; flex-shrink:0 }
/* Few combatants (or a tall desktop column) → portrait card: big rounded-rect
   avatar on top, name → HP num/num → bar stacked beneath. Card height tracks
   count (column ÷ count) and screen size, so this flips off on its own as the
   field fills up or the viewport shrinks — no JS. */
@container bt-card (min-height: 140px) {
  .combatant-card__body { display:flex; flex-direction:column; align-items:center; text-align:center; gap:calc(4*var(--u)) }
  .combatant-card__portrait { flex:1 1 auto; width:100%; block-size:auto; aspect-ratio:auto; min-block-size:0; border-radius:calc(10*var(--u)) }
  .combatant-card__portrait img { border-radius:inherit }
  /* main keeps its full height (name → HP → intent); the portrait yields, so the
     intent line never gets pushed past the card edge */
  .combatant-card__main { align-items:center; flex-shrink:0 }
  /* attack-marks pinned to a corner so the portrait stays dominant */
  .combatant-card__marks { position:absolute; top:calc(6*var(--u)); left:calc(6*var(--u)); right:auto; max-width:none; justify-content:flex-start }
}

/* Shrink gracefully: tight cards drop the intent line, very tight also the name */
@container bt-card (max-height: 64px) { .combatant-card__intent { display:none } }
@container bt-card (max-height: 50px) { .combatant-card__name { font-size:.78rem } }

.battle-command { display:flex; flex-direction:column; gap:.5rem; align-items:center }
.battle-actions { display:flex; flex-wrap:wrap; gap:.5rem; justify-content:center }
.battle-action.is-selected { background:linear-gradient(135deg,var(--bt-accent),#b87333); box-shadow:0 0 0 2px var(--bt-accent); color:#160c02 }
.battle-command__hint { margin:0; font-size:.85rem; color:var(--bt-text-muted); text-align:center; min-height:1.2em }
.battle-command__buttons { display:flex; gap:.6rem; flex-wrap:wrap; justify-content:center }

.battle-log { padding:calc(8*var(--u)); max-height:calc(160*var(--u)); overflow:auto }
.battle-log__title { margin:0 0 .4rem; font-size:.85rem; color:var(--bt-text-muted) }
.battle-log__list { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.2rem; font-size:.8rem }
.battle-log__entry { display:flex; gap:.4rem }
.battle-log__round { color:var(--bt-text-muted); flex-shrink:0; font-variant-numeric:tabular-nums }

@media(max-width:480px) {
  .combatant-card__name { font-size:.78rem }
  .battle-screen { padding:calc(10*var(--u)) }
}

/* ============================================================
   Sprite animations (js/sprite.js, atlases from tools/imgpipe)
   ============================================================
   One atlas image, one animation per row. Playback is a single shared
   keyframe that slides the row horizontally; the row itself is selected
   once by --row-offset. Every value below is written by mountSprite(),
   except animation-timing-function, which it sets directly as an inline
   style — see the note in sprite.js for why it is not steps(var(--frames)).

   background-size is pinned to the atlas's scaled dimensions so the sprite
   can be rendered at any size without the browser tiling or fitting it. */
.sprite {
  display: block;
  width: var(--frame-w);
  height: var(--frame-h);
  background-image: var(--sprite-url);
  background-repeat: no-repeat;
  background-size: var(--atlas-w) var(--atlas-h);
  background-position: 0 var(--row-offset);
  animation-name: sprite-run;
  animation-duration: var(--dur);
  animation-iteration-count: var(--iterations, infinite);
  animation-fill-mode: forwards;   /* a non-looping animation holds its last frame */
}

/* Pixel-art atlases ship at their true resolution, so the browser is doing
   the upscaling and must not interpolate while it does. */
.sprite--px { image-rendering: pixelated }

/* steps(n) with the default `end` stops one step short of the final value for
   as long as the animation keeps running, so a looping row shows its last
   frame and never the cell past it. A one-shot animation DOES reach that final
   value at the end and, with fill-mode:forwards, holds it — which is why
   mountSprite() gives a one-shot animation an (n-1)-step slide instead, so the
   value it lands on and holds is its own last frame. */
/* Both axes, via the `background-position` shorthand, even though only x moves.
   The base rule sets the shorthand and a keyframe that animated the
   `background-position-x` LONGHAND alone left the y axis — i.e. --row-offset,
   the row selector — to be resolved from the base rule mid-animation. WebKit
   has historically dropped the un-animated axis on that mix, which plays every
   sprite on row 0 whatever row was asked for. Carrying --row-offset explicitly
   through both keyframes costs nothing and cannot lose the row. */
@keyframes sprite-run {
  from { background-position: 0 var(--row-offset) }
  to   { background-position: calc(-1 * var(--frames) * var(--frame-w)) var(--row-offset) }
}

/* `.sprite` deliberately does NOT honour prefers-reduced-motion — do not
   "helpfully" add it back. It used to be `animation-play-state: paused` here,
   which froze every character on frame 0 of whichever row was mounted. That
   read as a bug rather than an accommodation: work-sprite.js's payout beat goes
   on swapping rows, and think/stash are deliberately single-frame poses with
   animation-name:none, so they still rendered correctly — the player got a
   four-image slideshow where half the poses were right and the animated ones
   never moved. iOS Reduce Motion is switched on by a lot of people for
   unrelated reasons (it also kills the springboard zoom), so that silently
   removed the game's main work feedback for a large share of iPhone users.
   The query is for vestibular triggers — parallax, viewport-scale zooms, big
   slides. This is a ~52px drawing looping at 2 fps: game content, the
   equivalent of a short looping video the player started by choosing to
   gather. The genuinely vestibular animations are the ones that honour it —
   see .room-map__travel-avatar-wrap above and playRewardBadge in
   js/reward-fx.js. */

/* ═══ Level-up card (js/level-up.js) ══════════════════════════════════════════
   Pure celebration: the level and its payout were granted server-side long
   before this appears (see the header of js/level-up.js). Appended to
   document.body like #reward-fx-layer, for the same overflow:hidden reason.
   Click dismisses, so pointer-events stay on — unlike the reward FX layer,
   this one is meant to be interacted with. */
.level-up {
  position:fixed; left:50%; top:38%; transform:translate(-50%,-50%);
  z-index:320; cursor:pointer; text-align:center;
  display:flex; flex-direction:column; align-items:center; gap:calc(8*var(--u));
  /* Sized in the clamp(floor, calc(N*--u), ceiling) idiom the work popover
     documents (docs/activity-card.md, "Sizing"): --u ≈ 0.39 on a 390px phone,
     so a bare calc() term collapses the card to ~160px there. The floors are
     what the phone actually gets; the ceilings are the desktop size. */
  padding:clamp(12px, calc(18*var(--u)), 22px) clamp(18px, calc(30*var(--u)), 36px);
  min-width:min(calc(100vw - 32px), clamp(240px, calc(220*var(--u)), 280px));
  max-width:min(calc(100vw - 32px), clamp(300px, calc(420*var(--u)), 420px));
  border-radius:calc(18*var(--u));
  background:linear-gradient(180deg, rgba(255,248,225,.98), rgba(214,176,103,.98));
  border:var(--hair) solid var(--panel-border);
  outline:calc(3*var(--u)) solid var(--panel-rim);
  box-shadow:0 calc(6*var(--u)) calc(24*var(--u)) rgba(0,0,0,.4), 0 0 calc(30*var(--u)) rgba(255,214,120,.75);
  color:var(--text); font-family:var(--font-fantasy);
  animation:level-up-in .45s cubic-bezier(.2,.9,.3,1.3) both;
}
.level-up--leaving { animation:level-up-out .42s ease-in forwards }

/* The sparks from js/items.js's spawnUpgradeSparks land here: they position
   themselves against the nearest positioned ancestor, so this box is what
   centres the burst on the card instead of on the page. */
.level-up__burst { position:absolute; inset:0; pointer-events:none }

.level-up__title  { font-size:clamp(.9rem, calc(17*var(--u)), 1.15rem); letter-spacing:.04em; text-transform:uppercase; opacity:.8 }
.level-up__level  { font-size:clamp(2rem, calc(64*var(--u)), 4rem); font-weight:800; line-height:1; text-shadow:0 calc(2*var(--u)) 0 rgba(255,255,255,.6) }
.level-up__rewards{ display:flex; flex-wrap:wrap; justify-content:center; gap:calc(6*var(--u)) }
.level-up__reward {
  display:inline-flex; align-items:center; gap:clamp(4px, calc(5*var(--u)), 7px);
  padding:clamp(3px, calc(4*var(--u)), 6px) clamp(7px, calc(10*var(--u)), 13px); border-radius:calc(12*var(--u));
  background:rgba(255,255,255,.45); border:var(--hair) solid var(--panel-border);
  font-size:clamp(.75rem, calc(14*var(--u)), .95rem); white-space:nowrap;
}
.level-up__reward-icon-image { width:1.3em; height:1.3em; object-fit:contain }
.level-up__reward-icon       { font-size:1.15em; line-height:1 }

@keyframes level-up-in {
  0%   { opacity:0; transform:translate(-50%,-50%) scale(.6) }
  100% { opacity:1; transform:translate(-50%,-50%) scale(1) }
}
@keyframes level-up-out {
  0%   { opacity:1; transform:translate(-50%,-50%) scale(1) }
  100% { opacity:0; transform:translate(-50%,-62%) scale(.92) }
}
/* The card carries information (which level, what it paid), so reduced motion
   keeps it and drops only the springy entrance — same rule as the reward
   badge in js/reward-fx.js. The sparks are skipped in JS. */
@media (prefers-reduced-motion: reduce) {
  .level-up          { animation-duration:.01ms }
  .level-up--leaving { animation-duration:.2s }
}

/* Experience bar in the header level cell. .cell has overflow:hidden, so this
   is a background fill rather than a child element or a box-shadow (see the
   .reward-fx-pulse note above). js/character.js sets --level-progress.
   Kept to a strip along the bottom edge rather than filling the whole cell:
   a full-height tint next to the neighbouring stat cells reads as a rendering
   glitch, a strip reads as a bar. */
.cell--level {
  --level-progress:0%;
  background-image:
    linear-gradient(90deg, var(--panel-border) var(--level-progress), rgba(80,46,2,.18) var(--level-progress));
  background-size:100% calc(4*var(--u) + 2px);
  background-position:left bottom;
  background-repeat:no-repeat;
}
