/* ============================================================
   worlds.css — the worlds game (the first tab of the games view): the map
   canvas + its touch controls, the in-world panels (people / settings /
   editor), the chat + its drawers, the jukebox, the ⭐ shop / trading board
   and the game's own theme.

   These rules used to live at the end of /styles.css; they are lifted here
   verbatim (a pure move — no selector and no declaration changed). index.html
   loads this AFTER /styles.css, so the site-shell layout and the games view's
   mini-games (tic-tac-toe, guess the song) stay behind in /styles.css.
   Colour tokens come from /base.css, theme overrides from /theme.css.

   Hand-written and 4-space-indented — prettier never touches public/*.css
   (see .prettierignore).
   ============================================================ */
/* ---------- Worlds tab ("versu worlds"; the first tab of the games view). The
   app is a canvas + WebSocket module imported on demand by the games app; see
   public/js/worlds/. ---------- */
/* The walk-around needs more width than the mini-games. */
.games-content:has(.games-panel[data-game-panel="worlds"]:not([hidden])) { max-width: 940px; }
.worlds-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 12px; }
.worlds-search-row { display: flex; gap: 6px; align-items: center; }
.worlds-search {
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-family: inherit;
    font-size: 13px;
    padding: 8px 10px;
    width: 200px;
}
.worlds-search:focus { outline: none; border-color: var(--accent); }
.worlds-bubbles { display: flex; flex-wrap: wrap; gap: 8px; }
.world-pill {
    display: inline-flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 1px;
    background: var(--surface-2);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-family: inherit;
    padding: 6px 12px;
    cursor: pointer;
    transition: border-color 0.15s, color 0.15s;
}
.world-pill:hover { border-color: var(--accent); }
.world-pill-owner { border-color: var(--accent); background: var(--accent-soft); }
.world-pill-name { color: var(--text-strong); font-size: 15px; font-weight: 700; }
.world-pill-sub { color: var(--dim); font-size: 10px; }
.worlds-online { display: flex; flex-direction: column; margin-left: auto; text-align: right; }
.worlds-count { color: var(--text-strong); font-size: 13px; }
.worlds-names { color: var(--dim); font-size: 11px; max-width: 360px; }
/* The lobby (no world — e.g. right after exiting): centre the "world name:"
   picker + the online count, and focus the input (leaveWorld/focusSearch in
   worlds.js). The class is cleared again the moment a world opens. */
.game-panel-inner.worlds-lobby-state { align-items: center; }
.game-panel-inner.worlds-lobby-state .worlds-form {
    justify-content: center;
    text-align: center;
    padding: 20px 0 4px;
}
.game-panel-inner.worlds-lobby-state .worlds-online {
    margin-left: 0;
    align-items: center;
    text-align: center;
}
.worlds-body { display: flex; gap: 14px; align-items: flex-start; position: relative; }
.worlds-main { flex: 1 1 auto; min-width: 0; overflow: hidden; }
.worlds-stage-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.worlds-where { color: var(--text-strong); font-size: 14px; }
.worlds-hint { color: var(--dim); font-size: 13px; padding: 30px 0; }
.worlds-canvas-wrap { min-width: 0; width: fit-content; max-width: 100%; }
/* On desktop the map is sized from the viewport HEIGHT (portrait aspect), so it
   fits the screen without scrolling and does NOT change size when the chat column
   opens/closes (its width no longer follows the column width). */
.worlds-canvas {
    width: auto;
    height: min(calc(100vh - 250px), 820px);
    max-width: 100%;
    display: block;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    cursor: crosshair;
    /* `pan-y pinch-zoom`: a single-finger vertical drag hands the browser a PAGE pan,
       and a two-finger pinch hands it a zoom — so a phone can pinch OUT to shrink the
       tall map (512x1024) until the whole world fits the screen. Taps still reach our
       pointer handlers (the canvas pointerdown/up wiring defers a touch to pointerup,
       so a scroll / a pinch is not read as a tap). Fullscreen blocks the pan (there is
       nothing to scroll) — see `.worlds-fullscreen .worlds-canvas`. */
    touch-action: pan-y pinch-zoom;
}
/* The People/Settings rail and the chat are floating right drawers on desktop, so
   opening either never resizes the map (the map is sized from the viewport). They
   share the slot and are mutually exclusive (see setRail/setChat in worlds.js). */
.worlds-rail {
    position: absolute;
    top: 0;
    right: 0;
    width: 340px;
    max-height: 100%;
    overflow: auto;
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 10px;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
    z-index: 8;
}
.worlds-rail[hidden] { display: none; }
.worlds-rail-head { display: flex; align-items: center; gap: 8px; }
/* The open menu's title (People "here" / Settings) — one menu at a time. */
.worlds-rail-title {
    flex: 1 1 auto;
    color: var(--dim);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.worlds-rail-body { display: flex; flex-direction: column; gap: 8px; }
.worlds-pane { display: flex; flex-direction: column; gap: 8px; }
.worlds-pane[hidden] { display: none; }
.worlds-people { display: flex; flex-direction: column; gap: 6px; min-height: 120px; }
/* The friends pane (the `{}` friends-online button): the requests waiting, then the
   friends online. Same row shape as the people list. */
.worlds-friends { display: flex; flex-direction: column; gap: 6px; min-height: 120px; }
.worlds-friends-head {
    color: var(--dim);
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    padding-top: 2px;
}
.worlds-friend-actions { display: flex; gap: 4px; margin-left: auto; }
.worlds-friend-remove { margin-left: auto; }
.worlds-person { display: flex; gap: 8px; align-items: flex-start; }
.worlds-person .comment-avatar { width: 26px; height: 26px; }
.worlds-person .who { display: flex; align-items: center; gap: 4px; }
.worlds-person-name { color: var(--text-strong); font-size: 12px; }
.worlds-person-sub { color: var(--dim); font-size: 11px; }
.worlds-person-me .who { color: var(--accent); }
.worlds-typing { color: var(--dim); font-size: 11px; font-style: italic; min-height: 14px; }
.worlds-typing[hidden] { display: none; }
/* The chat drawer (see the rail above). Fills its own height by the viewport; its
   log scrolls. */
.worlds-chatbar {
    position: absolute;
    top: 0;
    right: 0;
    width: 340px;
    max-height: 100%;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 10px;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
    z-index: 9;
}
.worlds-chatbar[hidden] { display: none; }
.worlds-chatbar .worlds-chat-log { min-height: 160px; max-height: min(420px, 46vh); }
/* In the lobby (no map) the chat is NOT a floating drawer: it sits in the normal
   flow — a column beside the lobby list on desktop, stacked on a phone — so it can
   never overlay the lobby / site text. In a world the base (absolute) rules above
   apply, and this class is absent. Higher specificity means it also beats the
   mobile fixed-bottom-sheet rule below. See docs/features/worlds.md. */
.worlds-lobby .worlds-chatbar {
    position: static;
    width: min(340px, 100%);
    max-height: none;
    box-shadow: none;
    z-index: auto;
}
/* An EMBEDDED chat bar is the same idea for a host that is NOT the game: the /chat
   page and the console's chat inbox (js/admin/chat.js) each put
   `.worlds-chat-embedded` on their own wrapper, so the card keeps the worlds chat
   chrome (surface, border, log, rows, composer) while sitting in the host page's
   normal flow — never the game's floating drawer. It also beats the mobile
   fixed-bottom-sheet rule below, like the lobby rule above. See
   docs/features/chat.md. */
.worlds-chat-embedded .worlds-chatbar {
    position: static;
    width: 100%;
    max-width: 100%;
    max-height: none;
    box-shadow: none;
    z-index: auto;
}
.worlds-mention { color: var(--accent-fg); font-weight: 600; }
.worlds-chat-grouped { padding-left: 34px; }
.worlds-chat-grouped .worlds-chat-text { color: var(--text); }
.worlds-onboard {
    position: absolute;
    left: 12px;
    right: 12px;
    bottom: 76px;
    display: flex;
    align-items: center;
    gap: 10px;
    background: rgba(0, 0, 0, 0.72);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    padding: 8px 12px;
    z-index: 4;
}
.worlds-onboard[hidden] { display: none; }
.worlds-onboard-text { color: var(--text); font-size: 12px; flex: 1 1 auto; }
/* The world loading screen: an opaque cover over the map from the moment a
   world's socket opens until the room's first `state` lands (showWorldLoading in
   public/js/worlds/worlds.js), so a join reads as "loading…" rather than a blank
   canvas. It sits above the over-map control clusters (the menu is z-index 6, the
   thumb ring 3) and the onboard tip. */
.worlds-loading {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 10px;
    background: var(--bg);
    border-radius: var(--radius-sm);
    z-index: 8;
}
.worlds-loading[hidden] { display: none; }
.worlds-loading-spin {
    width: 26px;
    height: 26px;
    border: 2px solid var(--border-strong);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin-disc 0.8s linear infinite;
}
.worlds-loading-text { color: var(--text); font-size: 13px; }
.worlds-loading-name { color: var(--dim); font-size: 11px; }
@media (prefers-reduced-motion: reduce) {
    .worlds-loading-spin { animation: none; }
}
.worlds-chat-title { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
.worlds-chat-log {
    min-height: 240px;
    max-height: 420px;
    overflow: auto;
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 6px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
}
.worlds-chat-row { display: flex; gap: 8px; align-items: flex-start; }
/* The row's name line: name + badges + [global] + the full `yyyy-mm-dd hh:mm:ss`
   stamp (.chat-time carries margin-left:auto, so the stamp sits at the right). */
.worlds-chat-row .who { display: flex; align-items: center; gap: 4px; min-width: 0; }
.worlds-chat-row .worlds-chat-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.worlds-chat-row .comment-avatar { width: 26px; height: 26px; }
.worlds-chat-name { color: var(--text-strong); font-size: 12px; }
/* A message's LINE BREAKS are part of the message (see docs/features/chat.md -> "A message's
   line breaks are its own"): the /chat composer is a textarea (Shift+Enter), and so are the
   console's reply box and the AI review list's draft boxes, so a body can hold `\n` — and
   every store path keeps it. HTML's default `white-space: normal` paints each one as a
   SPACE, which is how a two-paragraph reply reached the fan as one run-on line. `pre-wrap`,
   never `pre`: an unbroken URL still wraps (the `word-break` below). Both chat mirrors — the
   fan's /chat page and the owner's console — render a body into this class and BOTH pages
   load this sheet (index.html + admin.html), so the one rule covers both; the worlds GAME's
   own chat input is a single-line <input>, so its rows can never hold a newline. */
.worlds-chat-text {
    color: var(--text);
    font-size: 13px;
    white-space: pre-wrap;
    word-break: break-word;
}
/* A linkify'd URL inside a message body (see docs/features/chat.md -> "The /chat page
   (the fan's side)"). Both chat mirrors — the fan's /chat page and the owner's console —
   paint http(s) links as real <a> tags, so the link is coloured as a link instead of the
   browser's default blue, and `word-break: break-word` above keeps a long URL inside the
   row. The UNDERLINE is what tells a link apart inside a translated row: that text is
   already --accent-fg, so colour alone would not. Lives here with .worlds-chat-text rather
   than in styles.css, because the console's rows are rendered from this sheet too
   (admin.html preloads it) and the same look must apply on every surface. */
.worlds-chat-text a { color: var(--accent-fg); text-decoration: underline; }
/* An auto-translated message (see docs/features/chat.md): the translation reads in
   --accent-fg (the accent tuned to stay legible as TEXT, so it clears 4.5:1 in every
   theme) and the WHOLE wrap is the tap target — `.chat-translated` only ever marks the
   translation SIDE, so a second tap always flips back to versutus's words and the note
   follows. */
.chat-translated-wrap { cursor: pointer; }
.chat-translated { color: var(--accent-fg); }
.chat-translated:hover { text-decoration: underline; }
.chat-translated-note { color: var(--dim); font-size: 10px; font-style: italic; }
/* A DELETED chat message (see docs/features/chat.md -> "Deleting a message"): only a
   superadmin's console ever renders one, and it stays COLLAPSED — a 🗑 placeholder in
   --red — until the wrap is clicked. The whole wrap is the tap handle in BOTH states
   (the rule the translated rows had to learn), and the row's red left border makes a
   deleted message unmistakable at a glance. */
.chat-row.chat-deleted { border-left: 2px solid var(--red); padding-left: 6px; }
.chat-deleted-wrap { cursor: pointer; }
.chat-deleted-text { color: var(--red); font-size: 13px; word-break: break-word; opacity: 0.85; }
.chat-deleted-text.chat-deleted-open { text-decoration: line-through; text-decoration-color: var(--red); opacity: 1; }
.chat-deleted-note { color: var(--red); font-size: 10px; font-style: italic; }
/* The row's tiny glyph controls: the SAME bare `--dim` control a comment row carries
   (`public/player/comments.js` — a bare 🗑 that goes --accent on hover, no chip and no
   border, the `.comment-act` look; that rule lives in styles.css, which the console does
   not load, so it is mirrored here). Two share the look on a LIVE message row in the
   console: the 🇸 read-translate glyph (a fan row with no English reading yet), the 💫
   problem-queue flag and the 🗑 delete (the /chat page carries the 🗑 on the
   fan's own row). Meant to be FOUND, not to shout. The 🇸 sits FIRST of the three, so
   translating reads before queueing and before deleting.
   They sit OUTSIDE `.comment-main` as the row's right-hand flex children, so they sit at
   the RIGHT of the row and are centred against it — never on their own line under the
   text. */
.chat-row .chat-delete,
.chat-row .chat-queue,
.chat-row .chat-reply,
.chat-row .chat-translate {
    align-self: center;
    background: transparent;
    border: none;
    color: var(--dim);
    font-family: inherit;
    font-size: 11px;
    line-height: 1;
    padding: 0 2px;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    transition: color 0.15s;
}
.chat-row .chat-delete:hover,
.chat-row .chat-queue:hover,
.chat-row .chat-reply:hover,
.chat-row .chat-translate:hover { color: var(--accent); }
/* A read-translate call is in flight on THIS row (docs/features/chat.md ->
   "read-translate"): the 🇸 goes quiet and un-clickable, so one message can never spend
   two model calls, and it lights up again if the call fails. */
.chat-row .chat-translate:disabled { opacity: 0.4; cursor: default; }
/* A message ON the problem queue (docs/features/chat.md -> "The problem queue"): the 💫 is
   GREYED and INERT — no click and no hover lift — because from the chip the flag is one-way
   (a stray click must never take a live case off the worklist); a glance down a thread still
   shows what is waiting. A SOLVED one is a ✅ BUTTON in --accent-fg: the ONE click an archived
   row carries, and it REOPENS the case (the row is otherwise the record of what was done, and
   the note / reply it holds survive the reopen until the next close). */
.chat-row .chat-queue.chat-queued { color: var(--dim); opacity: 0.55; cursor: default; }
.chat-row .chat-queue.chat-queued:hover { color: var(--dim); }
.chat-row .chat-queue.chat-solved { color: var(--accent-fg); }
/* The ↩ REPLY QUOTE (docs/features/chat.md -> "Reply quoting"): the block a reply renders
   ABOVE its own words — who was answered and what they said — plus the composer's bar above
   the reply box. BOTH are jump handles (the whole strip is the tap target), and the row a
   jump lands on flashes once (.chat-quote-flash) so the eye finds it in a long thread. The
   quote is deliberately read-only: it is the words of an OLDER message, not a place to type.

   It reads as a QUOTE CARD, not a run-on line: a 3 px accent rule, the answered name on its
   own small line (with the same ↩ the chip and the swipe use), and the words under it clamped
   to TWO lines. The clamp is the point — the server trims a quote to 160 chars, but on a
   phone that is still five lines of someone else's sentence inside a reply. The full words
   are one tap away (the jump). */
.chat-quote {
    display: block;
    border-left: 3px solid var(--accent-fg);
    background: var(--surface-2);
    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
    padding: 3px 8px 4px 7px;
    margin: 3px 0 5px;
    cursor: pointer;
    font-size: 11px;
    line-height: 1.35;
    max-width: 100%;
    transition: background-color 0.15s;
}
.chat-quote:hover { background: var(--surface-3); }
.chat-quote-who { display: block; color: var(--accent-fg); font-weight: 600; }
.chat-quote-who::before { content: '↩\00a0'; }
.chat-quote-text {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
    color: var(--dim);
    word-break: break-word;
}
.chat-quote:hover .chat-quote-text { text-decoration: underline; }
.chat-quote-bar {
    display: flex;
    align-items: center;
    gap: 6px;
    border-left: 3px solid var(--accent-fg);
    background: var(--surface-2);
    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
    padding: 4px 6px;
    margin-bottom: 6px;
    font-size: 11px;
    color: var(--dim);
    cursor: pointer;
}
/* The bar names the message being answered in the same ↩ the row renders: the mark is the
   page's own vocabulary for "this is a quote", in the console and on /chat alike. */
.chat-quote-bar::before { content: '↩'; color: var(--accent-fg); flex: 0 0 auto; }
/* `[hidden]` must be spelled out: an author `display` beats the UA `[hidden]` rule (the
   same trap as `.worlds-actions` below), so without this the bar would sit above the reply
   box even with nothing quoted — on load, after the ✕, and on a fresh thread. */
.chat-quote-bar[hidden] { display: none; }
.chat-quote-bar-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-quote-clear {
    background: transparent;
    border: none;
    color: var(--dim);
    font-family: inherit;
    font-size: 11px;
    line-height: 1;
    padding: 0 2px;
    cursor: pointer;
}
.chat-quote-clear:hover { color: var(--accent); }
/* ONE flash when a quote jumps to the message it answers. The class is re-added by JS on
   every jump (a reflow between removes), so a second jump on the same row flashes again. */
.chat-quote-flash { animation: chat-quote-flash 1.1s ease-out; }
@keyframes chat-quote-flash {
    0% {
        box-shadow: 0 0 0 2px var(--accent-fg);
    }
    100% {
        box-shadow: 0 0 0 2px transparent;
    }
}
@media (prefers-reduced-motion: reduce) {
    .chat-quote-flash { animation: none; }
}
/* The /chat page's own two lines (`#chat-spend` and `#chat-anon-keep`) are page-level
   layout, not worlds chrome — their rules live in styles.css with the rest of `.chat-*`
   (see "The chat with versutus"). */
.chat-row .chat-delete:disabled,
.chat-row .chat-queue:disabled,
.chat-row .chat-reply:disabled { opacity: 0.4; cursor: default; }
/* The 🇸 read-translate glyph on a fan row that has no English reading yet (see
   docs/features/chat.md -> "read-translate"): the same bare tiny glyph control as the 🗑
   beside it (on the row, --dim, --accent on hover, no chip and no border), sitting LEFT of
   it because translating reads before deleting. A message's WORDS stay plain — the only
   tap targets on a row are this glyph, the 🇸/flip wrap of a row that IS translated, the
   🗑, and a link. */
.chat-row .chat-translate {
    align-self: center;
    background: transparent;
    border: none;
    color: var(--dim);
    font-family: inherit;
    font-size: 11px;
    line-height: 1;
    padding: 0 2px;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    transition: color 0.15s;
}
.chat-row .chat-translate:hover { color: var(--accent); }
.chat-row .chat-translate:disabled { opacity: 0.4; cursor: default; }
/* The ⚑ report affordance on someone else's chat message (never your own, never a
   system line — see makeChatRow). `margin-left: auto` pushes it to the row's right
   edge; once reported it goes quiet and is disabled. */
.worlds-chat-report {
    margin-left: auto;
    flex: 0 0 auto;
    background: transparent;
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    color: var(--dim);
    font-family: inherit;
    font-size: 12px;
    line-height: 1;
    padding: 2px 5px;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.worlds-chat-report:hover { color: var(--red); border-color: var(--border-strong); }
.worlds-chat-report.done,
.worlds-chat-report:disabled { color: var(--dim); opacity: 0.45; cursor: default; }
.worlds-chat-form { display: flex; gap: 6px; }
.worlds-chat-input {
    flex: 1 1 auto;
    min-width: 0;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-family: inherit;
    font-size: 13px;
    padding: 8px 10px;
}
.worlds-chat-input:focus { outline: none; border-color: var(--accent); }
/* The touch twin of the desktop `/` key: a bare slash in the chat row that
   pre-fills a command. Touch only — updateCmdButton hides it in WASD mode and
   wherever the commands do not work (no admin, not the world's owner). */
.worlds-chat-cmd {
    flex: 0 0 auto;
    min-width: 34px;
    padding: 8px 10px;
    font-size: 13px;
}
/* The admin `/` command person-picker: a floating list over the map (top-left so
   it never covers the chat drawer). Rows are clicked to run the command. */
.worlds-cmdmenu {
    position: absolute;
    top: 12px;
    left: 12px;
    width: min(260px, 70%);
    max-height: min(360px, 60vh);
    overflow: auto;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 10px;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
    z-index: 10;
}
.worlds-cmdmenu[hidden] { display: none; }
.worlds-cmdmenu-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.worlds-cmdmenu-title { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
.worlds-cmdmenu-list { display: flex; flex-direction: column; gap: 4px; }
.worlds-cmdmenu-item {
    display: flex;
    align-items: center;
    gap: 8px;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-family: inherit;
    font-size: 13px;
    padding: 5px 8px;
    cursor: pointer;
    text-align: left;
}
.worlds-cmdmenu-item:hover { color: var(--text-strong); border-color: var(--accent); }
.worlds-cmdmenu-item.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
.worlds-cmdmenu-item .comment-avatar { width: 26px; height: 26px; }
/* The resident cat's picker row: a 🐱 sized to the player avatars so rows align. */
.worlds-cmdmenu-cat { width: 26px; text-align: center; font-size: 18px; line-height: 1; }
.worlds-cmdmenu-empty { color: var(--dim); font-size: 12px; padding: 4px 2px; }
.worlds-canvas-wrap { position: relative; }
/* The map's top HUD: ONE flex row across the map's top edge. It holds the LEFT
   cluster (`gameMenu`: here / friends / ⭐ / the notice lines / ⚙ / exit) and the
   RIGHT one (`fsMenu`: 🐞 + ⛶ fullscreen). The two used to be separate absolutely
   positioned boxes — max-content sized at top-left and top-right — so on a narrow
   map the left line ran straight UNDER the right pair: the buttons overlapped.
   As one row the left cluster wraps inside the space left of the right cluster,
   which keeps its corner (`justify-content: space-between`), so the ⭐ balance
   (`.worlds-stars`, full-basis) is ALWAYS at the top-left and is simply pushed
   down by however many lines the buttons take. The row itself is click-through so
   the map still receives taps between the clusters; each cluster takes its own
   taps back. */
.worlds-hud {
    position: absolute;
    top: 8px;
    left: 8px;
    right: 8px;
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 6px;
    z-index: 6;
    pointer-events: none;
}
/* The game window's control clusters: overlaid on the map so the game stays clean
   and the map never reflows. Hidden in the lobby (no map there). `min-width: 0`
   is the wrap's licence: a flex item's default content minimum would keep the
   cluster at its max-content width and let it push under the right cluster. */
.worlds-menu {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    min-width: 0;
    pointer-events: auto;
}
.worlds-menu[hidden] { display: none; }
.worlds-menu .icon-btn { background: var(--surface-2); }
/* The ⭐ balance chip sits on ITS OWN LINE, at the top HUD row's LEFT edge, under
   whatever button lines the left cluster wrapped to (the chip's basis is the FULL
   width of the cluster, so no button can share its line). It is a plain DISPLAY — no border, no
   background, no button chrome, ever. The ONLY thing a 📱 phone changes is that the
   chip becomes TAPPABLE (`u` / a tap calls the trader from anywhere) — see
   .worlds-stars-phone below. The left-aligned text and a soft shadow keep it legible
   over the map. */
.worlds-stars {
    order: 1;
    flex: 0 0 100%;
    text-align: left;
    color: var(--text-strong);
    font-family: inherit;
    font-size: 12px;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    padding: 2px 0 0;
    background: none;
    border: 0;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.65);
    cursor: default;
}
.worlds-stars-empty { color: var(--dim); }
/* With a 📱 the balance is TAPPABLE — but it must still read as a plain readout, not a
   button: only the cursor + a hover tint change, never a border / background / padding.
   Without a phone the chip is `disabled` (paintStars), so nothing about it is clickable. */
.worlds-stars-phone {
    cursor: pointer;
}
.worlds-stars-phone:hover { color: var(--accent); }
/* The performance clock: shown only while somebody is on stage (the JS hides it
   otherwise) — its own FULL-width line, left-aligned like the ⭐ balance and ON THE
   LINE UNDER it (order 1 still precedes the food line's order 2; the full basis wraps
   it off the balance's line), with the accent tint marking it live. It is a CONTROL (a
   button that toggles the live-audio window), so it keeps the chip's plain look but
   reads as tappable. */
.worlds-perf-chip {
    order: 1;
    flex: 0 0 100%;
    text-align: left;
    color: var(--accent);
    font-family: inherit;
    font-size: 12px;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    padding: 2px 0 0;
    background: none;
    border: 0;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.65);
    cursor: pointer;
}
.worlds-perf-chip:hover { text-decoration: underline; }
.worlds-perf-chip[hidden] { display: none; }
/* The food line, directly UNDER the star balance (order 2 keeps it after the stars
   chip, which is order 1; the ⚙ / exit buttons stay on the first line). It reads the
   chosen snack and how many are left, so "how many can I still drop" is always on
   screen. Purely a display. */
.worlds-food-chip {
    order: 2;
    flex: 0 0 100%;
    text-align: left;
    color: var(--text-strong);
    font-size: 12px;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    padding: 1px 0 0;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.65);
}
.worlds-food-chip.worlds-food-empty { color: var(--dim); }
/* The in-map notice CLUSTER, under the food line (order 3): one line per notice —
   "star collected" / "+N ⭐" / "star burnt" / a trade closed. A new notice is
   PREPENDED, so the newest reads on TOP and the older lines are pushed DOWN instead
   of being replaced (a login pays the galaxies' income and the daily +1 back to
   back — both stay readable). showNotify caps the stack at NOTIFY_MAX (4) lines and
   drops the OLDEST, so it never grows far down over the map. */
.worlds-notify {
    order: 3;
    flex: 0 0 100%;
    text-align: left;
    padding: 1px 0 0;
}
/* One notice. The line — not the cluster — carries the tone colour, the ellipsis
   and the text shadow, so each notice keeps its own tint. */
.worlds-notify-line {
    color: var(--accent);
    font-size: 12px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}
.worlds-food-chip[hidden],
.worlds-notify[hidden] { display: none; }
/* The friend log-on / log-off tints: GREEN for a friend arriving, RED for leaving
   — the colour IS the message, so it must not be the neutral accent. The `.worlds-
   status*` pair is the lobby's status line (no in-map notice line there); the
   compound selector beats `.game-status`'s colour. BLUE is the third tone: the OTHER
   side of a live trade moved (an edit / an accept / a confirm), which is information
   about the board rather than someone coming or going. */
.worlds-notify-line.worlds-notify-ok { color: #4ade80; }
.worlds-notify-line.worlds-notify-off { color: #f87171; }
.worlds-notify-line.worlds-notify-info { color: #60a5fa; }
/* The fourth tone: a performance going LIVE (or wrapping up) — the stage's own accent
   colour, since it is about the stage rather than somebody coming or going. */
.worlds-notify-line.worlds-notify-live { color: var(--accent); }
.worlds-status.worlds-status-ok { color: #4ade80; }
.worlds-status.worlds-status-off { color: #f87171; }
.worlds-status.worlds-status-info { color: #60a5fa; }
/* The fullscreen cluster keeps its own corner at the END of the top HUD row
   (`.worlds-hud`): it never shrinks and never wraps, so it reads like a window's
   maximize control while the left cluster wraps beside it. */
.worlds-menu-right { flex: 0 0 auto; justify-content: flex-end; }
.worlds-icon { font-size: 15px; }
/* The "here" button reads the live people count ("3 here") instead of a 👥 icon. */
.worlds-here-btn { font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* The friends-online button ("{n} friends online") sits RIGHT of "here" on the
   same line. It dims when nobody is on, so a glance says whether it is worth
   opening. */
.worlds-friends-btn { font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.worlds-friends-btn.worlds-friends-empty { color: var(--dim); }
/* Text buttons in the compact over-map cluster (exit world / exit fullscreen). */
.worlds-text-btn { font-size: 12px; white-space: nowrap; }
.icon-btn {
    background: transparent;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-size: 15px;
    line-height: 1;
    padding: 5px 8px;
    cursor: pointer;
    transition: border-color 0.15s, color 0.15s;
}
.icon-btn:hover { border-color: var(--accent); }
.worlds-settings {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin: 0 0 12px;
    padding: 10px 12px;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
}
.worlds-set-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.worlds-set-label { color: var(--dim); font-size: 12px; min-width: 96px; }
.worlds-mode.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
/* Every Settings / editor on-off toggle highlights the ACTIVE side with an
   accent outline (`.active-nav`, toggled in worlds.js) — the hints + "leave
   with o" toggles AND the editor's combat toggles (weapons / knockback / pvp,
   `.worlds-toggle-btn`). Same treatment as the controls-mode buttons, so the
   current setting reads at a glance. */
.worlds-superweapon.active-nav,
.worlds-walkfx.active-nav,
.worlds-hints.active-nav,
.worlds-quickexit.active-nav,
.worlds-toggle-btn.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
/* A Settings-PANEL picker button (a `mini-btn` inside `.worlds-settings`) that carries
   `.active-nav` highlights its chosen side. This rule is SCOPED, not per-class, so a NEW
   settings picker CANNOT ship the JS class toggle without its CSS half — the very half the
   audio-latency row (`.worlds-audioquality-btn`) was missing, leaving the profile in use
   looking unselected. A toggle OUTSIDE this panel (e.g. the editor's) still needs its own
   `.active-nav` rule (above). See docs/features/theming.md -> "Aesthetic guidelines". */
.worlds-settings .mini-btn.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
.worlds-color {
    width: 40px;
    height: 28px;
    padding: 0;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    background: var(--bg);
    cursor: pointer;
}
.worlds-volume {
    flex: 1 1 100px;
    max-width: 160px;
    accent-color: var(--accent);
    cursor: pointer;
}
/* The Settings "sound effects" checkbox, directly under the volume range: the
   game's own UI cues on/off (a per-device choice — see "Sound effects"). */
.worlds-sfx-toggle {
    width: 18px;
    height: 18px;
    accent-color: var(--accent);
    cursor: pointer;
}
.worlds-admin { border-top: 1px solid var(--border); padding-top: 8px; }
.worlds-set-status { color: var(--dim); font-size: 12px; }
.worlds-set-size { min-width: 44px; text-align: center; color: var(--text-strong); font-variant-numeric: tabular-nums; }
/* The game's own theme picker (Settings). "site" follows the site theme; any
   other value restyles the game window only (see applyWorldsTheme). */
.worlds-theme {
    flex: 1 1 120px;
    max-width: 180px;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-family: inherit;
    font-size: 13px;
    padding: 6px 8px;
    cursor: pointer;
}
.worlds-theme:focus { outline: none; border-color: var(--accent); }
/* A themed game window: the worlds tab can carry its own theme (data-theme on
   this wrapper — see public/js/worlds/worlds.js). Give it a self-contained
   surface so it reads as its own window even when the site theme differs; an
   unset theme (the default) leaves the panel untouched. */
.game-panel-inner[data-theme] {
    background: var(--bg);
    border-radius: var(--radius);
    padding: 14px 16px;
}
.worlds-joystick {
    position: absolute;
    /* The anchor is the stick's CENTRE on the map's block grid: 10 blocks in from
       the left edge, 12 up from the bottom by default (152 / 184 px of the native
       512x1024 grid, i.e. the middle of the 10th / 12th 16px block). The position is
       a variable — the settings' "edit positions" writes --worlds-move-x / -y from
       the saved layout, and the fallback here is the built-in default (see
       TOUCH_DEFAULT_LAYOUT in public/js/worlds/logic.js — KEEP THE TWO IN STEP).
       Every over-map value is native px multiplied by --worlds-ui-scale (see the
       group note at .game-panel-inner). */
    left: calc(var(--worlds-move-x, 152px) * var(--worlds-ui-scale));
    bottom: calc(var(--worlds-move-y, 184px) * var(--worlds-ui-scale));
    transform: translate(-50%, 50%);
    width: calc(116px * var(--worlds-ui-scale));
    height: calc(116px * var(--worlds-ui-scale));
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.28);
    border: 2px solid rgba(255, 255, 255, 0.35);
    touch-action: none;
    z-index: 2;
}
.worlds-joystick-knob {
    position: absolute;
    left: 50%;
    top: 50%;
    width: calc(52px * var(--worlds-ui-scale));
    height: calc(52px * var(--worlds-ui-scale));
    margin: calc(-26px * var(--worlds-ui-scale)) 0 0 calc(-26px * var(--worlds-ui-scale));
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.55);
    border: 2px solid rgba(255, 255, 255, 0.8);
    pointer-events: none;
}
.worlds-fs { margin-left: 0; }
.worlds-chat-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.worlds-chat-head-main { display: flex; align-items: center; gap: 8px; min-width: 0; }
.worlds-chat-tabs { display: flex; flex-wrap: wrap; gap: 4px; }
.worlds-chat-tabs[hidden] { display: none; }
.worlds-chat-tab {
    background: transparent;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--dim);
    font-family: inherit;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    padding: 3px 8px;
    cursor: pointer;
    transition: color 0.15s, border-color 0.15s;
}
.worlds-chat-tab:hover { color: var(--text-strong); border-color: var(--accent); }
.worlds-chat-tab.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
/* A global line's [global] tag in the ALL tab — the colour is what sets it apart from
   the world lines around it. */
.worlds-chat-global { color: var(--accent-fg); font-size: 10px; letter-spacing: 0.04em; margin-left: 5px; }
/* The UTC+8 hh:mm:ss clock to the LEFT of every SYSTEM line (see makeChatRow). */
.worlds-chat-time { flex: 0 0 auto; color: var(--dim); font-size: 11px; font-variant-numeric: tabular-nums; opacity: 0.85; }
/* The Send button while the global channel cools down: it shows the countdown in the
   button's place (see paintSendButton), so give it room to avoid a layout jump. */
.worlds-chat-send { min-width: 46px; }
.worlds-chat-send.worlds-chat-cool { color: var(--dim); opacity: 0.85; cursor: default; }
.worlds-weapons { display: flex; flex-wrap: wrap; gap: 6px; }
/* The weapon picker lives in the Settings panel under food (it used to be a bar over
   the map), so its buttons share the row with the label. */
.worlds-weapons-row .worlds-weapons { flex: 1 1 auto; min-width: 0; }
/* The audio-latency picker (Settings): a three-way choice laid out like the weapon bar,
   and its buttons share the row with the label. */
.worlds-audioquality { display: flex; flex-wrap: wrap; gap: 6px; }
.worlds-audioquality-row .worlds-audioquality { flex: 1 1 auto; min-width: 0; }
.weapon-btn {
    background: transparent;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--dim);
    font-family: inherit;
    font-size: 12px;
    padding: 5px 10px;
    cursor: pointer;
    transition: color 0.15s, border-color 0.15s;
}
.weapon-btn:hover { color: var(--text-strong); border-color: var(--accent); }
.weapon-btn.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
.worlds-aim {
    position: absolute;
    /* The move stick's mirror: 10 blocks in from the RIGHT edge and 12 up from the
       bottom by default (the map is exactly 512 wide, so its centre is at 360 from
       the left edge — see the note above: the fallback mirrors
       TOUCH_DEFAULT_LAYOUT). Note this one is anchored from the LEFT too, so a
       dragged position is the same number whatever the map is wrapped in. */
    left: calc(var(--worlds-aim-x, 360px) * var(--worlds-ui-scale));
    bottom: calc(var(--worlds-aim-y, 184px) * var(--worlds-ui-scale));
    transform: translate(-50%, 50%);
    width: calc(116px * var(--worlds-ui-scale));
    height: calc(116px * var(--worlds-ui-scale));
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.28);
    border: 2px solid rgba(255, 120, 120, 0.55);
    touch-action: none;
    z-index: 2;
}
/* Brighten the ring while the stick is actually being dragged (the avatar's
   projectile needle — see drawAimArrow in public/js/worlds/worlds.js — brightens
   too). */
.worlds-aim.aiming {
    border-color: rgba(255, 120, 120, 0.95);
}
/* The 💬 chat FAB. In the LOBBY it floats bottom-centre over the page (this rule);
   inside a world it is moved into the thumb ring around the right joystick — see
   `.worlds-thumbs` below, which overrides this position. The box is the TOUCH
   target; the glyph + the visible circle are the smaller `.worlds-btn-face` span
   inside it (see that rule), so the button reads a touch lighter without shrinking
   the hit area. */
.worlds-chat-fab {
    position: absolute;
    left: 50%;
    bottom: calc(25% + 2px);
    transform: translateX(-50%);
    width: calc(52px * var(--worlds-ui-scale));
    height: calc(52px * var(--worlds-ui-scale));
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    font-size: calc(20px * var(--worlds-ui-scale));
    background: transparent;
    border: 0;
    border-radius: 50%;
    z-index: 3;
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
    transition: transform 0.12s ease;
}
.worlds-chat-fab:active { transform: translateX(-50%) scale(0.92); }
.worlds-chat-fab[hidden] { display: none; }
.worlds-chat-fab-badge {
    position: absolute;
    top: calc(-4px * var(--worlds-ui-scale));
    right: calc(-4px * var(--worlds-ui-scale));
    box-sizing: border-box;
    min-width: calc(18px * var(--worlds-ui-scale));
    height: calc(18px * var(--worlds-ui-scale));
    padding: 0 calc(4px * var(--worlds-ui-scale));
    border-radius: calc(9px * var(--worlds-ui-scale));
    background: var(--accent);
    color: #fff;
    font-size: calc(10px * var(--worlds-ui-scale));
    line-height: calc(18px * var(--worlds-ui-scale));
    text-align: center;
}
.worlds-chat-fab-badge[hidden] { display: none; }
/* ---------- the over-map touch controls (the two sticks + chat / k / j / auto) ----------
   The chat button + the k / j action buttons + the auto-fire toggle live in ONE
   overlay that covers the map, so the map never reflows — only the controls inside
   take pointer events. Each control is anchored by its CENTRE to its own point on
   the block grid: the settings' "edit positions" writes `--worlds-<id>-x` / `-y`
   (native px from the map's left / bottom) and the fallbacks here are the built-in
   defaults in TOUCH_DEFAULT_LAYOUT (public/js/worlds/logic.js) — KEEP THE TWO IN
   STEP. Those defaults put the buttons where they started life: ringing the RIGHT
   stick on a 104px ring — chat due south of it, k south-west, j due west, and
   auto-fire due north (the chat took auto-fire's old 6 o'clock spot) — but nothing
   here assumes a ring any more: any control can be dragged anywhere on the map. */
.worlds-thumbs {
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: 3;
}
/* The k / j wrapper becomes a transparent full-size overlay of the map, so each
   button can be placed on its own point (it must NOT eat the stick's pointer
   events — only the buttons below re-enable them). */
.worlds-thumbs .worlds-actions {
    position: absolute;
    inset: 0;
    display: block;
    transform: none;
}
.worlds-thumbs .worlds-actions[hidden] { display: none; }
.worlds-thumbs .worlds-chat-fab,
.worlds-thumbs .worlds-action-btn,
.worlds-thumbs .worlds-auto {
    position: absolute;
    top: auto;
    right: auto;
    pointer-events: auto;
    transform: translate(-50%, 50%);
}
/* chat — slot 4 (due south of the aim stick) by default */
.worlds-thumbs .worlds-chat-fab {
    left: calc(var(--worlds-chat-x, 360px) * var(--worlds-ui-scale));
    bottom: calc(var(--worlds-chat-y, 80px) * var(--worlds-ui-scale));
}
.worlds-thumbs .worlds-chat-fab:active { transform: translate(-50%, 50%) scale(0.92); }
/* k (drop food) — slot 5 (south-west) by default */
.worlds-thumbs .worlds-act-drop {
    left: calc(var(--worlds-k-x, 286.5px) * var(--worlds-ui-scale));
    bottom: calc(var(--worlds-k-y, 110.5px) * var(--worlds-ui-scale));
}
/* j (interact) — slot 6 (due west) by default */
.worlds-thumbs .worlds-act-interact {
    left: calc(var(--worlds-j-x, 256px) * var(--worlds-ui-scale));
    bottom: calc(var(--worlds-j-y, 184px) * var(--worlds-ui-scale));
}
/* The `l` button (the star menu) takes the slot north-west of the ring and the
   inventory's 🎒 button mirrors `k` to the south-east, so the free half of the ring
   goes to the two menus. See
   DEFAULT_TOUCH_LAYOUT.slots in public/js/worlds/logic.js. */
.worlds-thumbs .worlds-act-menu {
    left: calc(var(--worlds-l-x, 286.5px) * var(--worlds-ui-scale));
    bottom: calc(var(--worlds-l-y, 257.5px) * var(--worlds-ui-scale));
}
.worlds-thumbs .worlds-act-bag {
    left: calc(var(--worlds-bag-x, 433.5px) * var(--worlds-ui-scale));
    bottom: calc(var(--worlds-bag-y, 110.5px) * var(--worlds-ui-scale));
}
/* auto-fire — slot 0 (due north) by default (combat worlds only). It is a TEXT
   label, so it keeps its own font / padding (legibility beats scaling) and only its
   POSITION scales with the map. The fallbacks above/below land the six buttons on
   the aim stick's 104px ring; the app resolves the slots itself (see touchPoints in
   public/js/worlds/logic.js), so these are only the pre-JS state. */
.worlds-thumbs .worlds-auto {
    left: calc(var(--worlds-auto-x, 360px) * var(--worlds-ui-scale));
    bottom: calc(var(--worlds-auto-y, 288px) * var(--worlds-ui-scale));
}
/* The `j` / `k` action buttons: a press SHRINKS the button a touch and lights the
   whole HITBOX — the 46px round box the thumb actually covers — instead of only
   the smaller `.worlds-btn-face` circle inside it. The anchor transform MUST survive
   the press: a bare `scale()` (the rule this replaced) came later with equal
   specificity, so it dropped the `translate(...)` and jumped the button half its
   size out from under the finger, which could drop the press entirely. */
.worlds-thumbs .worlds-action-btn:active {
    transform: translate(-50%, 50%) scale(0.94);
    box-shadow: 0 0 0 calc(2px * var(--worlds-ui-scale)) var(--accent);
}
/* ---------- the touch-control position editor (Settings -> "edit positions") ----------
   While dragging, every control wears a dashed outline, and the app's capture-phase
   pointerdown (worlds.js) grabs the press, so it never walks, eats, opens the chat
   or fires — a drag must not act as a tap. The hint + done / cancel live in the
   settings row itself (no bar over the map: a phone's map is taller than its screen,
   so the drawer is the one surface that is always reachable). */
.worlds-posediting .worlds-joystick,
.worlds-posediting .worlds-aim,
.worlds-posediting .worlds-chat-fab,
.worlds-posediting .worlds-action-btn,
.worlds-posediting .worlds-auto {
    outline: calc(2px * var(--worlds-ui-scale)) dashed var(--accent);
    outline-offset: calc(2px * var(--worlds-ui-scale));
    cursor: move;
    touch-action: none;
}
.worlds-posediting .worlds-dragging { outline-style: solid; }
/* The one-line explanation inside the settings row, while the editor is on. */
.worlds-touch-hint { flex: 1 1 100%; color: var(--dim); font-size: 11px; line-height: 1.35; }
/* The editor's numeric rows: one per control (a name + its block / slot inputs), so a
   position can be typed instead of dragged. They double as the read-out while
   dragging (syncPosInputs), so the fields are read-only in spirit but stay editable. */
.worlds-posnums { flex: 1 1 100%; display: flex; flex-direction: column; gap: 4px; }
.worlds-posnums[hidden] { display: none; }
.worlds-posnum-row { display: flex; align-items: center; gap: 6px; }
.worlds-posnum-name { min-width: 64px; color: var(--dim); font-size: 12px; }
.worlds-posnum-dir { min-width: 14px; color: var(--dim); font-size: 12px; text-align: center; }
.worlds-num {
    width: 56px;
    box-sizing: border-box;
    background: var(--bg);
    color: var(--text);
    border: 1px solid var(--border-strong);
    border-radius: 4px;
    font-family: inherit;
    font-size: 12px;
    padding: 3px 6px;
}
.worlds-num:focus { outline: none; border-color: var(--accent); }
/* The j / k thumb action buttons (interact + drop food). Their wrapper is
   retargeted by `.worlds-thumbs` (below) into the ring around the right joystick;
   absolutely positioned so they never reflow the canvas (see the reflow gotcha in
   docs/features/worlds.md). */
.worlds-actions {
    position: absolute;
    left: 50%;
    bottom: calc(25% + 64px);
    transform: translateX(-50%);
    display: flex;
    gap: 10px;
    z-index: 3;
}
/* `[hidden]` must be spelled out: an author `display` beats the UA `[hidden]`
   rule, so without this the buttons would show in WASD mode / the lobby. */
.worlds-actions[hidden] { display: none; }
.worlds-action-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: calc(46px * var(--worlds-ui-scale));
    height: calc(46px * var(--worlds-ui-scale));
    padding: 0;
    border-radius: 50%;
    font-size: calc(18px * var(--worlds-ui-scale));
    background: transparent;
    border: 0;
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
    transition: transform 0.12s ease;
}
/* The thumb buttons (💬 chat + `k` / `j`) read a touch smaller than they touch: the
   BUTTON box stays the full 52 / 46px (the hit target) and this span — the glyph's
   own element — draws the visible circle inside it. It is a real in-flow child (not
   a pseudo-element), so the glyph paints ON TOP of the circle rather than under it.
   A press on the 💬 FAB brightens the face circle; the `j` / `k` buttons light the
   whole hitbox instead (see `.worlds-thumbs .worlds-action-btn:active`). Both boxes
   scale with the map (see --worlds-ui-scale), the 1px hairline staying crisp. */
.worlds-btn-face {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: calc(40px * var(--worlds-ui-scale));
    height: calc(40px * var(--worlds-ui-scale));
    border-radius: 50%;
    background: var(--surface-2);
    border: 1px solid var(--border-strong);
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
.worlds-action-btn .worlds-btn-face {
    width: calc(34px * var(--worlds-ui-scale));
    height: calc(34px * var(--worlds-ui-scale));
}
/* The chosen snack, as a small badge on the `k` button (the face keeps the KEY it
   fires, like `j` — see selectFood). Absolutely placed against the button box, and
   non-interactive so the press still lands on the button. */
.worlds-act-badge {
    position: absolute;
    top: calc(-2px * var(--worlds-ui-scale));
    right: calc(-2px * var(--worlds-ui-scale));
    font-size: calc(12px * var(--worlds-ui-scale));
    line-height: 1;
    pointer-events: none;
}
.worlds-chat-fab:active .worlds-btn-face,
.worlds-chat-fab:hover .worlds-btn-face { border-color: var(--accent); }
.worlds-food.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
/* The larder: each Settings snack carries how many of it we hold (paintFood) and dims
   at none — a snack is finite, since a `k` drop SPENDS one. The count is a real child
   span beside the emoji (so the glyph stays put), and an empty span means the room has
   not told us yet rather than "0". */
.worlds-food-count { margin-left: 3px; font-size: 10px; color: var(--dim); font-variant-numeric: tabular-nums; }
.worlds-food-count:empty { display: none; }
.worlds-food-empty { opacity: 0.45; }
.world-pill-count { display: block; color: var(--dim); font-size: 10px; }
/* Fullscreen: the WHOLE game window fills the screen. The fullscreen ELEMENT is
   the root (.game-panel-inner), so its status line + the lobby world-name picker
   come along — exiting a world keeps the picker in view. `onFsChange` marks BOTH
   the root and .worlds-body with `.worlds-fullscreen`: the root rules below let
   the body fill it, the .worlds-body rules give the game-window surface. The map
   is sized by fitCanvas() to fit, so the window NEVER scrolls — the drawers
   (chat / rail) overlay it. */
.game-panel-inner.worlds-fullscreen {
    align-items: stretch;
    gap: 0;
    padding: 0;
    overflow: hidden;
    background: var(--bg);
    box-sizing: border-box;
}
.game-panel-inner.worlds-fullscreen .worlds-body { flex: 1 1 auto; min-height: 0; }
/* The iPhone fallback: Safari there has no Element.requestFullscreen (only a <video>
   may go fullscreen), so the ⛶ button PINS the root over the viewport with CSS
   instead — `onFsChange` adds `.worlds-fs-fallback` (see worlds.js). Everything else
   about fullscreen is identical, so only the pinning lives here: the
   `.worlds-fullscreen` rules below still give the game-window surface. `dvh` (with a
   `vh` fallback) follows the phone's collapsing URL bar. */
.game-panel-inner.worlds-fs-fallback {
    position: fixed;
    inset: 0;
    z-index: 200;
    width: 100vw;
    height: 100vh;
    height: 100dvh;
}
.worlds-body.worlds-fullscreen {
    background: var(--bg);
    padding: 8px;
    box-sizing: border-box;
    overflow: hidden;
    gap: 8px;
}
/* In fullscreen the window never scrolls, so the map keeps every gesture (a drag
   is for the scene editor, a tap for inspect/sit) — no page pan, no pinch-zoom. */
.worlds-fullscreen .worlds-canvas { touch-action: none; }
/* On a DESKTOP, fullscreen mirrors a PHONE — a centered portrait column ("a phone
   in the middle of the screen"), the site background on the sides — instead of the
   desktop row layout (which put the map at the far left). The map fills the height;
   `--worlds-map-w` (set by fitCanvas) is that column's width, so the chat (a top
   sheet) and the rail (a bottom sheet) line up over the SAME column, exactly like a
   phone. The `:not(.worlds-phone)` guard keeps this block off a DEVICE that is a phone
   but whose ≤720px query was defeated (see phoneViewport): such a device matches
   `min-width: 721px` too, so without the guard these rules would fight the phone block
   above and win on specificity. */
@media (min-width: 721px) {
    .game-panel-inner:not(.worlds-phone) .worlds-body.worlds-fullscreen {
        flex-direction: column;
        align-items: center;
    }
    .game-panel-inner:not(.worlds-phone) .worlds-body.worlds-fullscreen .worlds-main {
        display: flex;
        flex-direction: column;
        align-items: center;
        overflow: visible;
    }
    .game-panel-inner:not(.worlds-phone) .worlds-body.worlds-fullscreen .worlds-chatbar,
    .game-panel-inner:not(.worlds-phone) .worlds-body.worlds-fullscreen .worlds-rail {
        position: absolute;
        left: 50%;
        right: auto;
        transform: translateX(-50%);
        width: min(var(--worlds-map-w, 720px), calc(100vw - 16px));
        max-height: 62vh;
        z-index: 10;
    }
    .game-panel-inner:not(.worlds-phone) .worlds-body.worlds-fullscreen .worlds-chatbar { top: 8px; bottom: auto; }
    .game-panel-inner:not(.worlds-phone) .worlds-body.worlds-fullscreen .worlds-rail { bottom: 8px; top: auto; }
    /* Mirror the phone chat: the input row sits just under the header. */
    .game-panel-inner:not(.worlds-phone) .worlds-body.worlds-fullscreen .worlds-chat-form { order: 1; }
    .game-panel-inner:not(.worlds-phone) .worlds-body.worlds-fullscreen .worlds-chat-log { order: 2; max-height: min(320px, 46vh); }
    .game-panel-inner:not(.worlds-phone) .worlds-body.worlds-fullscreen .worlds-typing { order: 3; }
}
/* The PHONE layout — a CLASS, never a media query. worlds.js -> syncPhoneClass() sets
   `.worlds-phone` on the game-window root from `phoneViewport()`, and that test does not
   trust the ≤720px media query alone: the account's text size is a root `zoom` (theme.js,
   PER DEVICE), and a root zoom rescales the space a media query is evaluated in — so on a
   phone set to ~200% the query could MISS while `innerWidth` still read the real screen
   width. This whole block then silently never applied: the map kept the desktop fit's
   clamp, the joysticks sat near its bottom edge so a downward drag reached the PAGE
   ("i can only move up"), and the world's chrome read tiny next to the 200% site — a
   fan's OnePlus report, unchanged by the phone fit meant to fix it. The fit, the
   scroll-into-view and this block are ONE decision: keep them in step with phoneViewport
   (the game window's DOM is built by JS, so there is no pre-JS paint to fall back to). */
.worlds-phone .worlds-body {
    /* A COLUMN flex container sizes its children on the HORIZONTAL axis through
       `align-items`, so the base rule's `flex-start` (which keeps the DESKTOP row's
       rail hard left) left the map shrink-to-fit: `.worlds-canvas`'s `width: 100%`
       resolved against a fit-content wrap, so it fell back to the canvas' INTRINSIC
       512px. A phone therefore got a 512px-wide map inside a ~360px viewport — the
       PAGE overflowed, not just the map (and a browser that fits an over-wide page to
       the screen shrinks the whole game window with it, which is what a fan reported
       as "the interface is too small"). STRETCH is what makes the map fill the width
       the rule below always claimed. Do not drop it. */
    flex-direction: column;
    align-items: stretch;
}
/* On a phone the rail is a fixed bottom sheet in the same slot as the chat
   (they are mutually exclusive — see setRail/setChat), so pressing ⚙ shows the
   People/Settings panel on screen instead of far below the long map. */
.worlds-phone .worlds-rail {
    position: fixed;
    left: 8px;
    right: 8px;
    bottom: 8px;
    top: auto;
    width: auto;
    max-height: 62vh;
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
    z-index: 10;
}
/* On a phone the map is FITTED TO THE SCREEN by fitCanvas (a viewport fit, exactly
   like fullscreen) — these two lines are only the PRE-FIT fallback, so the first paint
   is already a screen-sized map. The old pair (`width: 100%; height: auto`) sized the
   map from the wrap's width: the portrait 512x1024 map then stood TWICE the screen
   tall, the joysticks (12 blocks up from the map's bottom) began below the fold, and
   the only gesture left on the map down there was a vertical PAGE pan — which a fan
   reported as "i can only move up". Keep them in step with fitCanvas's phone branch.
   The wrap HUGS the map (`fit-content`, centred): every over-map control — the HUD
   clusters, both joysticks, the j / k / 💬 ring — is absolutely anchored to the WRAP,
   so a stretch (`width: auto` under the body's `align-items: stretch`) put them out on
   the page whenever the fitted map came up narrower than the screen — the whole of a
   fan's "every button is outside the map" screenshot. `max-width: 100%` (the base
   rule) keeps a wide map from overflowing the column. */
.worlds-phone .worlds-canvas-wrap { width: fit-content; margin: 0 auto; }
.worlds-phone .worlds-canvas { width: auto; height: min(calc(100vh - 16px), 1024px); height: min(calc(100dvh - 16px), 1024px); margin: 0 auto; }
/* The two joysticks keep their GRID anchors on a phone too — the world is the
   same 512x1024 either way, only its rendered size changes (see the base
   rules), so nothing is re-positioned here. The chat / k / j / auto buttons
   each carry their own anchor for the same reason (`.worlds-thumbs`). */
/* On a phone the chat drops from the TOP, never a bottom sheet: the on-screen
   keyboard lives at the bottom, so a top-anchored panel never hides behind it.
   The input row is re-ordered to sit just under the header, so it is always at
   the very top — clear of the keyboard — while the log grows below it. */
.worlds-phone .worlds-chatbar {
    position: fixed;
    left: 8px;
    right: 8px;
    top: 8px;
    bottom: auto;
    width: auto;
    z-index: 10;
    max-height: 62vh;
}
.worlds-phone .worlds-chatbar .worlds-chat-form { order: 1; }
.worlds-phone .worlds-chatbar .worlds-chat-log { order: 2; max-height: min(320px, 38vh); }
.worlds-phone .worlds-chatbar .worlds-typing { order: 3; }
.worlds-phone .worlds-online { margin-left: 0; text-align: left; }
/* Bigger touch targets + a wrappable toolbar for thumbs. */
.worlds-phone .world-pill { padding: 9px 14px; }
.worlds-phone .weapon-btn { padding: 8px 12px; }
.worlds-phone .worlds-stage-head { flex-wrap: wrap; }
/* Fullscreen on a phone: CENTER the map + its thumb controls on the screen (the
   portrait map's leftover space is split above and below it), so the game reads
   as a centered phone rather than hugging the bottom edge. fitCanvas() sizes the
   map from the viewport in fullscreen — not the wrap's measured top — so
   centering it cannot feed back into the height. */
.worlds-phone .worlds-body.worlds-fullscreen .worlds-main {
    display: flex;
    flex-direction: column;
    justify-content: center;
}

/* ---------- Worlds scene editor (owner-only design mode) ---------- */
.worlds-edit-btn { border-color: var(--accent); }
.worlds-edit-btn.active-nav { background: var(--accent-soft); color: var(--text-strong); }
/* The editor panel is an absolute overlay over the map: it must never sit in the
   flow column, which would reflow (resize) the canvas — see the reflow gotcha in
   docs/features/worlds.md. */
.worlds-editor {
    position: absolute;
    left: 8px;
    top: 8px;
    z-index: 9;
    width: min(300px, 82%);
    max-height: calc(100% - 16px);
    overflow: auto;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 10px;
    background: var(--surface-2);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}
.worlds-editor[hidden] { display: none; }
.worlds-editor-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); }
.worlds-palette { display: flex; flex-wrap: wrap; gap: 4px; }
.worlds-tool {
    background: transparent;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-family: inherit;
    font-size: 11px;
    padding: 4px 8px;
    cursor: pointer;
}
.worlds-tool:hover { border-color: var(--accent); }
.worlds-tool.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
.worlds-tool:disabled { cursor: default; }
.worlds-tool:disabled:hover { border-color: var(--border-strong); }
.worlds-editor-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.worlds-editor-row .mini-btn { padding: 3px 10px; }
.worlds-editor-status { color: var(--dim); font-size: 11px; min-height: 14px; }
/* The shift+drag rectangle hint under the palette (a one-line explanation). */
.worlds-edit-hint { font-size: 10px; line-height: 1.35; }
/* The editor head doubles as a drag handle, so the panel can be moved off a
   corner it would otherwise permanently cover; the ✕ leaves the editor. */
.worlds-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; cursor: grab; touch-action: none; }
.worlds-editor-head:active { cursor: grabbing; }
.worlds-editor-head-btns { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.worlds-editor-toggle { flex: 0 0 auto; }
.worlds-editor-close { flex: 0 0 auto; }
/* The head's ▾ arrow: a plain CSS rotation to ▸ while the panel is collapsed (the
   same trick the queue's .toggle-arrow uses), so the two states share one glyph. */
.worlds-editor-arrow { display: inline-block; font-size: 10px; color: var(--dim); transition: transform 0.2s ease; }
.worlds-editor-collapsed .worlds-editor-arrow { transform: rotate(-90deg); }
/* COLLAPSED: the panel shrinks to its head. Every other child — the palette, the
   shift-drag hint, the colour row, the pets list, the combat toggles, the actions and
   the status line — hides, so the map underneath can be edited with the panel out of
   the way. Design mode KEEPS running: the armed tool, the selection and the
   autosave-on-exit are untouched (setEditCollapsed in worlds.js sets this class —
   never hide the panel itself, or the owner really would stop editing). */
.worlds-editor-collapsed {
    width: auto;
    padding: 5px 8px;
    gap: 0;
    overflow: visible;
}
.worlds-editor-collapsed > :not(.worlds-editor-head) { display: none; }
/* The pets editor: one row per pet (a cat/dog toggle, a name box, a colour
   swatch, a remove ✕). */
.worlds-editor-pets { display: flex; flex-direction: column; gap: 4px; }
.worlds-pets-list { display: flex; flex-direction: column; gap: 4px; }
.worlds-pet-row { display: flex; align-items: center; gap: 4px; }
.worlds-pet-kind { padding: 2px 6px; }
.worlds-pet-name {
    flex: 1 1 auto;
    min-width: 0;
    background: var(--surface);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-family: inherit;
    font-size: 11px;
    padding: 3px 6px;
}
.worlds-pet-name:focus { outline: none; border-color: var(--accent); }
.worlds-pet-color { width: 30px; padding: 0; }
.worlds-pet-remove { padding: 2px 6px; }
.worlds-pets-hint { min-height: 0; }
/* The fan's own-world bubble sits beside the `v` preset; outlined so it reads as
   "yours". */
.worlds-pill-mine { border-color: var(--accent); }
/* While designing, the play controls + interaction ring step aside so the map is
   a clean surface to arrange. The 💬 FAB is in the thumb ring now, but it is still
   HIDDEN in JS (updateFabVisibility) rather than here, so one rule covers it in
   both homes (the lobby float and the world's ring). */
.worlds-editing .worlds-joystick,
.worlds-editing .worlds-aim,
.worlds-editing .worlds-auto,
.worlds-editing .worlds-actions,
.worlds-editing .worlds-menu { display: none; }
/* While designing, the canvas keeps every gesture (drag to move / place items) —
   no page pan, even on a phone (it would fight the item drag). */
.worlds-editing .worlds-canvas { cursor: default; touch-action: none; }
/* Armed rocket targeting (k with the 🚀 chosen): the map reads as "pick a player". */
.worlds-targeting .worlds-canvas { cursor: crosshair; }

/* ---------- Worlds jukebox (shared queue; opened with `j`) ---------- */
.worlds-jukebox {
    position: absolute;
    right: 8px;
    top: 8px;
    z-index: 9;
    width: min(300px, 86%);
    max-height: calc(100% - 16px);
    overflow: auto;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 10px;
    background: var(--surface-2);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}
.worlds-jukebox[hidden] { display: none; }
/* ---------- Worlds stars: the `l` menu, the trader's shop, the trading board and
   the trade notice. Every panel lives INSIDE the canvas wrap — over the map itself,
   like the jukebox menu and the `/` person-picker — so a phone and a desktop show the
   same layout, and each is built from the same row language as the `/` person-picker.
   They never coexist: `l` closes before the shop / the trade board opens (and the
   board closes before either). The trading interface is its own panel (`.worlds-trade`
   below) with one non-modal toast for the incoming notice. */
.worlds-starmenu,
.worlds-shop {
    position: absolute;
    top: 12px;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 10px;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
    z-index: 10;
}
.worlds-starmenu { left: 12px; width: min(270px, 72%); max-height: min(360px, 60vh); overflow: auto; }
.worlds-shop { right: 12px; width: min(360px, 92%); max-height: min(460px, 72vh); overflow: auto; }
.worlds-starmenu[hidden],
.worlds-shop[hidden] { display: none; }
/* The row's right-hand note (the ⭐ balance in the "use a star" row). */
.worlds-menu-note { margin-left: auto; color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
/* The inventory row's action line: what the HIGHLIGHTED trader item does, printed
   under its label (see itemDesc in worlds.js) — a panel footer would scroll out of the
   `overflow: auto` menu, so it rides inside the selected row instead. */
.worlds-cmdmenu-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.worlds-cmdmenu-desc { color: var(--dim); font-size: 11px; line-height: 1.3; }
/* A READ-ONLY row in the `l` menu: the pair a trade would swap, above the agree /
   back-out rows (it is never selectable, so it carries no border or hover). */
.worlds-menu-line {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 4px 8px;
    color: var(--text-strong);
    font-size: 13px;
}
/* The incoming-trade notice: a small card over the map that never takes the keys
   away (the answer is given from the `l` menu), so a walking player is not yanked. */
.worlds-trade-toast {
    position: absolute;
    left: 50%;
    top: 12px;
    transform: translateX(-50%);
    max-width: min(320px, 84%);
    padding: 8px 12px;
    background: var(--accent-soft, var(--surface-2));
    border: 1px solid var(--accent);
    border-radius: var(--radius-sm);
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
    color: var(--text-strong);
    font-size: 12px;
    line-height: 1.4;
    text-align: center;
    cursor: pointer;
    z-index: 11;
}
.worlds-trade-toast[hidden] { display: none; }
.worlds-shop-balance { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
/* The shop's own action row: the max button (the touch twin of Shift+J — see
   buyAllSelected). Same `.mini-btn` language as the tabs and the row actions; it
   greys out while a shop op is in flight, exactly like a row's own button. */
.worlds-shop-actions { display: flex; gap: 6px; }
.worlds-shop-action { flex: 1 1 0; padding: 5px 8px; }
.worlds-shop-action[disabled] { opacity: 0.45; cursor: default; }
/* The shop's tab strip (`a`/`d` switches it, or a tap on a tab): `buy` is the shelf,
   `sell` is the trader's buy-back list. The OPEN tab carries `.active-nav` — the same
   highlight language as every other in-map list, so which tab is showing is visible. */
.worlds-shop-tabs { display: flex; gap: 6px; }
.worlds-shop-tabs[hidden] { display: none; }
.worlds-shop-tab { flex: 1 1 0; padding: 3px 8px; }
.worlds-shop-tab.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
/* The snack SUBMENU's heading bar (the collapsed food row opens it): a back button —
   the pointer's way out, which `k` / Esc do on the keyboard — beside the sub-list's
   title. The tab strip is hidden while it shows (renderShop), so this takes its place. */
.worlds-shop-subhead {
    display: flex;
    align-items: center;
    gap: 8px;
}
.worlds-shop-subhead[hidden] { display: none; }
.worlds-shop-subback { padding: 3px 8px; }
.worlds-shop-subtitle { color: var(--dim); font-size: 12px; }
.worlds-shop-hint { color: var(--dim); font-size: 11px; }
.worlds-shop-row {
    display: flex;
    align-items: center;
    gap: 8px;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    padding: 6px 8px;
}
/* The keyboard-highlighted shelf row (w/s move it, j buys it) — the same
   `.active-nav` language the jukebox / settings lists use. */
.worlds-shop-row.active-nav { background: var(--accent-soft); border-color: var(--accent); }
/* A shelf row the balance cannot cover: dimmed, so the price (and the refusal) is
   legible at a glance instead of a silent press. */
.worlds-shop-locked { opacity: 0.55; }
/* The shelf's ONE globally-limited item — the 🎤 cover ticket (STAR_ITEMS.cover.supply
   1): a gold-edged row carrying its `1 of 1` stamp, so the trophy reads as a single
   object rather than another menu row. */
.worlds-shop-limited { border-color: var(--accent); }
.worlds-shop-stamp {
    margin-left: 6px;
    padding: 0 5px;
    border: 1px solid var(--accent);
    border-radius: 999px;
    color: var(--accent);
    font-size: 10px;
    letter-spacing: 0.06em;
    vertical-align: 1px;
    white-space: nowrap;
}
/* ...and once that ONE copy is gone (the room's atomic claim — coverSold): the edge
   goes dashed, the price is struck through and the button reads sold out. The row
   stays legible on purpose: a trophy that is gone is a story. */
.worlds-shop-sold { border-style: dashed; }
.worlds-shop-sold .worlds-shop-price { text-decoration: line-through; opacity: 0.7; }
.worlds-shop-sold .worlds-shop-stamp { border-color: var(--border-strong); color: var(--dim); }
/* The trader's "enter code" item: the 🎟️ row plus the input + submit form it opens,
   with its own status line (the form only appears once that row is used). */
.worlds-shop-code {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
    margin-top: 2px;
}
.worlds-shop-code[hidden] { display: none; }
/* The quantity form (`l` in the shop): the same input row as the code form. */
.worlds-shop-qty {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
    margin-top: 2px;
}
.worlds-shop-qty[hidden] { display: none; }
.worlds-qty-input { flex: 1 1 40%; letter-spacing: normal; text-transform: none; }
/* The buying screen: an opaque cover over the whole shelf while a `shop-buy` is in
   flight, so an overbuy cannot double-spend (the rows are blocked too). */
.worlds-shop-loading {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    background: var(--bg);
    border-radius: var(--radius-sm);
    z-index: 2;
}
.worlds-shop-loading[hidden] { display: none; }
.worlds-code-input {
    flex: 1 1 60%;
    min-width: 0;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-family: inherit;
    font-size: 13px;
    letter-spacing: 0.08em;
    padding: 6px 8px;
    /* A code is stored upper-cased (see normalizeStarCode): show what will be sent. */
    text-transform: uppercase;
}
.worlds-code-input:focus { outline: none; border-color: var(--accent); }
.worlds-code-submit { padding: 5px 10px; }
.worlds-code-status { flex: 1 1 100%; min-height: 14px; }
.worlds-shop-glyph { font-size: 20px; line-height: 1; }
.worlds-shop-info { flex: 1 1 auto; min-width: 0; }
.worlds-shop-name { color: var(--text-strong); font-size: 13px; }
.worlds-shop-desc { color: var(--dim); font-size: 11px; line-height: 1.3; }
.worlds-shop-price { color: var(--text-strong); font-size: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; }
.worlds-shop-buy { padding: 3px 8px; }
.worlds-shop-buy[disabled] { opacity: 0.45; cursor: default; }
/* The TRADING INTERFACE: a live, two-sided board. Your offer on the LEFT, theirs on
   the RIGHT, your inventory under them, and accept / confirm along the bottom. It
   lives inside the canvas wrap like every other in-world panel (see the note above).
   The panel itself never scrolls: `.worlds-trade-body` (the offer columns + your
   items) is the ONE scrolling region, so the accept row along the bottom stays on
   screen however long the lists get. */
.worlds-trade {
    position: absolute;
    top: 12px;
    left: 50%;
    transform: translateX(-50%);
    width: min(420px, 94%);
    max-height: min(78vh, 560px);
    overflow: hidden;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 10px;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
    z-index: 12;
}
.worlds-trade[hidden] { display: none; }
/* The board's ONLY scrolling region — the two offer columns and your inventory. The
   head, the quantity form, the hint and the accept / confirm row are its siblings and
   are pinned (`flex: 0 0 auto` below), so the bottom row is always visible.
   `min-height: 0` is what lets this flex child actually shrink inside the panel's
   max-height; without it a long list pushes the footer off the panel instead of
   scrolling, which is exactly the bug this replaced. */
.worlds-trade-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
.worlds-trade .worlds-cmdmenu-head { flex: 0 0 auto; }
.worlds-trade-cols { display: flex; gap: 8px; }
.worlds-trade-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.worlds-trade-h { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
.worlds-trade-list { display: flex; flex-direction: column; gap: 3px; min-height: 46px; }
.worlds-trade-inv { display: flex; flex-direction: column; gap: 4px; }
.worlds-trade-inv[hidden] { display: none; }
/* One row of the board: an offered row / a row you may add. The keyboard highlight
   reuses the shared `.active-nav` language every in-map list moves. */
.worlds-trade-row {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    text-align: left;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    padding: 4px 8px;
    color: var(--text);
    font: inherit;
    font-size: 13px;
    cursor: pointer;
}
.worlds-trade-row.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
/* THEIR side is read-only: no hover, no pointer. */
.worlds-trade-readonly { cursor: default; opacity: 0.85; }
.worlds-trade-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.worlds-trade-empty { color: var(--dim); font-size: 11px; padding: 4px 2px; }
/* The quantity form (a row you hold more than one of): the same input row as the shop.
   It, the hint and the accept / confirm row are PINNED (`flex: 0 0 auto`) — only
   `.worlds-trade-body` above scrolls. */
.worlds-trade-qty { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; flex: 0 0 auto; }
.worlds-trade-qty[hidden] { display: none; }
.worlds-trade-hint { color: var(--dim); font-size: 11px; line-height: 1.35; flex: 0 0 auto; }
.worlds-trade-actions { display: flex; gap: 6px; justify-content: flex-end; flex: 0 0 auto; }
.worlds-trade-accept,
.worlds-trade-decline { padding: 5px 12px; }
/* The edit stage's ACCEPT once pressed: a green ✔ sits BESIDE the (disabled)
   button, which is relabelled "accepted, waiting for the other party…". An edit
   clears both sides' accept, so a fresh state drops straight back to "accept". */
.worlds-trade-check { color: var(--green); font-size: 15px; font-weight: 700; line-height: 1; }
.worlds-trade-accept.accepted { opacity: 0.75; }
.worlds-trade-accept.accepted:disabled { cursor: default; }
/* Enter opens the chat WHILE the trading board is up (the board stays LIVE — closing
   your view never ends the trade). The chat docks at the TOP (a phone / fullscreen
   layout), so instead of hiding behind it the board is PUSHED DOWN: it drops to the
   bottom of the map and shortens, so both fit, and the chat sits above it. On a wide
   desktop the chat is a right-hand drawer, so the board just settles low. */
.worlds-chat-open .worlds-chatbar { z-index: 13; }
.worlds-chat-open .worlds-trade { top: auto; bottom: 8px; max-height: min(58vh, 420px); }
.worlds-jb-head { display: flex; align-items: center; justify-content: space-between; }
.worlds-jb-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); }
.worlds-jb-close { padding: 0 8px; }
.worlds-jb-now { color: var(--text-strong); font-size: 13px; min-height: 18px; }
.worlds-jb-controls { display: flex; gap: 6px; }
.worlds-jb-sub { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); margin-top: 4px; }
.worlds-jb-queue,
.worlds-jb-add { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow: auto; }
.worlds-jb-row,
.worlds-jb-add-row {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    background: transparent;
    border: 0;
    color: var(--text);
    font-family: inherit;
    font-size: 12px;
    text-align: left;
    padding: 4px 6px;
    border-radius: var(--radius-sm);
}
.worlds-jb-add-row { cursor: pointer; }
.worlds-jb-add-row:hover { background: var(--accent-soft); }
/* The keyboard highlight (`w`/`s`) on the add list — an inset accent bar so it
   reads even while the pointer is elsewhere. */
.worlds-jb-add-row.active-nav { background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent); }
.worlds-jb-idx { color: var(--dim); min-width: 14px; }
.worlds-jb-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.worlds-jb-by { color: var(--dim); font-size: 11px; }
.worlds-jb-plus { color: var(--accent); font-weight: 700; }
.worlds-jb-empty { color: var(--dim); font-size: 11px; padding: 4px 0; }
.worlds-jb-search {
    width: 100%;
    box-sizing: border-box;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text);
    font-family: inherit;
    font-size: 12px;
    padding: 6px 8px;
}
.worlds-jb-search:focus { outline: none; border-color: var(--accent); }
/* A system chat line (enter / leave / a trade / a buy / a private trade offer) is
   quiet + italic and carries a UTC+8 timestamp on its left — that pairing is what
   makes it read as SYSTEM amid the world + global lines on the ALL tab. */
.worlds-chat-sys { align-items: baseline; gap: 6px; padding-left: 6px; border-left: 2px solid var(--border-strong); }
.worlds-chat-sys .worlds-chat-text { color: var(--dim); font-style: italic; }
/* A friend's log-on / log-off also lands in the SYSTEM tab, tinted GREEN / RED to
   match the over-map notice (which fades after NOTIFY_MS — the log keeps the
   moment). The compound selectors beat the quiet system-line rule above. */
.worlds-chat-sys.worlds-chat-sys-ok { border-left-color: #4ade80; }
.worlds-chat-sys.worlds-chat-sys-ok .worlds-chat-text { color: #4ade80; }
.worlds-chat-sys.worlds-chat-sys-off { border-left-color: #f87171; }
.worlds-chat-sys.worlds-chat-sys-off .worlds-chat-text { color: #f87171; }
/* The blue tone in the SYSTEM tab: the durable copy of a live trade's other side
   moving (the notice line under the star count fades after NOTIFY_MS). */
.worlds-chat-sys.worlds-chat-sys-info { border-left-color: #60a5fa; }
.worlds-chat-sys.worlds-chat-sys-info .worlds-chat-text { color: #60a5fa; }
/* While the jukebox menu is open, the play controls step aside. */
.worlds-jukebox-open .worlds-joystick,
.worlds-jukebox-open .worlds-aim,
.worlds-jukebox-open .worlds-auto,
.worlds-jukebox-open .worlds-actions { display: none; }

/* The worlds reconnect pill: a calm status line + a slow-breathing dot. */
.game-status.worlds-reconnecting {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 4px 12px;
    border: 1px solid var(--border-strong);
    border-radius: 999px;
    color: var(--dim);
}
.game-status.worlds-reconnecting::before {
    content: '';
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--dim);
    animation: worlds-breathe 1.6s ease-in-out infinite;
}
@keyframes worlds-breathe {
    0%,
    100% {
        opacity: 0.35;
        transform: scale(0.8);
    }
    50% {
        opacity: 1;
        transform: scale(1);
    }
}
@media (prefers-reduced-motion: reduce) {
    .game-status.worlds-reconnecting::before {
        animation: none;
        opacity: 0.7;
    }
}

/* ---- the stage's live audio (worlds-audio.js; see docs/features/worlds.md) ----
   The PERFORMING INTERFACE: the window a set is run from, and it belongs to the people ON THE
   STAGE — a device off it never sees it (the crowd still HEARS the set; that is the audio
   module's own default). A member OPENS it with `j` (or the over-map performance clock, or a
   map tap), `j` again is the MIC toggle (unmute / mute) and `k` (or the ✕) closes it.
   WHO HEARS WHAT is the performance's PHASE (`.worlds-audio-phase`): during the sound
   CHECK only the people on stage hear each other, and once an ADMIN starts the set
   everyone does. An ON-STAGE admin also gets `l` (start / stop the whole show), and the
   cluster's "tap to listen" button stays the audience FALLBACK for a browser that refused
   the sound.
   A set is a GROUP: the panel shows the roster (WHO is on stage) and each remote voice
   plays through its own <audio> element in worlds-audio.js, never a per-publisher element.
   The ADMIN-only console (`.worlds-audio-console`) sits inside it: the set's start / stop,
   and a row per member with their LEVEL METER (`.worlds-audio-row-meter`) and the mute for
   them. It sits at the map's TOP-RIGHT, one row under the ⛶ fullscreen cluster
   (`.worlds-menu-right` = top: 8px / right: 8px) so it can never cover the LEFT-hand
   HUD — the ⭐ balance, the performance clock (`.worlds-perf-chip`) and the notice
   line — which must stay readable while the window is up.
   **While the CONSOLE is up the window WIDENS** (`.worlds-audio-wide`, toggled by
   worlds-audio.js with `showConsole`): the compact 240px box ellipsed every member NAME
   and left the FX editor's knobs no room, so an admin's window stretches to
   `min(620px, 100% - 16px)` — still TOP-RIGHT, so the left HUD stays clear.
   The z-index is the CONTRACT with the chat and the other panels: the window sits
   BELOW the chat drawer / rail / shop (`.worlds-chatbar` 9, 10 once it is a phone / a
   fullscreen top sheet, 13 while `worlds-chat-open`; `.worlds-rail` 8, 10 as a phone
   bottom sheet; the `/` picker and the other panels 9-12), so the chat OPENS OVER the
   performance window and any panel the player opens lands on top of it — and ABOVE
   the HUD cluster (6) and the touch controls (3). Like every in-world panel it lives
   inside the canvas wrap (never the document body), reuses the app's tokens and
   classes, and its selection language matches the other panels. */
.worlds-audio {
    position: absolute;
    top: 44px;
    right: 8px;
    left: auto;
    width: min(240px, 62%);
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 10px;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
    z-index: 7;
}
.worlds-audio[hidden] { display: none; }
/* The ADMIN console needs ROOM, so while it is up the window WIDENS well past its compact
   240px box (worlds-audio.js sets the class with `showConsole`) — a member's NAME and their
   FX editor finally have space instead of ellipsing. It stays anchored TOP-RIGHT and is
   capped at the map's width minus the gutters, so the LEFT-hand HUD (⭐ / clock / notice
   line) is never covered: the invariant above holds in BOTH states. */
.worlds-audio.worlds-audio-wide { width: min(620px, calc(100% - 16px)); }
.worlds-audio-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.worlds-audio-title { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
/* The window's ✕: it dismisses the performance window (the clock chip brings it back). */
.worlds-audio-close { flex: 0 0 auto; padding: 0 8px; }
.worlds-audio-status { color: var(--text-strong); font-size: 13px; }
/* The connection's own line: shown ONLY when the transport turns poor (worlds-audio.js ->
   applyQuality). Amber, not the "off" red — it warns about the NETWORK, not a failure of the
   set, and the status line above keeps the strong colour. */
.worlds-audio-quality { color: #fbbf24; font-size: 11px; line-height: 1.35; }
.worlds-audio-quality[hidden] { display: none; }
.worlds-audio-note { color: var(--dim); font-size: 11px; line-height: 1.35; }
.worlds-audio-note[hidden] { display: none; }
.worlds-audio-rows { display: flex; flex-wrap: wrap; gap: 6px; }
/* The 🗣 MONITOR button in that row (worlds-audio.js -> toggleMonitor): the performer's OWN
   mic, played LOCALLY into their own headphones. It is a TOGGLE, so its state rides the
   shared `.active-nav` accent — the same language every in-map list moves (see the toggle
   rules above), which is why it needs its own selector here. */
.worlds-audio-monitor-btn.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
.worlds-audio-keys { color: var(--dim); font-size: 11px; }
/* The level meter: a thin bar that fills with the CAPTURED level, so the performer can
   see the set is actually live (and that `l` mutes it). */
.worlds-audio-meter {
    height: 6px;
    border-radius: 999px;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    overflow: hidden;
}
.worlds-audio-meter[hidden] { display: none; }
.worlds-audio-meter-fill {
    display: block;
    width: 0%;
    height: 100%;
    background: var(--accent);
    transition: width 80ms linear;
}
@media (prefers-reduced-motion: reduce) {
    .worlds-audio-meter-fill { transition: none; }
}
/* The performance window's roster line: WHO is on stage, for a GROUP (see
   world-audio-members) — it reuses the note's type, because it is one more line of
   information, not a new rung in the type scale. */
.worlds-audio-members { color: var(--dim); font-size: 11px; line-height: 1.35; }
.worlds-audio-members[hidden] { display: none; }
/* The performance's PHASE — 'sound check' until an admin starts it, 'the performance is
   live' after. It sits a rung above the notes (it is the state of the whole set, not a
   detail of this device), and it is what explains why an audience hears nothing during a
   check. */
.worlds-audio-phase { color: var(--text-strong); font-size: 12px; }
.worlds-audio-phase[hidden] { display: none; }
/* The ADMIN-only console inside the performance window (worlds-audio.js): the set's own
   start / end row, then a row per member with their LEVEL METER and the mute for them.
   The ops are asks — the room re-checks every one of them, so this is a convenience,
   never the gate. */
.worlds-audio-console { display: flex; flex-direction: column; gap: 4px; }
.worlds-audio-console[hidden] { display: none; }
.worlds-audio-console-head { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
.worlds-audio-row { display: flex; align-items: center; gap: 8px; }
.worlds-audio-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.worlds-audio-row-state { color: var(--dim); font-size: 11px; }
/* The same "off" red the notify / status lines use (.worlds-notify-off). */
.worlds-audio-row-state.is-muted { color: #f87171; }
/* The set's own switch (start the performance / end it): the row is the console's first
   child, so the button gets the whole width. */
.worlds-audio-console-actions { margin-bottom: 2px; }
.worlds-audio-console-actions .mini-btn { width: 100%; }
/* ONE member's level, as the room relays it (their own `stage-level`, ~4/s): a short bar
   beside the name, so an admin can see at a glance who is actually being heard — and a
   member who stopped simply decays back to empty. */
.worlds-audio-row-meter {
    flex: 0 0 44px;
    height: 5px;
    border-radius: 999px;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    overflow: hidden;
}
.worlds-audio-row-meter-fill {
    display: block;
    width: 0%;
    height: 100%;
    background: var(--accent);
    transition: width 80ms linear;
}
@media (prefers-reduced-motion: reduce) {
    .worlds-audio-row-meter-fill { transition: none; }
}
/* The console's per-member EFFECTS (worlds-audio.js -> the fx region): the 🎚 button on a
   member row, its OPEN state (the shared accent, like every other in-map toggle), and the
   editor it opens under that row. The knobs follow the SIGNAL order (pre -> EQ -> compressor
   -> post) and each pairs a range input with a NUMBER field the admin can type a value
   straight into, plus its unit — so a setting is both draggable and legible without hovering,
   and a member with no effects shows nothing here, because they sound exactly as they always
   did. */
.worlds-audio-row-fx { flex: 0 0 auto; padding: 0 6px; }
.worlds-audio-row-fx.active-nav { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
/* The row's remaining CONTROLS — the mute and the REMOVE (worlds-audio.js -> setMemberMute /
   removeMember): never squeeze them. The NAME is the flexible child
   (`.worlds-audio-row-name`), so a long name ellipsizes instead of clipping a button's
   label; the REMOVE sits last because it is the only control here that moves a PERSON (the
   room EJECTS them off the stage), not just their sound. */
.worlds-audio-row-mute,
.worlds-audio-row-remove { flex: 0 0 auto; }
.worlds-audio-fx {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin: 2px 0 6px;
    padding: 8px;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
}
.worlds-audio-fx-head { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; color: var(--text-strong); font-size: 12px; }
/* The head's save state (worlds-audio.js -> paintFxSaved): "saving…" while an op is in flight,
   "saved" once the room's echo confirms it stuck — tinted like the chat's own "ok" lines. */
.worlds-audio-fx-saved { color: var(--dim); font-size: 11px; }
.worlds-audio-fx-saved.is-saved { color: var(--green); }
.worlds-audio-fx-row { display: flex; align-items: center; gap: 6px; }
.worlds-audio-fx-label { flex: 0 0 66px; color: var(--dim); font-size: 11px; }
.worlds-audio-fx-range { flex: 1; min-width: 0; }
/* The typed-value field beside each slider (worlds-audio.js -> fxKnob): a compact number box,
   right-aligned on tabular figures so a typed value lines up under the one before it, with its
   unit (dB / s / :1) in the following span. */
.worlds-audio-fx-num {
    flex: 0 0 52px;
    width: 52px;
    background: var(--surface-2);
    color: var(--text);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    padding: 1px 4px;
    font-family: inherit;
    font-size: 11px;
    font-variant-numeric: tabular-nums;
    text-align: right;
}
.worlds-audio-fx-unit {
    flex: 0 0 20px;
    color: var(--dim);
    font-size: 11px;
}
.worlds-audio-fx-reset { align-self: flex-start; }
.worlds-audio-fx-hint { color: var(--dim); font-size: 11px; line-height: 1.35; }
/* The "tap to go live" / "tap to listen" affordance in the over-map cluster. It is the
   GESTURE both flows need (getUserMedia / playback), so it reads as a button and is
   tinted with the stage's own accent. */
.worlds-audio-cta {
    color: var(--accent);
    font-size: 12px;
    white-space: nowrap;
}
.worlds-audio-cta[hidden] { display: none; }
/* The CROWD's passive line, beside that fallback (worlds-audio.js -> crowdHearing): while the
   set is actually playing, this is the ONLY thing off the stage that says the sound is coming
   through, because the performing interface is member-only. Never a control — it borrows the
   fallback's TYPE, not its button shape (no background, no pointer), and stays dim so a working
   set stays quiet on the HUD. */
.worlds-audio-heard {
    color: var(--dim);
    font-size: 12px;
    white-space: nowrap;
    align-self: center;
    pointer-events: none;
}
.worlds-audio-heard[hidden] { display: none; }
/* The ON-SCREEN debug log (worlds-debug.js -> startDiag). An ABSOLUTE overlay inside the
   canvas wrap — never a flow sibling, so it cannot reflow the map (see the gotcha) — pinned
   over the map's top-left, monospace and scrollable: it is what a phone with no console
   reads and then SENDS. It deliberately takes no keys, so the worlds game keeps them. */
.worlds-diag {
    position: absolute;
    left: 8px;
    top: 8px;
    z-index: 30;
    display: flex;
    flex-direction: column;
    gap: 6px;
    width: min(360px, calc(100% - 16px));
    max-height: min(70%, 440px);
    padding: 8px;
    background: var(--surface-2);
    color: var(--text);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 11px;
    line-height: 1.35;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.worlds-diag-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.worlds-diag-title { color: var(--text-strong); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.worlds-diag-facts { margin: 0; max-height: 120px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: var(--dim); }
.worlds-diag-log {
    margin: 0;
    flex: 1 1 auto;
    min-height: 72px;
    max-height: 180px;
    overflow: auto;
    white-space: pre-wrap;
    word-break: break-word;
}
.worlds-diag-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.worlds-diag-status { color: var(--dim); }
.worlds-diag-btn[aria-pressed='true'] { background: var(--accent-soft); border-color: var(--accent); color: var(--text-strong); }
/* The lobby's status line has no in-map notice line, so the LIVE tone needs a colour
   there too — the in-map twin is .worlds-notify-live. */
.worlds-status.worlds-status-live { color: var(--accent); }
