/* Arena console — the app frame. One cockpit on every route: the unified
   grid that owns navigation and frames the view:
     #shell.shell
       > #hud.shell-hud          top bar (keeps its .topbar class/chrome)
       + #rail.shell-rail        section rail (hidden < 800px)
       + #scene.shell-scene      view host (#view keeps .container)
       + #ctx.shell-ctx          context column (hidden < 800px) — the mock's
                                 composition, scrolling as one surface
       + #marquee.shell-marquee  ticker strip — HIDDEN on every route (the
                                 mock has no marquee row; its payload feeds
                                 the column's «Лента» tab)
       + #act.shell-act          action bar — gamepad hints + the PiP return
       + #tabbar.shell-tabbar    mobile nav (hidden >= 800px)
   Load after components.css; reuses tokens.css. Every selector is scoped to
   .shell or a shell zone id, so this file is inert until the shell markup
   exists.

   THE FRAME NO LONGER SCROLLS, and this is the change everything else rests
   on. The old shell was `min-height: 100dvh` with #marquee and #tabbar as
   position:fixed escapees and per-breakpoint padding-bottom on the scene to
   clear them (comments C4/C5). Now .shell is EXACTLY 100dvh with
   overflow:hidden, marquee and tabbar are real grid rows, and the
   compensations are gone because there is nothing left to compensate for.

   The safety wedge that makes that survivable: #scene is itself a scroll
   container (min-height:0; overflow-y:auto). Every existing view keeps
   working untouched — it scrolls inside the scene instead of inside the
   document. Converting a view to inner scrollers is that screen lane's own
   work in its own file, not a precondition for this frame.

   Z-map: hud var(--z-topbar)=50 > tabbar 45 > marquee 44 > act 43 > ctx 41 >
   rail 40 > scene.

   Every zone needs a z-index of its own, and the reason is easy to get wrong.
   `.bg-scene` is a FIXED, opaque, full-viewport gradient at z-index 0 that
   precedes #shell in the document (base.css:85). A positioned box at z-index 0
   paints above the backgrounds of ordinary static content, so a zone that
   declares nothing is painted UNDERNEATH the app background and simply is not
   there. #ctx and #act shipped that way: on a phone it hid the «Вернуться»
   row, the one control that carries a wandering player back into a live match.

   What saves the others is the z-index alone, not `position` — z-index applies
   to grid and flex items whether or not they are positioned, which is why
   #rail works with `z-index: 40` and no position at all. The `position:
   relative` on these zones is belt and braces, and matches #hud and #scene.
   The load-bearing line is the z-index: add a zone without one and it vanishes
   into the background. */

/* === shell grid ===
   Rows: hud / scene / marquee / act / tabbar.
   `act` and `tabbar` are `auto` rather than fixed heights so an empty zone
   costs zero pixels — #act is 54px only when it is carrying something (see
   below), and #tabbar is display:none above 800px.

   --- safe area ---
   index.html ships `viewport-fit=cover`, which is what makes the console paint
   under the notch and the home indicator in the first place — and
   `env(safe-area-inset-*)` is then the only thing that puts it back. Until now
   the frame spent exactly one of the four: `bottom`, on the tab bar and the
   sheet. Bottom is the PORTRAIT inset. Turn the phone and the reserved bands
   move to the SHORT sides, i.e. left and right, where this grid had nothing at
   all.

   Measured at 844x390 with a notched phone's real landscape insets (47 left,
   47 right, 21 bottom) over CDP, on all 33 routes: every one of the rail's
   items, the crown that is the door back to the hub, the identity block, the
   notification bell and the whole right edge of the context column were inside
   a band the device does not render into. Not a gutter — the rail is 62px
   wide and starts at x = 0, so 47 of it was simply not on the screen.

   One rule instead of six, because every zone is a child of this grid: the
   frame is still exactly 100dvh (border-box, base.css) and the COLUMNS give
   the pixels back. It is inert wherever the insets are zero, which is every
   desktop browser and every measurement in the ladder below.

   TOP IS THE FOURTH, AND IT IS NOT A COLUMN — IT IS THE HUD'S OWN HEIGHT.
   `top` is the PORTRAIT inset: turn a notched phone upright and the reserved
   band moves back to the top edge, where this grid had nothing. Measured at
   390x844 with a 47px top inset over CDP, on all 34 routes: `#hud` at y=0..58
   and its contents — the brand, the counter pills, 🔔, 👤 — at y=6.5..50.5, so
   3.5px of a 44px target cleared the band and everything else on the bar was
   inside a strip the device does not render into. Every screen in the
   product, both bars. After, on the same 34: `#hud` 0..105 with 47px of
   padding, its contents 53.5..97.5, `#scene` 58 -> 105 and 47px shorter, and
   not one element on any route moved by anything other than +47 in y.
   The pixels are given back by the HUD ROW rather than by `padding-top` on
   this grid, and the difference is a seam. `#hud` paints its own
   `rgba(18,12,34,.9) → rgba(12,8,24,.72)` glass; padding on `.shell` would
   leave the band above it showing `.bg-scene` (`--bg`, #08060f) instead — a
   different colour touching the bar, which is the exact thing js/main.js
   refuses to do when it sets Telegram's header to #110b20 («the header has to
   match what it touches, not what is two layers under it, or the seam just
   moves»). Growing the row and padding the bar puts the top of the bar's own
   gradient at y=0 and its content below the inset, so there is no band.
   Inert wherever the inset is zero — every desktop browser, every landscape
   phone (iOS reports top=0 there, which is why the landscape lane could leave
   this one) and every measurement in the ladder below. */
.shell {
  display: grid;
  height: 100dvh;
  overflow: hidden;
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
  grid-template-columns: var(--rail-w) minmax(0, 1fr) var(--ctx-w);
  grid-template-rows: calc(var(--hud-h) + env(safe-area-inset-top)) minmax(0, 1fr) auto auto auto;
  grid-template-areas:
    "hud     hud     hud"
    "rail    scene   ctx"
    "marquee marquee marquee"
    "act     act     act"
    "tabbar  tabbar  tabbar";
}

/* === hud (topbar role) ===
   #hud keeps its existing .topbar class from components.css (glass, flex,
   border-bottom, inner logo/.topbar-right layout). Here it is only re-seated
   from position:fixed into the grid flow; the id selector outranks .topbar so
   the fixed positioning does not fight the grid. The sticky it used to carry
   existed only to survive document scroll — there is none now. */
#hud.shell-hud {
  grid-area: hud;
  position: relative;
  top: auto;
  left: auto;
  right: auto;
  z-index: var(--z-topbar);
  /* Height and padding move together with the grid row above (§safe area):
     the bar is `--hud-h` of CONTENT sitting under `env(safe-area-inset-top)`
     of its own gradient, not a shorter bar pushed down. `align-items: center`
     then centres the row inside the content box, so nothing on the bar shifts
     relative to the bar itself. Both terms are zero on every window that has
     no notch, where this resolves to the plain 58px it always was. */
  height: calc(var(--hud-h) + env(safe-area-inset-top));
  /* MEASURED FROM THE DESIGN, not adjusted by eye: the bar is a 16px-gapped
     row padded 14px on the left and 18px on the right, and the right-hand
     cluster is pushed over by a flexible gap rather than by
     `justify-content: space-between`. The difference matters because the bar
     now carries THREE groups, not two — brand, then season and fire, then the
     spacer — and space-between would have spread the season block into the
     middle of the window instead of leaving it beside the brand. */
  justify-content: flex-start;
  gap: 16px;
  padding: env(safe-area-inset-top) 18px 0 14px;
  background: linear-gradient(180deg, rgba(18, 12, 34, 0.9), rgba(12, 8, 24, 0.72));
}
#hud.shell-hud .topbar-right { margin-left: auto; gap: 8px; }

/* The brand: a 30px gold tile carrying the crown, then the wordmark. The
   crown used to be a bare 24px glyph pulsing on the background and the name a
   17px neon gradient — twice the size of anything else on the bar, and the
   first thing the eye landed on was the logo rather than the season. */
#hud .topbar-logo { gap: 9px; padding: 0; }
#hud .topbar-logo:hover { background: none; }
#hud .topbar-crown {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  display: grid;
  place-items: center;
  border-radius: var(--r-sm);
  background: linear-gradient(135deg, var(--gold), #c98218);
  color: var(--gold-ink);
  font-size: 16px;
  line-height: 1;
  box-shadow: 0 0 18px rgba(var(--t-gold), 0.45);
  animation: none;
}
#hud .topbar-name {
  font-family: var(--font-display);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.03em;
  background: none;
  -webkit-text-fill-color: currentColor;
  color: var(--text);
}

/* === HUD left: the season chapter and the fire chip ===
   Everything that belongs to the LEFT of the flexible gap. #hudMeta cannot
   carry these — it lives inside `.topbar-right` — so the shell mints this node
   as a direct child of the header, immediately after the logo. */
#hud .hud-left {
  display: flex;
  align-items: center;
  gap: 16px;
  /* NEVER SHRINKS, and that is a diagnostic choice as much as a visual one.
     Left shrinkable this was the only elastic item in the row, so every width
     the bar could not afford was paid for by silently crushing the season
     block — the countdown spilled out of a 0px box and slid under the counters
     with nothing measurable to show for it. Rigid, an overspent bar overflows
     where `#hud.scrollWidth - clientWidth` can see it, and the ladder below is
     what keeps that at zero. */
  flex: 0 0 auto;
}
#hud .hud-left:empty { display: none; }
#hud .hud-season {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
#hud .hud-sep {
  flex: 0 0 auto;
  width: 1px;
  height: 22px;
  background: rgba(var(--t-violet), 0.2);
}
#hud .hud-season-word {
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-faint);
  white-space: nowrap;
}
#hud .hud-season-name {
  font-family: var(--font-display);
  font-size: 12px;
  font-weight: 600;
  color: var(--violet-300);
  white-space: nowrap;
}
#hud .hud-season-left {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: rgba(var(--t-mute), 0.44);
  white-space: nowrap;
}

/* The fire chip. No countdown and no button in the design, and none here: a
   streak that tells you when it expires is a threat rather than a reward. */
#hud .hud-fire {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
  padding: 5px 11px;
  border-radius: var(--r-pill);
  border: 1px solid rgba(var(--t-gold), 0.26);
  background: rgba(var(--t-gold), 0.06);
  transition: transform 0.18s var(--spring), background 0.16s, border-color 0.16s;
}
#hud .hud-fire.is-hot {
  border-color: rgba(var(--t-gold), 0.42);
  background: rgba(var(--t-gold), 0.1);
}
@media (hover: hover) {
  #hud .hud-fire:hover { transform: translateY(-1px); }
}
#hud .hud-fire-glyph { font-size: 13px; line-height: 1; }
#hud .hud-fire:not(.is-hot) .hud-fire-glyph { opacity: 0.4; filter: grayscale(1); }
#hud .hud-fire-num {
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--gold);
}

/* === HUD tail: the store door and the player ===
   Minted as the last child of `.topbar-right`, after the five icon buttons
   index.html ships, so the bar ends on the player the way the design does. */
#hud .hud-tail {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
#hud .hud-tail:empty { display: none; }
#hud .hud-store {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
  white-space: nowrap;
  padding: 5px 12px;
  border-radius: var(--r-pill);
  border: 1px solid rgba(var(--t-violet), 0.24);
  background: rgba(var(--t-violet), 0.1);
  transition: background 0.16s, border-color 0.16s;
}
@media (hover: hover) {
  #hud .hud-store:hover {
    background: rgba(var(--t-gold), 0.16);
    border-color: rgba(var(--t-gold), 0.45);
  }
}
#hud .hud-store-glyph { font-size: 12px; line-height: 1; color: var(--gold); }
#hud .hud-store-word { font-size: 11px; font-weight: 700; color: var(--text-dim); }

/* Route/state text the HUD carries next to the connection pill. Minted by the
   chrome lane; empty and invisible until then. */
#hudMeta:empty { display: none; }
#hudMeta {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  color: var(--text-soft);
  white-space: nowrap;
  min-width: 0;
}
/* Two minted children now — the identity strip and the counters — and either
   can be empty on its own. `:empty` collapses the one that is, so the gap
   above never opens around nothing.

   Written with #hud in front deliberately: `#hudMeta > :empty` alone ties on
   specificity with `#hud .hud-me` below and loses on source order, which left
   a logged-out desktop bar carrying 10px of gap around a strip that was not
   there — a 317.91px right-hand cluster where the bar has always had one of
   307.91px. */
#hud #hudMeta > :empty { display: none; }

/* === HUD identity ===
   The player's own name, clan tag and pass level, from /arena/api/me and
   /chess/api/battlepass. Until the console grew this the arena never said the
   player's name back to them: the top bar had a 👤 icon and nothing else, so
   you could cross every screen without seeing who you were signed in as.

   Same `null` rule as the counters below, and it bites harder here: a
   placeholder name is a claim about the player's identity, so nothing renders
   until the server has answered.

   WHAT CHANGED FROM THE FLAT STRIP. This was three elements on one line —
   name, tag, worn title — that dropped one at a time as the window narrowed.
   The finished design puts them in a bordered pill with an avatar tile and
   two lines, tag and pass level sharing the second, and no worn title at all.
   The title is not lost: it is in the block's title/aria-label, and it was the
   widest element on the bar («Хранитель преданий» measures 119.2px on its own)
   and therefore the first thing to overrun it. */
#hud .hud-me {
  display: flex;
  align-items: center;
  gap: 9px;
  flex: 0 0 auto;
  min-width: 0;
  white-space: nowrap;
  padding: 4px 12px 4px 5px;
  border-radius: var(--r-pill);
  border: 1px solid rgba(var(--t-violet), 0.2);
  background: rgba(var(--t-violet), 0.1);
  transition: background 0.16s, border-color 0.16s;
}
@media (hover: hover) {
  #hud .hud-me:hover {
    background: rgba(var(--t-violet), 0.18);
    border-color: rgba(var(--t-violet), 0.42);
  }
}
#hud .hud-me-avatar {
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  font-size: 14px;
  line-height: 1;
  background: linear-gradient(135deg, var(--violet-600), #312152);
  border: 1px solid rgba(var(--t-gold), 0.4);
}
#hud .hud-me-text {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  line-height: 1.15;
  min-width: 0;
}
/* The one hard stop in the block, and it is sized so it never fires on real
   data. A Telegram username is at most 32 characters, and the widest label
   that can therefore reach here — sigil, «@», 32 characters — measures
   252.88px in this face at this size; the anonymous fallback «User 483920191»
   is 101.16px. 200px is under both, on purpose: the block now carries an
   avatar and a border as well, and the bar has five icon buttons to fit
   beside it. An ellipsis at the 32-character ceiling is the correct outcome —
   the alternative is the store pill leaving the screen. */
#hud .hud-me-name {
  max-width: 200px;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 12px;
  font-weight: 800;
  color: var(--text);
  white-space: nowrap;
}
#hud .hud-me-sub {
  max-width: 200px;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.08em;
  color: var(--gold);
  white-space: nowrap;
}

/* === HUD counters ===
   Two numbers the console keeps in front of the player at all times: who is
   here and what they can spend. The season used to be a third chip and is now
   the chapter line on the left of the bar, where the design keeps it — the
   same fact twice was costing 60px of a row that is short of it.

   `null` renders NOTHING — no chip, no placeholder dash. A counter that has
   not loaded yet must not look like a counter reading zero. */
#hud .hud-chips {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  /* Never the part that gives. Every chip inside is already `flex: 0 0 auto`,
     so shrinking this box did not shrink a counter — it only let the chips
     spill out of their own container. Width pressure belongs on the name
     above, which is the one element in the bar that can take it. */
  flex: 0 0 auto;
  min-width: 0;
}
#hud .hud-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  flex: 0 0 auto;
  padding: 3px 8px;
  border-radius: var(--r-pill);
  border: 1px solid var(--stroke);
  background: var(--panel-soft);
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
}
#hud .hud-glyph { font-size: 13px; line-height: 1; }
#hud .hud-val { color: var(--text); font-size: 12px; font-weight: 800; }
#hud .hud-word { font-size: 11px; font-weight: 500; color: var(--text-soft); }
#hud .hud-chip-online {
  padding: 5px 11px;
  gap: 7px;
  border-color: rgba(var(--t-ok), 0.3);
  background: rgba(var(--t-ok), 0.1);
  color: var(--ok);
}
/* The live dot, and it is a dot rather than a ◉ glyph because the design
   breathes it: a static ring says «this many people exist», a pulsing one says
   «this many are here now», which is the only reason the counter is on the
   bar at all. */
#hud .hud-live-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ok);
  animation: shell-pulse 2s ease-in-out infinite;
}
/* deadends-map K2: the crowns pill's rules are gone — renderHud() stopped
   minting `.hud-chip-crowns` when the ♛ economy was ruled dead (see the «NO
   CROWNS CHIP» note in ui/shell.js). */
@keyframes shell-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.45; transform: scale(0.82); }
}
@media (prefers-reduced-motion: reduce) {
  #hud .hud-live-dot { animation: none; }
}

/* === THREE BUTTONS THAT LEFT THE BAR ===
   Music, language and sign-out. The finished design's top bar carries online,
   invite, bell, store and the player — it has no music, no language
   and no power button, because none of the three is navigation and none is
   pressed more than once a session.

   They are hidden here, not deleted: the nodes stay where index.html puts
   them with every listener main.js attached intact, and the «Ещё» sheet
   renders a row that mirrors each one's state and forwards the click. The
   sheet is reachable at EVERY width — the rail's ⋯ item at >= 800px, the tab
   bar's ⋯ below it — and all three also have a second home on ДОСЬЕ.

   WHAT THIS BOUGHT, and the numbers are why it was worth doing rather than a
   matter of taste. Measured as `#hud.scrollWidth - #hud.clientWidth` in an
   authed session inside a width-pinned iframe, BEFORE:

     360  391px over      800  105px over
     390  361px over      905    0
     430  321px over     1024    0
     520  231px over     1280    0
     600  305px over     1440    0

   The bar has overflowed on every phone this arena has ever run on, and the
   cause was never the counters: five icon buttons on base.css's 44px touch
   floor plus their gaps are 260px of a 390px window before anything else is
   drawn. Three of them leaving takes 156px off a phone bar and 126px off a
   desktop one, which is also what lets the season chapter survive down to
   905px instead of 1024. After, the overflow is 0 at every width above.

   👤 goes the other way and only above 905: there the identity block IS the
   door to ДОСЬЕ, so the icon beside it is the same door twice. Below 905 the
   block is gone and the icon is the only one, so it stays. 🔔 keeps its slot
   at every width — it carries an unread count, and a notification you have to
   go looking for is not a notification. */
#hud #musicToggle,
#hud #langToggle,
#hud #logoutBtn { display: none; }
/* `min-height` is the phone-held-sideways half of the pair below: the identity
   block only replaces 👤 where the identity block actually exists, and on a
   short viewport it does not. */
@media (min-width: 905px) and (min-height: 501px) {
  #hud #profileBtn { display: none; }
}

/* === WHAT THE TOP BAR CARRIES AT WHICH WIDTH ===
   Every threshold is a measurement, not a guess, and the measurements were
   re-taken after the three buttons above left — the ladder that fits a bar
   carrying eight controls is the wrong ladder for one carrying five.

   Method: an iframe pinned to the width (headless Chrome will not honour
   --window-size below ~500px, so the window is oversized and the frame is the
   real viewport), an authed session, and `#hud.scrollWidth - #hud.clientWidth`
   read back. Content width of a bar carrying everything:

     >= 905px   957px    (icon buttons 34px; 👤 hidden, identity block instead)
     >= 905px  1052px    (the same bar WITH the connection pill)
     <= 904px   519px    (touch floor 44px; season, fire, store, identity gone)

   Made of, at 1280: 32 padding + 183.6 brand + 16 + 252.2 season and fire +
   16 + 457.4 right-hand cluster.

   MEASURE ON A SCREEN THAT OPENS A SOCKET. The 957px figure was taken on the
   hub, and #connPill ships `hidden` — so the bar it measured is not the bar
   the dashboard, a chess game or any of the eight arcade screens draws. Those
   un-hide the pill and the bar is 95px wider, which is how it came to overflow
   at 905 and again at 1024 with nothing in the ladder to catch it. Use
   `#/arcade/durak` or `#/game/<id>`, and check the 32-character username
   ceiling as well as a short one — `.hud-me-name` is capped, not elastic.

   The order things leave in is the design's own: first the words that repeat
   something already legible beside them, then whole blocks that have another
   door one tap away. Measured overflow after this ladder is 0 at 320, 360,
   390, 430, 520, 905, 960, 1023, 1024, 1100, 1279, 1280 and 1440 — on
   `#/arcade/durak`, authed, with both a 15- and a 32-character name. */

/* 905–1279: the season loses its label, the store its word and the connection
   pill its word. «СЕЗОН Июль 47д 04:11» → «Июль 47д 04:11», «★ Магазин» → «★»,
   «● синк…» → «●».

   THE THRESHOLD USED TO BE 1023 AND THAT WAS THE BUG. The 957px budget below
   was measured on a bar with no connection pill, because the screens it was
   measured on do not open a socket; the dashboard, a chess game and all eight
   arcade screens do, and re-measured with it the full bar needs 1052px for a
   15-character name and ~1150 for the 32-character ceiling `.hud-me-name`
   allows. So between 1024 and 1279 the bar was over budget and overflowed —
   measured 1052 against 1024 — and the thing hanging off the right-hand edge
   was the player's own name. The words come back at `large` (>= 1280), which
   is the only window that has always been able to afford all of them. */
@media (max-width: 1279px) {
  #hud .hud-season-word { display: none; }
  #hud .hud-store-word { display: none; }
  /* THE CONNECTION PILL WAS NEVER IN THE 957px BUDGET ABOVE. It is hidden on
     the screens the ladder was measured on and un-hidden by every screen that
     opens a socket — the dashboard, a chess game and all eight arcade screens
     — so the bar those screens draw is 40–47px wider than the one that was
     measured, and at 905 it overflowed: `#hud.scrollWidth` 952 against a
     clientWidth of 905, with the player's own name hanging off the right edge.
     Measured `ok/authed/905` on sixteen routes.
     Its word goes for exactly the reason the season's and the store's do: the
     coloured dot beside it already says online / syncing / offline, and above
     800px the action row spells it out again in «связь стабильна». */
  #hud #connText { display: none; }
  #hud .conn-pill { padding: 5px 8px; gap: 0; }
  /* And the name gets a narrower ceiling than the 200px the widest window can
     afford. 905 is the tightest width that still carries the identity block at
     all (904 removes `.hud-tail` wholesale), and there the bar fits a 15-
     character name exactly — a 32-character one took it 97px over. The block
     is a door to /profile, where the name is written out in full. */
  #hud .hud-me-name,
  #hud .hud-me-sub { max-width: 98px; }
}
/* <= 904: base.css's touch floor lands and every remaining button grows to
   44px, so everything with a door elsewhere leaves the bar. The season and
   the fire are both on ДОСЬЕ; the store is a rail and sheet row; the identity
   block hands back to the 👤 button, which is un-hidden at exactly this width
   for that reason. What stays is the brand, who is online, 👤 and 🔔.

   `.hud-left` and `.hud-tail` are removed rather than emptied, because an
   empty flex child still spends the row's 16px gap.

   THE HEIGHT CLAUSE IS THE SAME DEVICE THROUGH THE OTHER AXIS, and it is not
   optional now that the frame insets itself for the notch (§safe area). A
   932x430 phone in landscape reads as `>= 905` on width, so it drew the wide
   bar — 1052px of content — inside a box that the two 47px insets had already
   cut to 838. Measured after the inset landed: `div.topbar-right` and
   `div.hud-tail` hanging off the right edge on every route at 926x428, i.e.
   the player's own name and the store door. Under 501px of viewport height
   there is no window in the world that is not a phone, so it takes the phone
   bar: brand, online, 👤, 🔔 — 519px, and it fits with 300 to spare.
   Same 500px constant as the touch floor in base.css and the landscape block
   in views/game.css; a viewport height is a window property and nothing else
   can ask for it. */
@media (max-width: 904px), (max-height: 500px) {
  #hud .hud-left,
  #hud .hud-tail { display: none; }
}
/* <= 599: the online counter drops its word. The pulsing dot beside the
   number already says «now», which is the whole content of «онлайн». */
@media (max-width: 599px) {
  #hud .hud-word { display: none; }
}
/* <= 520 the wordmark goes (components.css:660) and the brand is a bare 30px
   crown tile — which is still the button that carries a lost player back to
   the hub, and 30px is not a thumb. base.css's touch floor only sets a
   min-HEIGHT, and `.topbar-logo` is not an `.icon-btn`, so nothing was
   claiming the other axis. The bar has 72px of slack at 390 (see the ladder
   above); this spends 14 of them. */
@media (max-width: 520px) {
  #hud .topbar-logo { min-width: 44px; justify-content: center; }
}

/* === scene (view host) ===
   The one scroll container in the frame, and a query container: screens size
   themselves against the SCENE, never the window. */
#scene.shell-scene {
  grid-area: scene;
  position: relative;
  min-width: 0;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  container-type: inline-size;
  container-name: scene;
}
/* The keyboard's rent. `--kb-h` (js/core/viewport.js) is the overlap between
   the soft keyboard and the window — the frame is exactly 100dvh and dvh does
   not shrink for keys on iOS, so without this a composer at the foot of the
   scene (match chat, clan forms, the war room) sits behind them unreachably.
   Paid as scroll room at the scene's foot, one rule for every composer at
   once; scoped to `kb-open` so the scene's geometry is byte-identical whenever
   the keyboard is down — which is every frame the game views' `--match-h`
   math cares about. */
html.kb-open #scene.shell-scene { padding-bottom: var(--kb-h, 0px); }

/* === WINDOW CLASSES — read this before writing an @container ===
   There is not a single @container in bot/arena/css apart from this note, so
   the five sizes are written down once, here, and every screen lane uses
   them verbatim:

     compact   @container scene (max-width: 599px)
     medium    @container scene (min-width: 600px) and (max-width: 904px)
     expanded  @container scene (min-width: 905px) and (max-width: 1279px)
     large     @container scene (min-width: 1280px) and (max-width: 1599px)
     huge      @container scene (min-width: 1600px)

   THE RULE MOST LIKELY TO BE GOT WRONG: the threshold is the width of the
   SCENE, not the window. The rail and the context column are subtracted from
   the window before the scene gets any of it, and they change independently
   of it — at a 1280px window the scene is only ~858px wide, which is
   `medium`, not `large`. A @media query here would lay out a three-column
   screen into a 858px hole.

   Budget in scene pixels: three columns need >= 900, two need >= 620.

   `huge` is the proportion class. Above a 1280px WINDOW the shell's chrome
   stops taking fixed widths: tokens.css grows `--ctx-w` as
   `clamp(344px, 25vw, 480px)` and `--container` as `clamp(1060px, 70vw,
   1400px)`, anchored so the mock's 1280×800 renders exactly as drawn and
   nothing jumps on the way up (at 1280 both vw terms are under their floors).
   A scene that crosses 1600px therefore has the container at its 1400px cap
   with canvas to spare around it: a `huge` screen may lay out past the
   container the way the full-bleed hosts already do, and it will never see a
   container under 1400px. Authed that takes a ~2160px window (rail 78 and the
   capped 480px column are subtracted first); a guest, who has no column, is
   `huge` from ~1678. No type scale comes with the class — the font histogram
   is byte-identical from 1280 up, so what was wrong at 1728 was geometry and
   geometry is what it got.

   The chrome widths stay a WINDOW question: the shell's own classes are set
   by @media in tokens.css, never by @container, because the rail and the
   column are subtracted from the window before the scene exists. Screens ask
   the scene; the shell asks the window. */

/* === rail (section navigation) ===
   Not a game switcher any more. Games moved to the hub's capsule showcase and
   to the tab bar's two recent slots; the rail carries the ten SECTIONS that
   until now shared exactly one door — a row of chips buried inside /profile.
   Three groups, hairline-separated, then a flexible gap and the «Ещё» door
   pinned to the foot. */
#rail.shell-rail {
  grid-area: rail;
  min-height: 0;
  overflow-y: auto;
  z-index: 40;
  width: var(--rail-w);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 12px 0;
  border-right: 1px solid var(--hairline);
  background: rgba(12, 8, 24, 0.5);
  scrollbar-width: none;
}
#rail.shell-rail::-webkit-scrollbar { display: none; }

/* The group separator, and the flexible gap that pins «Ещё» to the bottom.
   NOT A LINE ANY MORE. It used to be a 26px hairline, which the finished
   design does not have: groups there are told apart by empty space, 12px on
   top of the row's own 6px gap. A 6px transparent spacer gives 6 + 6 + 6 = 18,
   which is exactly the design's 6px gap plus its 12px group margin. */
#rail.shell-rail .rail-sep {
  flex: 0 0 auto;
  width: 100%;
  height: 6px;
  margin: 0;
  background: none;
}
#rail.shell-rail .rail-spacer { flex: 1 1 auto; min-height: 8px; }

/* A section the rail has no room for at this height. `foldRail()` (ui/shell.js)
   sets it, and the same pass puts that section into the «Ещё» sheet — so this
   rule hides a door only where another one has just been opened. It is
   `display: none` rather than `visibility: hidden` on purpose: the fold loop
   re-measures `scrollHeight` after each row, and a row that still occupies its
   box would never let the rail come back under the fold.
   BOTH ELEMENT CLASSES ARE NAMED rather than writing `.is-folded` alone, and
   the reason is a measured failure, not tidiness: `#rail.shell-rail .rail-item`
   below is `display: flex` at the same (1,2,0) specificity and later in the
   file, so the bare selector lost the cascade and folded every section while
   hiding none of them — the rail rendered full, the sheet listed all eight, and
   `scrollHeight` never came down because the loop's own measurement never
   changed. (1,3,0) wins regardless of order. */
#rail.shell-rail .rail-item.is-folded,
#rail.shell-rail .rail-sep.is-folded { display: none; }

/* An item is a 60px column: 19px glyph over an 8px uppercase label, 44px tall
   without the label and 52px with it. 44px is the touch floor, not a look — a
   rail item is the smallest target in the app. */
#rail.shell-rail .rail-item {
  position: relative;
  flex: 0 0 auto;
  width: 60px;
  min-height: 44px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  padding: 0;
  border-radius: 13px;
  color: rgba(var(--t-mute), 0.5);
  transition: color 0.18s, background 0.18s, box-shadow var(--dur-med), transform 0.18s var(--spring);
}
@media (min-width: 905px) {
  #rail.shell-rail .rail-item { min-height: 52px; }
}
@media (hover: hover) {
  #rail.shell-rail .rail-item:hover {
    color: var(--text);
    background: rgba(var(--t-violet), 0.14);
  }
  #rail.shell-rail .rail-item:hover .rail-label { color: var(--text-dim); }
}
/* Every hover above has an :active twin. A phone never leaves the hover
   state, so the press feedback has to come from somewhere else. */
#rail.shell-rail .rail-item:active {
  transform: scale(0.94);
  background: rgba(var(--t-gold), 0.13);
}
#rail.shell-rail .rail-item.is-active {
  color: var(--gold);
  background: rgba(var(--t-gold), 0.13);
  box-shadow: 0 0 20px rgba(var(--t-gold), 0.16);
}
#rail.shell-rail .rail-item.is-active .rail-label { color: var(--gold); }
/* Left marker stripe on the active item: 3×22 flush against the item's own
   left edge, not floated four pixels off it. */
#rail.shell-rail .rail-item.is-active::before {
  content: "";
  position: absolute;
  left: -1px;
  top: 50%;
  transform: translateY(-50%);
  width: 3px;
  height: 22px;
  border-radius: var(--r-sm);
  background: var(--gold);
  box-shadow: 0 0 12px rgba(var(--t-gold), 0.75);
}
/* «Ещё» stays on screen even when the rail overflows.
   MEASURED: the eleven items need 658px of rail. The rail gets the viewport
   minus the HUD (58) and marquee (28), minus the action row (54) whenever a
   live game is showing — so it needs 744px idle and 798px in a match. A
   1366×768 laptop has about 660px of viewport after browser chrome and
   overflows even idle; a 1440×900 Mac has ~785px and overflows the moment a
   game goes live.
   The flexible gap above pins «Ещё» to the end of the CONTENT, which is not
   the same as the end of the rail, and the scrollbar is hidden two rules up —
   so the door to /battlepass, /shop and /analytics simply left the screen with
   nothing to say it had. Sticky pins it to the rail instead. */
#rail.shell-rail .rail-more {
  color: var(--text-faint);
  position: sticky;
  bottom: -12px; /* cancels the rail's bottom padding, so it sits flush */
  background: var(--bg);
  padding-bottom: 12px;
  /* An opaque background is not enough to be on top. `.rail-icon` below is
     `position: relative; z-index: 1`, and a sticky box with `z-index: auto`
     paints under it — so the glyphs of the items scrolling past printed
     straight through «Ещё». It shows the moment the rail is short enough to
     scroll a glyph under the sticky row: measured on all 33 routes at
     844x390 with the safe-area inset shortening the rail to 229px,
     `elementFromPoint` on «Ещё» returned another item's `.rail-icon`. */
  z-index: 2;
}

/* The glyph box: the ring lives here rather than on the item, because the
   item's height changes when the label appears at 905px and an absolutely
   placed ring would drift off the icon. */
#rail.shell-rail .rail-glyph {
  position: relative;
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  display: grid;
  place-items: center;
}

/* Rail labels appear only once the rail is 78px wide (>= 905px window). */
#rail.shell-rail .rail-label {
  display: none;
  font-family: var(--font-display);
  font-size: var(--fs-rail);
  font-weight: var(--fw-rail);
  letter-spacing: var(--ls-rail);
  text-transform: uppercase;
  color: var(--text-faint);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: color 0.16s;
}
@media (min-width: 905px) {
  #rail.shell-rail .rail-label { display: block; }
}
/* MEASURED, not guessed: the label track is 68px, and at 8px/800/.06em a
   Russian uppercase label runs ~6.9px per character. Anything past 9
   characters — ВОСХОЖДЕНИЯ (76px), ТРЕНИРОВКА (66px) — is cut mid-word, and a
   navigation label ending in «…» fails the three-second read outright. Two of
   eleven labels taper rather than truncate; JS sets the class by length, so
   the English rail (ASCENTS, TRAINING) never takes it. Game labels are
   shortened at a word boundary in JS instead — see navGameLabel(). */
#rail.shell-rail .rail-label.is-long {
  font-size: 7px;
  letter-spacing: 0.02em;
}
/* One label in eleven needs a second step down. MEASURED at 1280 with the
   rail's own face and tracking, against the 60px track:
     ШАХМАТЫ 51  СОБЫТИЯ 47  ТРИБУНА 45  СНАРЯГА 43  ПРОГРЕСС 49   — all at 7px
     ТРЕНИРОВКА 63 — 3px over, and no amount of tracking closes 3px at 7px.
   The alternative was the design's own word for this rail, «Полигон», which
   fits at 8px; it is not used because /training titles itself «Тренировка» and
   a door labelled differently from the room behind it is worse than a label a
   point smaller. */
#rail.shell-rail .rail-label.is-longest {
  font-size: 6.5px;
  letter-spacing: 0.01em;
}

/* Unread mark. One signal, one source: an ascent unlocked since the player
   last opened the gallery. Danger-red rather than gold, which is the design's
   choice and the right one for a second reason: gold is what an ACTIVE item
   already is, so a gold dot on a gold item was invisible exactly where it
   mattered. */
/* deadends-map S6: a guest's locked door. The row stays clickable — the
   router stashes the destination and bounces to /login — the mark and the
   dimmed glyph just say so before the tap. */
#rail.shell-rail .rail-item.is-locked .rail-icon,
#tabbar.shell-tabbar .tab-item.is-locked .tab-icon { opacity: 0.55; }
.rail-lockmark {
  position: absolute;
  right: -4px;
  bottom: -3px;
  font-size: 9px;
  line-height: 1;
  filter: grayscale(1);
  opacity: 0.9;
}
.tab-lockmark {
  position: absolute;
  top: 5px;
  right: 50%;
  margin-right: -18px;
  font-size: 8px;
  line-height: 1;
  filter: grayscale(1);
  opacity: 0.9;
}
#rail.shell-rail .rail-dot {
  position: absolute;
  top: 8px;
  right: 9px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--danger);
  box-shadow: 0 0 8px rgba(var(--t-danger), 0.8);
}

/* progress ring around an item: pure conic-gradient, no mask, no svg.
   --p is 0..1 (set inline by JS). The faint remainder track collapses to
   opacity 0 when there is no progress to show. */
#rail.shell-rail .rail-ring {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: conic-gradient(var(--gold) calc(var(--p, 0) * 360deg), rgba(var(--t-violet), 0.14) 0);
  opacity: clamp(0, calc(var(--p, 0) * 999), 1);
  transition: opacity var(--dur-med);
}
/* CSS cannot branch on a custom-property value, so JS carries the p==0 case
   as a class (DOP, live stand); it wins on specificity over the clamp(). */
#rail.shell-rail .rail-ring.is-zero { opacity: 0; }
#rail.shell-rail .rail-ring::after {
  content: "";
  position: absolute;
  inset: 3px;
  border-radius: 50%;
  background: var(--bg2);
}
#rail.shell-rail .rail-icon {
  position: relative;
  z-index: 1;
  font-size: 19px;
  line-height: 1;
}

/* === ctx (context column) ===
   The console's right-hand column: the mock's composition (identity, fire,
   «в одном шаге», the four tabs and their rows) on every route. THE COLUMN
   IS THE SCROLLER — overflow-y: auto below — and its content stacks at
   natural height inside, so when the composition is taller than the window
   the whole column scrolls as one surface (like Telegram's right panel):
   the mock's look, with everything still reachable. The scroll-edge mask
   (has-more-below / has-more-above, set by ui/shell.js) is the affordance. */
#ctx.shell-ctx {
  grid-area: ctx;
  position: relative;
  z-index: 41;
  display: flex;
  flex-direction: column;
  gap: 0;
  min-width: 0;
  min-height: 0;
  /* NO PADDING AND NO GAP, and both were removed rather than tuned. The
     column's blocks are full-bleed bands separated by hairlines, so an outer
     padding would inset every rule line by 10px and a gap would break each one
     into a floating card. Each block carries its own padding instead. */
  padding: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  border-left: 1px solid var(--hairline);
  background: rgba(12, 8, 24, 0.56);
}
/* A column with nothing in it is a hairline and a tint down the side of the
   screen, which reads as a broken panel — and worse, the 344px grid track
   stays reserved, so a guest at 1280px loses a quarter of the window to
   nothing. Collapse both. `:has()` is the only way to reach the grid from the
   child; where it is unsupported the layout degrades to exactly what shipped
   before this file, so there is nothing to fall back to. */
#ctx.shell-ctx:empty { display: none; }
/* Scoped to the width where a third column exists at all, and the scoping is
   load-bearing rather than tidy.
   `:has()` takes the specificity of its most specific argument, so
   `.shell:has(#ctx.shell-ctx:empty)` scores (1,3,0). The phone rule it has to
   lose to is `.shell` inside `@media (max-width: 799px)` at (0,1,0) — a media
   query adds no specificity. Unscoped, this rule therefore won the COLUMNS on
   a phone while the media query kept the AREAS: a one-column area template
   laid over three column tracks, every zone landing in track 1, and track 1 is
   `--rail-w`. Measured at a true 390x844: the whole app rendered in a 62px
   sliver, on every screen, in the default state — #ctx ships empty and only a
   screen fills it.
   The desktop behaviour it was written for is correct and is kept: at 1280
   with an empty ctx the track collapses to 0 and the scene takes the width. */
@media (min-width: 800px) {
  .shell:has(#ctx.shell-ctx:empty) {
    grid-template-columns: var(--rail-w) minmax(0, 1fr) 0;
  }
}

/* The shell's own stack inside the column. A plain block at its natural
   height: #ctx above is the one scroller, so the whole column scrolls as one
   surface when the stack overflows. Never constrained to the track: a flex
   child that could shrink here would push the overflow back into the
   column's children instead of the column itself. */
:is(#ctx, .l3-wall) .ctx-shell {
  flex: 0 0 auto;
  display: block;
  min-width: 0;
}

/* --- identity card --- */
:is(#ctx, .l3-wall) .ctx-id {
  flex: 0 0 auto;
  padding: 14px;
  border-bottom: 1px solid var(--hairline);
}
:is(#ctx, .l3-wall) .ctx-id-head {
  display: flex;
  align-items: center;
  gap: 11px;
  margin-bottom: 12px;
  min-width: 0;
}
/* The avatar wears the battle-pass ring: a conic sweep with a disc punched out
   of the middle, --p set inline by JS. `is-blank` is the null case — no pass
   payload means no ring at all, because a ring at zero says the player just
   levelled up. */
:is(#ctx, .l3-wall) .ctx-avatar {
  position: relative;
  flex: 0 0 auto;
  width: 52px;
  height: 52px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: conic-gradient(
    var(--gold) calc(var(--p, 0) * 1turn),
    rgba(var(--t-violet), 0.16) 0
  );
}
:is(#ctx, .l3-wall) .ctx-avatar.is-blank { background: rgba(var(--t-violet), 0.16); }
:is(#ctx, .l3-wall) .ctx-avatar::after {
  content: "";
  position: absolute;
  inset: 3px;
  border-radius: 50%;
  background: #120c22;
}
:is(#ctx, .l3-wall) .ctx-avatar-glyph { position: relative; z-index: 1; font-size: 19px; line-height: 1; }
:is(#ctx, .l3-wall) .ctx-id-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
:is(#ctx, .l3-wall) .ctx-id-name {
  display: flex;
  align-items: baseline;
  gap: 5px;
  min-width: 0;
  font-size: 13px;
  font-weight: 800;
  color: var(--text);
}
:is(#ctx, .l3-wall) .ctx-id-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
:is(#ctx, .l3-wall) .ctx-id-clan { flex: 0 0 auto; font-size: 10px; color: var(--gold); }
:is(#ctx, .l3-wall) .ctx-id-pass {
  font-size: 10px;
  color: rgba(var(--t-mute), 0.44);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* --- three stat tiles --- */
:is(#ctx, .l3-wall) .ctx-tiles {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 7px;
}
:is(#ctx, .l3-wall) .ctx-tile {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 6px;
  border-radius: 10px;
  background: rgba(var(--t-violet), 0.08);
  text-align: center;
  transition: background 0.16s;
}
@media (hover: hover) {
  :is(#ctx, .l3-wall) .ctx-tile:hover { background: rgba(var(--t-violet), 0.16); }
  :is(#ctx, .l3-wall) .ctx-tile-gold:hover { background: rgba(var(--t-gold), 0.14); }
}
/* An empty tile keeps its height. The value line is reserved whether or not a
   number arrived, so the row does not jump 18px when the request lands — and
   an unanswered tile is blank rather than showing a zero it was never told. */
:is(#ctx, .l3-wall) .ctx-tile-val {
  min-height: 18px;
  font-family: var(--font-mono);
  font-size: 15px;
  font-weight: 700;
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}
:is(#ctx, .l3-wall) .ctx-tile-gold .ctx-tile-val { color: var(--gold); }
:is(#ctx, .l3-wall) .ctx-tile-ok .ctx-tile-val { color: var(--ok); }
/* Wraps rather than truncates, and that is a deviation from the design taken
   on purpose. The label track is 88px inside a 100px tile at the widest
   context column, and «🎮 партий за неделю» measures 94px in Manrope at 8px —
   one line reads «партий за недел…», which is not a label any more. Two lines
   of 8px cost the tile 9px of height and it keeps its meaning. */
:is(#ctx, .l3-wall) .ctx-tile-lbl {
  font-size: 8px;
  line-height: 1.2;
  letter-spacing: 0.04em;
  color: rgba(var(--t-mute), 0.42);
  text-wrap: pretty;
}

/* --- the fire --- */
:is(#ctx, .l3-wall) .ctx-fire {
  flex: 0 0 auto;
  padding: 12px 14px;
  border-bottom: 1px solid var(--hairline);
}
:is(#ctx, .l3-wall) .fire-card {
  display: flex;
  flex-direction: column;
  gap: 9px;
  padding: 13px 14px;
  border-radius: var(--r-lg);
  border: 1px solid rgba(var(--t-gold), 0.22);
  background: linear-gradient(140deg, rgba(var(--t-gold), 0.05), rgba(18, 12, 34, 0.6));
}
:is(#ctx, .l3-wall) .fire-card.is-tended {
  border-color: rgba(var(--t-gold), 0.34);
  background: linear-gradient(140deg, rgba(var(--t-gold), 0.09), rgba(18, 12, 34, 0.6));
}
/* Ash, not alarm. The design has no red in any fire state: a broken streak is
   a fact about yesterday, not an emergency. */
:is(#ctx, .l3-wall) .fire-card.is-lost,
:is(#ctx, .l3-wall) .fire-card.is-new,
:is(#ctx, .l3-wall) .fire-card.is-blind {
  border-color: rgba(var(--t-violet), 0.2);
  background: linear-gradient(140deg, rgba(var(--t-violet), 0.05), rgba(18, 12, 34, 0.6));
}
:is(#ctx, .l3-wall) .fire-head { display: flex; align-items: center; gap: 10px; }
:is(#ctx, .l3-wall) .fire-glyph {
  font-size: 21px;
  line-height: 1;
  filter: drop-shadow(0 0 10px rgba(var(--t-gold), 0.55));
}
:is(#ctx, .l3-wall) .fire-card.is-lost .fire-glyph,
:is(#ctx, .l3-wall) .fire-card.is-new .fire-glyph,
:is(#ctx, .l3-wall) .fire-card.is-blind .fire-glyph { filter: grayscale(1); opacity: 0.45; }
:is(#ctx, .l3-wall) .fire-count { display: flex; align-items: baseline; gap: 5px; min-width: 0; }
:is(#ctx, .l3-wall) .fire-days {
  font-family: var(--font-mono);
  font-size: 26px;
  font-weight: 700;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: var(--gold);
}
:is(#ctx, .l3-wall) .fire-dayword {
  font-size: 9px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}
:is(#ctx, .l3-wall) .fire-grow { flex: 1 1 auto; }
:is(#ctx, .l3-wall) .fire-kicker {
  font-family: var(--font-display);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-soft);
}
:is(#ctx, .l3-wall) .fire-line {
  font-family: var(--font-display);
  font-size: 12px;
  font-weight: 800;
  line-height: 1.3;
  color: var(--gold);
  text-wrap: pretty;
}
:is(#ctx, .l3-wall) .fire-card.is-lost .fire-line,
:is(#ctx, .l3-wall) .fire-card.is-new .fire-line,
:is(#ctx, .l3-wall) .fire-card.is-blind .fire-line { color: rgba(var(--t-mute), 0.82); }
:is(#ctx, .l3-wall) .fire-sub {
  font-size: 10px;
  line-height: 1.5;
  color: var(--text-soft);
  text-wrap: pretty;
}
/* 60 days, one column each: a candle scaled by how much was played, a 6% dot
   for a day that was not. The dot matters — a zero-height column is
   indistinguishable from a gap, and the gaps are the whole point of the strip. */
:is(#ctx, .l3-wall) .fire-spark {
  display: flex;
  align-items: flex-end;
  gap: 1.5px;
  height: 26px;
}
:is(#ctx, .l3-wall) .fire-bar {
  flex: 1 1 0;
  min-width: 0;
  align-self: flex-end;
  border-radius: 1.5px;
  background: rgba(var(--t-violet), 0.16);
}
:is(#ctx, .l3-wall) .fire-bar.is-on {
  background: linear-gradient(180deg, rgba(var(--t-gold), 0.62), rgba(var(--t-gold), 0.16));
}
:is(#ctx, .l3-wall) .fire-foot { display: flex; align-items: center; gap: 8px; }
:is(#ctx, .l3-wall) .fire-note { font-size: 9px; color: rgba(var(--t-mute), 0.32); }
/* deadends-map S4: the fire CTA leads to the selected game's own screen —
   standing on that screen already, the button is not a door and hides
   (syncFireDoor() keeps the class current across navigation). */
.fire-act.is-here { display: none; }
:is(#ctx, .l3-wall) .fire-act {
  min-height: 38px;
  padding: 10px 13px;
  border-radius: var(--r-md);
  border: 1px solid rgba(var(--t-gold), 0.45);
  background: rgba(var(--t-gold), 0.14);
  font-family: var(--font-display);
  font-size: 11px;
  font-weight: 800;
  color: var(--gold);
  white-space: nowrap;
  transition: transform 0.16s var(--spring), background 0.16s;
}
@media (hover: hover) {
  :is(#ctx, .l3-wall) .fire-act:hover { transform: translateY(-1px); background: rgba(var(--t-gold), 0.22); }
}

/* --- «В одном шаге» ---
   The rings payload's `next`, finally with the distance attached: the server
   ranks these by remaining actions and now sends `cur`/`target` along with the
   name, so the row can carry a track instead of a bare label. A row whose
   payload lacks either number keeps the old shape — the gallery count — rather
   than drawing a track at 0%. */
:is(#ctx, .l3-wall) .ctx-next {
  flex: 0 0 auto;
  padding: 12px 14px;
  border-bottom: 1px solid var(--hairline);
}
:is(#ctx, .l3-wall) .ctx-head {
  font-family: var(--font-display);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--gold);
  margin-bottom: 9px;
}
:is(#ctx, .l3-wall) .ctx-list { display: flex; flex-direction: column; gap: 9px; }
:is(#ctx, .l3-wall) .ctx-row {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  text-align: left;
  border-radius: var(--r-xs);
  transition: background 0.16s;
}
@media (hover: hover) {
  :is(#ctx, .l3-wall) .ctx-row:hover { background: rgba(var(--t-violet), 0.08); }
}
:is(#ctx, .l3-wall) .ctx-icon { flex: 0 0 auto; font-size: 17px; line-height: 1; }
:is(#ctx, .l3-wall) .ctx-text {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
  flex: 1 1 auto;
}
:is(#ctx, .l3-wall) .ctx-name {
  font-size: 10px;
  font-weight: 700;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
:is(#ctx, .l3-wall) .ctx-meta {
  font-family: var(--font-mono);
  font-size: 9px;
  color: var(--text-faint);
}
:is(#ctx, .l3-wall) .ctx-track {
  height: 4px;
  border-radius: 3px;
  background: rgba(var(--t-violet), 0.16);
  overflow: hidden;
}
:is(#ctx, .l3-wall) .ctx-fill {
  display: block;
  height: 100%;
  border-radius: 3px;
  background: linear-gradient(90deg, var(--violet), var(--gold));
  box-shadow: 0 0 8px rgba(var(--t-gold), 0.5);
}
:is(#ctx, .l3-wall) .ctx-left {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: 9px;
  font-variant-numeric: tabular-nums;
  color: rgba(var(--t-mute), 0.4);
}
:is(#ctx, .l3-wall) .ctx-empty {
  font-size: 11px;
  line-height: 1.5;
  color: var(--text-faint);
  text-wrap: pretty;
}

/* --- the four-tab switcher and its body --- */
:is(#ctx, .l3-wall) .ctx-tabs {
  flex: 0 0 auto;
  display: flex;
  gap: 3px;
  padding: 9px 12px 0;
}
:is(#ctx, .l3-wall) .ctx-tab {
  flex: 1 1 0;
  min-width: 0;
  padding: 7px;
  border-radius: 8px;
  font-size: 10px;
  font-weight: 800;
  letter-spacing: 0.04em;
  color: rgba(var(--t-mute), 0.4);
  transition: background 0.16s, color 0.16s;
}
:is(#ctx, .l3-wall) .ctx-tab.is-active { background: rgba(var(--t-gold), 0.14); color: var(--gold); }
@media (hover: hover) {
  :is(#ctx, .l3-wall) .ctx-tab:not(.is-active):hover { color: var(--text-dim); }
}
/* The tab's body. NOT a scroller: it grows to the full height of its rows
   and the whole column scrolls as one surface (#ctx.shell-ctx above carries
   overflow-y: auto). The 6px gap between rows is the row rhythm. */
:is(#ctx, .l3-wall) .ctx-body {
  padding: 8px 10px 12px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
/* --- the scroll edge ---
   `ui/shell.js` sets these two on the COLUMN from its scroll position. The
   mask sits on #ctx itself because the column is the one scroller now — the
   mock's whole composition rides one surface, and the edge says there is
   more of it below (or above).
   28px is one line of the rival card's meta row plus its padding: enough that
   the cut row is visibly dissolving rather than sliced, and not so much that a
   whole row is dimmed. The top fade is shorter — coming BACK is a scroll the
   player has already performed, so it only has to hint, not teach.
   A mask paints on the composited element, so the rules are written out in full
   for both axes rather than composed: `mask-image` does not cascade in parts. */
#ctx.shell-ctx.has-more-below {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
}
#ctx.shell-ctx.has-more-above {
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 14px);
  mask-image: linear-gradient(to bottom, transparent, #000 14px);
}
#ctx.shell-ctx.has-more-above.has-more-below {
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 14px, #000 calc(100% - 28px), transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 14px, #000 calc(100% - 28px), transparent);
}
:is(#ctx, .l3-wall) .ctx-feed {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 9px 10px;
  border-radius: var(--r-md);
  border: 1px solid rgba(var(--t-violet), 0.1);
  background: var(--row);
}
:is(#ctx, .l3-wall) .ctx-feed-icon {
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  display: grid;
  place-items: center;
  border-radius: var(--r-sm);
  background: rgba(var(--t-violet), 0.16);
  border: 1px solid rgba(var(--t-violet), 0.16);
  font-size: 14px;
  line-height: 1;
}
:is(#ctx, .l3-wall) .ctx-feed-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  flex: 1 1 auto;
}
:is(#ctx, .l3-wall) .ctx-feed-title {
  font-size: 11px;
  font-weight: 700;
  line-height: 1.3;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
:is(#ctx, .l3-wall) .ctx-feed-sub {
  font-family: var(--font-mono);
  font-size: 9px;
  line-height: 1.35;
  color: rgba(var(--t-mute), 0.42);
}
/* --- «Счёты»: one row per person you have actually played ---
   ONE EYE-PASS ROW, not a three-line card. The old shape stacked who+score, the
   per-game chips, then last-met + the door — ~93px, so a 344px column at an
   800px window showed a single rival above the fold. Now the name carries the
   per-game breakdown inline as compact chips, the games/last-met line truncates
   beneath it, the score sits at the right, and the door is the row's full
   height — ~54px, the same language (glass card, hairline) at a third of the
   height. The score stays in the mono face at 15px: it is the only figure in
   the console with a human on the other side of it. */
:is(#ctx, .l3-wall) .ctx-rival {
  display: flex;
  align-items: center;
  gap: 8px;
  /* 54 = the 44px door + 4+4 vertical padding + 2 border (border-box). A smaller
     min-height lets the full-height door overflow the card (measured: a 44px
     row put the door's bottom edge 7px past the row's), and 54 sits inside the
     design's 52–60 target band. */
  min-height: 54px;
  padding: 4px 8px 4px 10px;
  border-radius: var(--r-md);
  border: 1px solid rgba(var(--t-violet), 0.1);
  background: var(--row);
}
:is(#ctx, .l3-wall) .ctx-rival-main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  flex: 1 1 auto;
}
/* The name line clips rather than wraps: the name shrinks with an ellipsis and
   the inline chips are dropped off the end before a second row can form. */
:is(#ctx, .l3-wall) .ctx-rival-line {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  overflow: hidden;
}
:is(#ctx, .l3-wall) .ctx-rival-name {
  flex: 0 1 auto;
  min-width: 0;
  font-size: 12px;
  font-weight: 700;
  line-height: 1.25;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
:is(#ctx, .l3-wall) .ctx-rival-score {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: 15px;
  font-weight: 800;
  line-height: 1;
  letter-spacing: 0.02em;
  color: var(--text-dim);
}
/* An even record stays neutral: a 5:5 is not a result. */
:is(#ctx, .l3-wall) .ctx-rival-score.is-up { color: var(--ok); }
:is(#ctx, .l3-wall) .ctx-rival-score.is-down { color: var(--danger); }
/* Inline per-game chips — the breakdown that used to be its own row, now a
   compact run after the name. They never shrink (the name does), so a record
   that disagrees across two boards still shows both scores in the glance. */
:is(#ctx, .l3-wall) .ctx-rival-chip {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: 3px;
  padding: 1px 5px;
  border-radius: var(--r-sm);
  background: rgba(var(--t-violet), 0.12);
}
:is(#ctx, .l3-wall) .ctx-rival-chip-icon { font-size: 10px; line-height: 1; }
:is(#ctx, .l3-wall) .ctx-rival-chip-score {
  font-family: var(--font-mono);
  font-size: 9px;
  font-weight: 700;
  line-height: 1;
  color: rgba(var(--t-mute), 0.72);
}
:is(#ctx, .l3-wall) .ctx-rival-meta {
  min-width: 0;
  font-size: 9px;
  line-height: 1.3;
  color: rgba(var(--t-mute), 0.42);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The door is the row's full height, right-aligned — a compact action, not the
   fat pill that used to stack a third line. min-height states the 44px thumb
   floor on desktop too: base.css's floor only reaches ≤904px and `data-tight`
   opts this button out of it, so without this the door would be 25px on a
   desktop and the row's only pressable target would be under the floor. */
:is(#ctx, .l3-wall) .ctx-rival-go {
  flex: 0 0 auto;
  align-self: stretch;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 44px;
  padding: 0 9px;
  border-radius: var(--r-sm);
  border: 1px solid rgba(var(--t-gold), 0.28);
  background: rgba(var(--t-gold), 0.1);
  transition: background 0.16s;
}
@media (hover: hover) {
  :is(#ctx, .l3-wall) .ctx-rival-go:hover { background: rgba(var(--t-gold), 0.2); }
}
:is(#ctx, .l3-wall) .ctx-rival-go-icon { font-size: 11px; line-height: 1; }
:is(#ctx, .l3-wall) .ctx-rival-go-text {
  font-size: 10px;
  font-weight: 800;
  line-height: 1;
  letter-spacing: 0.02em;
  color: var(--gold);
}

/* --- «Друзья»: presence ordered by closeness ---
   Same single eye-pass row as «Счёты»: a status dot, the name (+ clan tag) over
   a truncated status line («в партии в нарды», «смотрит», «отошёл»), and one
   compact right-aligned door — «Смотреть» when there is a board to open,
   «Вызвать» otherwise. Cribbed from .ctx-rival so it sits in the same column at
   the same ~54px height. */
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres {
  display: flex;
  align-items: center;
  gap: 8px;
  /* 54 in the column; the presence sheet overrides the variable to 48 so it
     carries more names to a fold (see .pres-sheet). */
  min-height: var(--pres-row-h, 54px);
  padding: 4px 8px 4px 10px;
  border-radius: var(--r-md);
  border: 1px solid rgba(var(--t-violet), 0.1);
  background: var(--row);
}
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  flex: 1 1 auto;
}
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-line {
  display: flex;
  align-items: baseline;
  gap: 5px;
  min-width: 0;
}
/* The dot is the at-a-glance liveness signal; the status line spells it out. */
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-dot {
  flex: 0 0 auto;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: rgba(var(--t-mute), 0.4);
}
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-dot.is-online,
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-dot.is-in_game { background: var(--ok); }
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-dot.is-spectating { background: rgba(var(--t-violet), 0.9); }
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-dot.is-away { background: var(--gold); }
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-dot.is-offline { background: rgba(var(--t-mute), 0.32); }
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-name {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 12px;
  font-weight: 700;
  line-height: 1.25;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-clan {
  flex: 0 0 auto;
  font-size: 9px;
  font-weight: 800;
  letter-spacing: 0.03em;
  color: rgba(var(--t-violet), 0.85);
}
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-status {
  font-size: 9px;
  line-height: 1.3;
  color: rgba(var(--t-mute), 0.62);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The player's own row carries no door and reads a touch apart. */
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres.is-self { border-style: dashed; }
/* Same door as .ctx-rival-go, renamed so this tab owns its own styles. Full row
   height, right-aligned, with the same explicit 44px floor. */
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-go {
  flex: 0 0 auto;
  align-self: stretch;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 44px;
  padding: 0 9px;
  border-radius: var(--r-sm);
  border: 1px solid rgba(var(--t-gold), 0.28);
  background: rgba(var(--t-gold), 0.1);
  transition: background 0.16s;
}
@media (hover: hover) {
  :is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-go:hover { background: rgba(var(--t-gold), 0.2); }
}
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-go-icon { font-size: 11px; line-height: 1; }
:is(#ctx, .pres-sheet, .l3-wall) .ctx-pres-go-text {
  font-size: 10px;
  font-weight: 800;
  line-height: 1;
  letter-spacing: 0.02em;
  color: var(--gold);
}

/* The empty state, at the size the filled one would be. Dashed rather than
   solid so it reads as «nothing here yet» instead of «a card that failed». */
:is(#ctx, .l3-wall) .ctx-blind {
  display: flex;
  flex-direction: column;
  gap: 9px;
  padding: 17px 16px;
  border-radius: var(--r-lg);
  border: 1px dashed rgba(var(--t-violet), 0.36);
  background: rgba(var(--t-violet), 0.05);
}
:is(#ctx, .l3-wall) .ctx-blind-text {
  font-size: 11px;
  line-height: 1.55;
  color: rgba(var(--t-mute), 0.62);
  text-wrap: pretty;
}
:is(#ctx, .l3-wall) .ctx-blind-act {
  min-height: 40px;
  padding: 11px 14px;
  border-radius: var(--r-md);
  border: 1px solid rgba(var(--t-violet), 0.26);
  background: var(--panel-soft);
  font-size: 11px;
  font-weight: 700;
  color: var(--text-dim);
  transition: background 0.16s;
}
@media (hover: hover) {
  :is(#ctx, .l3-wall) .ctx-blind-act:hover { background: var(--row-hover); }
}
/* THE 40 IS A DESKTOP NUMBER AND IT WAS WINNING WHERE IT MUST NOT.
   `:is(#ctx, .l3-wall) .ctx-blind-act` scores (1,1,0); base.css's touch floor is
   `button:not([data-tight])` at (0,1,1), so the min-height above simply beat
   it and this button rendered 193x40 at 860px — inside the exact band the
   floor exists for. The column starts at 800px and the floor stops at 904px,
   so 800..904 is a real overlap: an iPad in portrait is 810 or 820, and every
   blind panel («Счёты» before the first finished game, «Друзья», «Клан») puts
   this button on every route there. Measured 40.0 before, 44.0 after.
   Same query as base.css verbatim, including the landscape height clause —
   a 926x428 phone on its side is past 800 and keeps the column, so it needs
   the thumb size too. Same shape and same reason as `.chat-send` and
   `.cl-kick`. Not `data-tight`: this is a real, pressable door out of an empty
   state, not an inline filter chip. */
@media (max-width: 904px), (max-height: 500px) {
  :is(#ctx, .l3-wall) .ctx-blind-act { min-height: 44px; }
}

/* --- «Клан»: the war room ---
   The console's clan tab is a war room, not a chat: a war-state header, the
   officer-edited objective pinned on top, system lines in their own register,
   and a composer at the foot. Cribbed from .ctx-feed / .ctx-blind so it sits in
   the same visual family as the rest of the column. */
:is(#ctx, .l3-wall) .clc-room { gap: 8px; }
:is(#ctx, .l3-wall) .clc-head {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 9px 11px;
  border-radius: var(--r-md);
  border: 1px solid rgba(var(--t-gold), 0.22);
  background: rgba(var(--t-gold), 0.08);
}
:is(#ctx, .l3-wall) .clc-head-war {
  font-size: 11px;
  font-weight: 800;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--gold);
}
:is(#ctx, .l3-wall) .clc-head-sub {
  font-family: var(--font-mono);
  font-size: 10px;
  line-height: 1.4;
  color: var(--text-dim);
}
:is(#ctx, .l3-wall) .clc-pin {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  padding: 9px 11px;
  border-radius: var(--r-md);
  border: 1px dashed rgba(var(--t-gold), 0.4);
  background: rgba(var(--t-gold), 0.06);
}
:is(#ctx, .l3-wall) .clc-pin.is-empty { display: none; }
:is(#ctx, .l3-wall) .clc-pin-tag {
  flex: 0 0 auto;
  font-size: 9px;
  font-weight: 800;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--gold);
  padding-top: 1px;
}
:is(#ctx, .l3-wall) .clc-pin-text {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 11px;
  line-height: 1.45;
  color: var(--text);
  text-wrap: pretty;
}
:is(#ctx, .l3-wall) .clc-pin-act {
  flex: 0 0 auto;
  padding: 3px 8px;
  border-radius: var(--r-sm);
  border: 1px solid rgba(var(--t-gold), 0.3);
  background: transparent;
  font-size: 9px;
  font-weight: 700;
  color: var(--text-dim);
}
:is(#ctx, .l3-wall) .clc-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}
:is(#ctx, .l3-wall) .clc-msg {
  display: flex;
  align-items: flex-start;
  gap: 6px;
  padding: 7px 10px;
  border-radius: var(--r-md);
  border: 1px solid rgba(var(--t-violet), 0.1);
  background: var(--row);
}
:is(#ctx, .l3-wall) .clc-msg-main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  flex: 1 1 auto;
}
:is(#ctx, .l3-wall) .clc-msg-label {
  font-size: 10px;
  font-weight: 700;
  color: var(--text-dim);
}
:is(#ctx, .l3-wall) .clc-msg-text {
  font-size: 11px;
  line-height: 1.45;
  color: var(--text);
  text-wrap: pretty;
  word-break: break-word;
}
:is(#ctx, .l3-wall) .clc-msg-pin {
  flex: 0 0 auto;
  padding: 2px 5px;
  border-radius: var(--r-sm);
  border: 1px solid transparent;
  background: transparent;
  font-size: 11px;
  line-height: 1;
  opacity: 0.55;
}
@media (hover: hover) {
  :is(#ctx, .l3-wall) .clc-msg-pin:hover { opacity: 1; background: var(--row-hover); }
}
/* System lines: no author, no bubble — a centered line in the room's own
   register, distinct from the things humans said. */
:is(#ctx, .l3-wall) .clc-msg.is-system {
  border: none;
  background: transparent;
  padding: 2px 10px;
  justify-content: center;
}
:is(#ctx, .l3-wall) .clc-msg-sys {
  font-size: 10px;
  line-height: 1.4;
  color: rgba(var(--t-mute), 0.62);
  text-align: center;
  font-style: italic;
}
:is(#ctx, .l3-wall) .clc-empty {
  padding: 14px 10px;
  font-size: 11px;
  line-height: 1.5;
  color: rgba(var(--t-mute), 0.55);
  text-align: center;
}
/* The body scrolls with the whole column now, so the composer is the room's
   last row in flow — pinning it to a foot that no longer exists would just be
   dead geometry. */
:is(#ctx, .l3-wall) .clc-composer {
  display: flex;
  align-items: center;
  gap: 6px;
}
:is(#ctx, .l3-wall) .clc-input {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 38px;
  padding: 8px 11px;
  border-radius: var(--r-md);
  border: 1px solid rgba(var(--t-violet), 0.24);
  background: var(--panel-soft);
  color: var(--text);
  font-size: 12px;
}
:is(#ctx, .l3-wall) .clc-input::placeholder { color: rgba(var(--t-mute), 0.5); }
/* 16px on touch or iOS zooms the page into the field (chat.css states the full
   argument); desktop keeps the mock's 12px. */
@media (pointer: coarse) {
  :is(#ctx, .l3-wall) .clc-input { font-size: 16px; }
}
:is(#ctx, .l3-wall) .clc-send {
  flex: 0 0 auto;
  min-height: 38px;
  padding: 8px 13px;
  border-radius: var(--r-md);
  border: 1px solid rgba(var(--t-gold), 0.32);
  background: rgba(var(--t-gold), 0.14);
  font-size: 11px;
  font-weight: 700;
  color: var(--gold);
}
@media (hover: hover) {
  :is(#ctx, .l3-wall) .clc-send:hover { background: rgba(var(--t-gold), 0.22); }
}
/* The composer is a real pressable target, so it keeps the 44px thumb floor in
   the same band as .ctx-blind-act (same query verbatim). */
@media (max-width: 904px), (max-height: 500px) {
  :is(#ctx, .l3-wall) .clc-send { min-height: 44px; }
  :is(#ctx, .l3-wall) .clc-input { min-height: 44px; }
}

/* === act (action bar) ===
   Full-width bar under the scene; the permanent home of the picture-in-picture
   return row, so a player who wandered off a live game always has one visible
   way back. `auto` height while empty — 54px the moment it has content, which
   is what stops an empty bar eating a tenth of a phone screen. */
#act.shell-act {
  grid-area: act;
  position: relative;
  z-index: 43;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 0;
}
#act.shell-act:empty { display: none; }
#act.shell-act:not(:empty) {
  height: var(--act-h);
  padding: 0 18px;
  background: rgba(12, 8, 24, 0.78);
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  border-top: 1px solid var(--hairline);
}

/* === the hint row ===
   Four console-style hints, each a coloured key badge and a label, and every
   one of them a door that exists in THIS arena. They are rendered only where
   the rail is (>= 800px): `#act:empty` collapsing to zero height is what stops
   the bar eating 54px of a phone, and a hint row that is always there is a bar
   that is never empty. */
#act .act-hints { display: flex; align-items: center; gap: 9px; min-width: 0; }
#act .act-hint {
  display: flex;
  align-items: center;
  gap: 7px;
  white-space: nowrap;
  padding: 6px 12px 6px 6px;
  border-radius: var(--r-sm);
  transition: background 0.16s;
}
@media (hover: hover) {
  #act .act-hint:hover { background: rgba(var(--t-violet), 0.12); }
}
#act .act-key {
  flex: 0 0 auto;
  width: 23px;
  height: 23px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  font-size: 10px;
  font-weight: 800;
  line-height: 1;
}
#act .act-key-ok { background: rgba(var(--t-ok), 0.2); color: var(--ok); border: 1px solid rgba(var(--t-ok), 0.5); }
#act .act-key-danger { background: rgba(var(--t-danger), 0.16); color: var(--danger-soft); border: 1px solid rgba(var(--t-danger), 0.4); }
#act .act-key-gold { background: rgba(var(--t-gold), 0.16); color: var(--gold); border: 1px solid rgba(var(--t-gold), 0.4); }
#act .act-key-violet { background: rgba(var(--t-violet), 0.14); color: var(--violet-300); border: 1px solid rgba(var(--t-violet), 0.32); }
#act .act-hint-label { font-size: 11px; font-weight: 700; color: rgba(var(--t-mute), 0.68); }
#act .act-grow { flex: 1 1 auto; }

/* === the connection pill ===
   NOT drawn inside a match, and that is the whole dedupe: there the top bar's
   #connPill is live and driven by the game view's own websocket, so it knows
   lag, retries and a dropped opponent, none of which this can see. Two pills
   disagreeing about the network is worse than one that is sometimes elsewhere. */
#act .act-conn {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
  white-space: nowrap;
  padding: 5px 11px;
  border-radius: var(--r-pill);
  border: 1px solid rgba(var(--t-ok), 0.3);
  background: rgba(var(--t-ok), 0.1);
}
#act .act-conn.is-bad {
  border-color: rgba(var(--t-danger), 0.3);
  background: rgba(var(--t-danger), 0.1);
}
#act .act-conn-dot {
  flex: 0 0 auto;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--ok);
}
#act .act-conn.is-bad .act-conn-dot { background: var(--danger); }
#act .act-conn-text { font-size: 10px; font-weight: 700; color: var(--ok); }
#act .act-conn.is-bad .act-conn-text { color: var(--danger); }

/* «Сейчас» — the return row. The one-game rule makes this the single point of
   return to a live match, so it is not decoration: without it a player who
   wandered into the shop loses the game on time. */
#act .act-now {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  min-width: 0;
}
#act .act-dot { flex: 0 0 auto; color: var(--gold); }
#act .act-dot.is-live { color: var(--danger); }
#act .act-text {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
  flex: 1;
}
#act .act-game {
  font-family: var(--font-display);
  font-size: 13px;
  font-weight: 800;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#act .act-state {
  font-size: 10px;
  font-weight: 700;
  letter-spacing: var(--ls-section);
  text-transform: uppercase;
  color: var(--text-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#act .act-btn { flex: 0 0 auto; min-height: 38px; }

/* === marquee (news ticker) ===
   GONE, on every route and at every width: the mock's rendered form has no
   marquee row. Its payload lives on — ui/shell.js still polls the ticker and
   renders it as the context column's «Лента» tab. The element and its grid
   row stay (a `display: none` item collapses the `auto` track to zero), so
   the hide is one rule with nothing to compensate for. */
#marquee.shell-marquee {
  grid-area: marquee;
  display: none;
}

#marquee.shell-marquee .marquee-track {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
  min-width: max-content;
  animation: shell-marquee-scroll var(--marquee-dur, 40s) linear infinite;
  will-change: transform;
}
#marquee.shell-marquee:hover .marquee-track { animation-play-state: paused; }

/* The quiet line is a sign, not a ticker.
   The scroll keyframe translates -50% because the real track is duplicated for
   a seamless loop; one short sentence has no second copy, so the same
   animation would slide it half off the screen and back forever. It also has
   nothing to scroll past — the point is that it sits there. */
/* A real ticker that happens to fit. Same reasoning as the quiet line — the
   -50% keyframe needs a duplicated track, and this one has no duplicate
   because one copy already fits the strip. Set by shell.js after measuring. */
#marquee.shell-marquee .marquee-track.marquee-short {
  animation: none;
  min-width: 0;
  width: 100%;
  justify-content: center;
}

#marquee.shell-marquee .marquee-track.marquee-quiet {
  animation: none;
  min-width: 0;
  width: 100%;
  justify-content: center;
}
#marquee.shell-marquee .marquee-track.marquee-quiet .marquee-item {
  margin-right: 0;
  color: var(--text-faint);
  font-weight: 500;
}

@keyframes shell-marquee-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}

#marquee.shell-marquee .marquee-item {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-right: 28px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.3px;
  color: var(--text-dim);
  white-space: nowrap;
}

/* === tabbar (mobile nav) ===
   Five slots: [Хаб][2 recent games][События][Ещё]. The five have to fit at
   390px without a wrapped label, which is the whole reason the labels are
   10px with an ellipsis and the slot count stops at five. */
#tabbar.shell-tabbar { display: none; }

#tabbar.shell-tabbar .tab-item {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  min-height: 44px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  padding: 0 2px;
  border-radius: var(--r-sm);
  color: var(--text-dim);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.2px;
  transition: color 0.16s, background 0.16s;
}
@media (hover: hover) {
  #tabbar.shell-tabbar .tab-item:hover { color: var(--text); }
}
#tabbar.shell-tabbar .tab-item:active {
  color: var(--gold);
  background: rgba(var(--t-gold), 0.12);
}
#tabbar.shell-tabbar .tab-item.is-active { color: var(--gold); }
#tabbar.shell-tabbar .tab-icon { font-size: 19px; line-height: 1; }
/* Sibling of .tab-icon and, until now, the only one of the pair with no rule —
   so the labels took whatever the cascade happened to give them. */
#tabbar.shell-tabbar .tab-label {
  font-size: 10px;
  line-height: 1.1;
  letter-spacing: 0.2px;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#tabbar.shell-tabbar .tab-dot {
  position: absolute;
  top: 7px;
  left: 50%;
  margin-left: 8px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--gold);
  box-shadow: 0 0 8px rgba(var(--t-gold), 0.8);
}

/* === «Ещё» sheet ===
   Navigation transforms rather than shrinks: every section that exists on the
   rail exists on a phone too, behind one door. At rail widths the same sheet
   carries only the three screens the console eventually folds away. */
.sheet-backdrop.modal-backdrop {
  align-items: flex-end;
  justify-content: center;
  padding: 0;
}
.sheet {
  width: min(100%, 520px);
  max-height: min(78dvh, 620px);
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 8px 12px calc(16px + env(safe-area-inset-bottom));
  background: var(--panel-strong);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.3);
  backdrop-filter: blur(var(--glass-blur)) saturate(1.3);
  border: 1px solid var(--stroke-strong);
  border-bottom: 0;
  border-radius: var(--r-xl) var(--r-xl) 0 0;
  box-shadow: var(--shadow-card);
  animation: shell-sheet-rise var(--dur-med) var(--ease-out) both;
}
@keyframes shell-sheet-rise {
  from { transform: translateY(14px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .sheet { animation: none; }
}
.sheet-grip {
  flex: 0 0 auto;
  width: 38px;
  height: 4px;
  margin: 2px auto 6px;
  border-radius: var(--r-pill);
  background: var(--stroke-strong);
}
.sheet-head {
  flex: 0 0 auto;
  padding: 0 4px 6px;
  font-family: var(--font-display);
  font-size: var(--fs-section);
  font-weight: var(--fw-section);
  letter-spacing: var(--ls-section);
  text-transform: uppercase;
  color: var(--text-faint);
}
.sheet-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  gap: 6px;
}
.sheet-item {
  width: 100%;
  min-height: 48px;
  text-align: left;
}
.sheet-icon {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: var(--r-sm);
  background: rgba(var(--t-violet), 0.14);
  font-size: 16px;
  line-height: 1;
}
.sheet-dot { position: static; }
/* deadends-map S6: the guest chrome. Locked rows dim their icon and carry a
   small lock; the «Войти» row heads the sheet as the one named door in. */
.sheet-item.is-locked .sheet-icon { opacity: 0.6; }
.sheet-lock {
  flex: 0 0 auto;
  margin-left: auto;
  font-size: 11px;
  line-height: 1;
  opacity: 0.7;
  filter: grayscale(1);
}
.sheet-login .sheet-icon {
  background: rgba(var(--t-gold), 0.16);
}
.sheet-login .names { color: var(--gold); font-weight: 700; }
/* The context sheet (ui/shell.js openCtxSheet): the phone's context column in
   the sheet frame. The body IS the column's composition — it carries
   `l3-wall`, so every `:is(#ctx, .l3-wall)` component rule answers — and the
   sheet's height cap makes the body the scroller, the same physics the column
   itself states. The keyboard's rent is paid here directly (the sheet is
   fixed to the window's bottom, not the scene's), so the war-room composer
   rises above the keys. */
.sheet-ctx-door .sheet-icon { background: rgba(var(--t-violet), 0.18); }
.ctx-sheet .ctx-sheet-body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 2px 2px 8px;
}
html.kb-open .ctx-sheet { padding-bottom: calc(16px + var(--kb-h, 0px)); }
/* The settings group, under the sections. A second head inside the same list
   rather than a second list, so the sheet stays one scroller. */
.sheet-head-sub { padding-top: 10px; }
.sheet-control .sheet-icon { background: rgba(var(--t-gold), 0.12); }
.sheet-value {
  flex: 0 0 auto;
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  color: var(--text-soft);
}

/* === presence sheet ===
   What the online pill opens: NAMES, never a bare headcount. The rows are the
   very same .ctx-pres the «Друзья» tab draws (ui/shell.js reuses presenceRow),
   tightened to 48px so more names reach a fold. Geometry follows the frame:
   below 800px it is the phone bottom sheet (same family as «Ещё»); at rail
   widths it is a popover anchored under the pill — ui/shell.js sets the fixed
   top/right/width from the pill's rect and re-pins on resize. */
.pres-backdrop.modal-backdrop {
  align-items: flex-end;
  justify-content: center;
  padding: 0;
}
/* The desktop popover: a transparent scrim (click-away to close, no dim) and
   no rise animation on a backdrop that has nothing visible to fade. The sheet
   itself is lifted out of this flex by the fixed position JS gives it. */
.pres-backdrop.pres-backdrop-pop.modal-backdrop {
  background: transparent;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  animation: none;
}
.pres-sheet {
  --pres-row-h: 48px;
  width: min(100%, 520px);
  max-height: min(78dvh, 620px);
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 8px 12px calc(16px + env(safe-area-inset-bottom));
  background: var(--panel-strong);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.3);
  backdrop-filter: blur(var(--glass-blur)) saturate(1.3);
  border: 1px solid var(--stroke-strong);
  border-bottom: 0;
  border-radius: var(--r-xl) var(--r-xl) 0 0;
  box-shadow: var(--shadow-card);
  animation: shell-sheet-rise var(--dur-med) var(--ease-out) both;
}
/* As a popover the sheet is a card: rounded all the way round, no bottom bleed,
   the phone grip gone. Its width and position come from JS, not this rule. */
.pres-backdrop-pop .pres-sheet {
  border-bottom: 1px solid var(--stroke-strong);
  border-radius: var(--r-xl);
  max-height: min(70dvh, 560px);
}
.pres-backdrop-pop .pres-sheet .sheet-grip { display: none; }
.pres-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 2px;
}
/* The loading / empty / failed line — one quiet register, never a fabricated
   «никого нет» before the read has answered (see presSheetBody). */
.pres-note {
  padding: 18px 12px;
  font-size: 11px;
  line-height: 1.5;
  color: rgba(var(--t-mute), 0.62);
  text-align: center;
  text-wrap: pretty;
}

/* === offline banner ===
   Shell-level because losing the network is not a screen's problem. Sticky at
   the head of the scroller so it cannot be scrolled away, and never over the
   board — it pushes content, it does not cover it. */
.shell-offline.banner {
  position: sticky;
  top: 0;
  z-index: 6;
  margin: 10px 14px 0;
}

/* === CRT glitch (scene flash on key events) ===
   Transform/filter/clip live on #scene only — putting them on #shell would
   break the fixed bg-scene backdrop. JS toggles .glitch for one 150ms run;
   steps() give hard frame cuts instead of a smooth tween. */
@keyframes shell-glitch {
  0%   { transform: translateX(0);    filter: none;               clip-path: inset(0 0 0 0); }
  30%  { transform: translateX(-2px); filter: hue-rotate(16deg);  clip-path: inset(8% 0 0 0); }
  65%  { transform: translateX(2px);  filter: hue-rotate(-20deg); clip-path: inset(0 0 6% 0); }
  100% { transform: translateX(0);    filter: none;               clip-path: inset(0 0 0 0); }
}
#scene.glitch { animation: shell-glitch 150ms steps(1, end) 1 both; }

/* === nav dedupe ===
   The rail (desktop) and tabbar (mobile) own game navigation now; the legacy
   in-view switcher is a duplicate at every breakpoint. Outranks .game-tabs
   by specificity regardless of stylesheet load order. */
.shell .game-tabs { display: none; }

/* === responsive ===
   Below 800px the frame collapses to one column plus the tab bar: no rail, no
   context column. Nothing here compensates for overlapping chrome any more —
   every zone is a grid row and takes its own space.

   The bottom safe-area inset is split across the two branches, and only one of
   them ever pays it. Below 800px the tab bar is the last row and carries it in
   its own height and padding (see its rule below); above 800px `#tabbar` is
   `display: none`, so the last row is the action bar — and the home indicator
   was landing on the hint row, measured 21px into the band at 844x390 and
   926x428. The two rules never both apply, so no double-count is possible. */
@media (min-width: 800px) {
  .shell { padding-bottom: env(safe-area-inset-bottom); }
}
@media (max-width: 799px) {
  .shell {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "hud"
      "scene"
      "marquee"
      "act"
      "tabbar";
  }
  #rail.shell-rail { display: none; }
  #ctx.shell-ctx { display: none; }
  #tabbar.shell-tabbar {
    grid-area: tabbar;
    display: flex;
    z-index: 45;
    align-items: stretch;
    height: calc(56px + env(safe-area-inset-bottom));
    padding: 0 6px env(safe-area-inset-bottom);
    background: var(--panel-strong);
    -webkit-backdrop-filter: blur(14px) saturate(1.3);
    backdrop-filter: blur(14px) saturate(1.3);
    border-top: 1px solid var(--hairline);
  }
}

/* === phone-landscape match: the game owns the window ===
   Turn a phone sideways in a match and the mock has neither the rail nor the
   context column — the board and the move list take the whole window, full
   bleed. The frame does not know that by width: 844px is past the 800px
   phone⇄rail break, so at 844x390 the rail (62px) and the column (252px) stay
   and the scene is left 530px — measured, the board 196px where the mock's is
   ~270. The collapse is therefore a ROUTE question, not a width one: ui/shell.js
   tags the frame `.is-game` on every match route (syncGameRouteClass), and only
   there, and only in phone-landscape, do the two side zones go. A hub at the
   same 844x390 keeps its rail and column; a match does not. Desktop (height
   over 500px) is untouched — this whole block is inert there.

   The grid is redefined to the same single column the phone block uses, and the
   rail and the column are display:none exactly as they are below 800px, so the
   scene's one track takes the full width.

   THE VERTICAL CHROME GOES TOO, because the mock's landscape match draws
   NEITHER bar. Measured at 844x390 the board is height-bound — scene 278px
   (390 − hud 58 − act 54), board 196 (scene − the view's own 82px foot) — so
   width alone (above) buys nothing; the two bars are the 112px the board is
   short of the mock's ~270.
   The act bar is display:none outright: its hints and the PiP return have
   nothing to add inside a match — the game's own resign / draw row and its
   reactions live in the view (game.css's landscape block), and PiP returns to
   a board the player is already on. Its 54px go straight back to the board.
   The hud cannot simply go: it carries #connPill, the connection pill the game
   view's OWN websocket drives (lag, retries, a dropped opponent), and the pill
   has no second home inside the view — hiding the bar would strand the one
   signal a competitive match needs. So the bar thins to a strip: its grid row
   goes from the fixed 58px to `auto`, the bar drops that height and everything
   but the pill (brand, counters, the icon buttons), and the pill sits right.
   When even the pill is hidden — a replay, or a socket not yet up — there is
   nothing left the game needs, so `:has(#connPill[hidden])` removes the strip
   too and the match is full-bleed. The board reads its new height off
   `--scene-h` (ui/board.js measures the scene), so none of this touches the
   view's own 82px foot — shrinking that to fake a bigger board would steal the
   resign row's 44px touch floor, which is what the 82 pays for.

   SPECIFICITY, because two rules fight for the columns here. The desktop
   `.shell:has(#ctx.shell-ctx:empty)` rule (shell.css §ctx) scores (1,3,0) and is
   scoped to `@media (min-width: 800px)` — which 844 satisfies — so on a SIGNED-
   OUT spectator's match (renderCtx() empties #ctx, the node goes :empty) it is
   live at the same time as this block and would hand the rail its 62px track
   back as dead space on the left. A plain `.shell.is-game` (0,2,0) loses to it.
   The `:has(#ctx.shell-ctx)` is always true (#ctx is in index.html on every
   route) and exists only to lift this rule to (1,3,0) so it ties the empty-ctx
   rule and wins on source order — the same specificity arithmetic the empty-ctx
   rule's own comment works through. The display:none rules need no such help:
   `.shell.is-game #rail.shell-rail` is (1,3,0) against the rail's base (1,1,0).
   It cannot be done by editing the empty-ctx rule instead: that rule also serves
   desktop guest spectators (height over 500), where excluding game routes would
   reserve a 344px empty column track and shrink their scene. */
@media (orientation: landscape) and (max-height: 500px) {
  .shell.is-game:has(#ctx.shell-ctx) {
    grid-template-columns: minmax(0, 1fr);
    /* First track from the fixed hud height to `auto`, so the bar can thin to
       the pill strip below instead of holding its 58px against the board. */
    grid-template-rows: auto minmax(0, 1fr) auto auto auto;
    grid-template-areas:
      "hud"
      "scene"
      "marquee"
      "act"
      "tabbar";
  }
  .shell.is-game #rail.shell-rail,
  .shell.is-game #ctx.shell-ctx,
  .shell.is-game #act.shell-act { display: none; }
  /* The hud thins to a strip that carries nothing but #connPill (see the
     comment above). `height: auto` lets the `auto` grid track follow the
     content down; the pill's own padding is the strip's height. */
  .shell.is-game #hud.shell-hud {
    height: auto;
    min-height: 0;
    padding: 3px 12px;
  }
  .shell.is-game #hud .topbar-logo,
  .shell.is-game #hud .hud-left,
  .shell.is-game #hud .hud-tail,
  .shell.is-game #hud #hudMeta,
  .shell.is-game #hud .icon-btn { display: none; }
  /* No pill, no strip: a replay or a socket not yet up leaves nothing the game
     needs on the bar, so it goes and the match takes the whole window. (2,3,0)
     — the `:has()` argument's id+attribute — beats the strip rule's (1,3,0). */
  .shell.is-game #hud.shell-hud:has(#connPill[hidden]) { display: none; }
}

/* === reduced motion === */
@media (prefers-reduced-motion: reduce) {
  #marquee.shell-marquee .marquee-track { animation: none; }
  #scene.glitch { animation: none; }
}

/* Q1 (Claude round-1 verified): hud is in-flow now — the fixed-topbar
   compensation on .container would double the top gap. */
#scene.shell-scene .container { padding-top: 18px; }

/* === neon icon assets ===
   Navigation icons are generated neon art (PNG with an alpha channel baked from a
   black field), not emoji. The same `.rail-icon` / `.tab-icon` / `.sheet-icon` /
   `.gt-icon` class is worn by either a text-glyph span or an <img>; these rules
   size the image form. The extra type selector beats the glyph rules above
   regardless of source order, so emoji sizing never leaks onto the art. The rail
   image stays inside the 30px glyph box and clear of the progress ring; the sheet
   image sits inside its 30px tinted chip via padding. */
#rail.shell-rail img.rail-icon { width: 22px; height: 22px; object-fit: contain; display: block; }
#tabbar.shell-tabbar img.tab-icon { width: 24px; height: 24px; object-fit: contain; display: block; }
img.sheet-icon { width: 30px; height: 30px; object-fit: contain; box-sizing: border-box; padding: 5px; }
.gt-tab img.gt-icon { width: 18px; height: 18px; object-fit: contain; display: block; }
