/*
 * mobile.css — the mobile-first LAYOUT layer for Moonfall.
 * ============================================================================
 * THE GATE
 *   Every layout rule here lives inside the phone-viewport media query:
 *
 *     @media (orientation: portrait)  and (max-width: 768px),
 *            (orientation: landscape) and (max-height: 560px) { … }
 *
 *   This is VIEWPORT-driven (not `pointer:coarse`), so the mobile layout
 *   engages on real phones, in a fine-pointer DevTools device emulator, and
 *   when a desktop window is shrunk to phone size. It NEVER matches a desktop
 *   window at normal size, so the desktop experience stays byte-identical.
 *   The exact same thresholds are mirrored in JS by
 *   UI.Platform.isMobileViewport() (public/scripts/ui/platform.js).
 *   isMobileUi() is intentionally broader because it also has a mobile-UA
 *   fallback; code that relocates DOM for styles in this file must use the
 *   viewport predicate. The CSS below does not depend on `body.mobile-ui` — a
 *   stale or late class can never break layout.
 *
 *   This file is loaded LAST (after responsive.css) → cascade authority for
 *   mobile. Per-screen mobile layouts live here; per-screen stylesheets keep
 *   owning desktop. Do NOT add a rule that can match a desktop DOM outside the
 *   gate (except a brand-new utility class no desktop element uses).
 *
 * BREAKPOINTS (canonical): 480 / 768 / 1024 + the phone-landscape battle query.
 * ============================================================================
 */

/* mobile-only content is hidden by default; the gate reveals it. Safe unscoped
 * because `.mf-show-mobile` exists nowhere on desktop DOM. */
.mf-show-mobile {
  display: none;
}

@media (orientation: portrait) and (max-width: 768px),
       (orientation: landscape) and (max-height: 560px) {

  /* ---- design tokens: one spacing/size scale ------------------------------
     The --mf-safe-* insets are declared globally in main.css so every layer
     can use them; do not redeclare them here. */
  :root {
    --mf-gap-xs: 4px;
    --mf-gap-sm: 8px;
    --mf-gap: 12px;
    --mf-gap-lg: 16px;
    --mf-gap-xl: 24px;

    /* minimum comfortable touch target (WCAG 2.5.5 / platform HIG) */
    --mf-touch: 44px;

    /* Vertical space the fixed account bar occupies, derived from where it
       actually sits (0.5rem from the top) and how tall its pills actually are
       (the tap floor), plus a 4px breathing gap. Any surface that scrolls
       underneath it reserves THIS — never a hand-picked number, which is how
       the lobby headings ended up under the stardust pill. */
    --mf-topbar-clear: calc(var(--mf-safe-top) + 0.5rem + var(--mf-touch) + 4px);

    /* The top-right chrome lane. Declared once so the account bar on the
       opposite side reserves the lane the corner actually occupies instead of
       a hand-tuned constant that drifts every time a control changes width. */
    --mf-chrome-gap: 6px;
    --mf-chrome-edge: 0.75rem;

    /* A phone paints every round rail trigger at the touch floor (the
       min-width/min-height rules below), so the shared rail tokens from
       main.css are restated in those units. Reserving the 34px desktop
       diameter instead would leave the cluster 10px short and slide Language
       under the corner control. */
    --mf-rail-btn: var(--mf-touch);
    --mf-rail-gap: var(--mf-chrome-gap);

    /* ONE CONTROL, NOT THREE — and the 130px difference is a whole defect.

       This reserved `edge + Mute + gap + Language + gap + Feedback` = 186px,
       because that is what the corner held when the token was written. It does
       not any more: `chrome-menu.js` adopts Feedback, Language and the
       full-screen trigger into the menu panel unconditionally at startup
       (`adoptLanguage`/`adoptFeedback`, and `ADOPTEE_HOSTS` re-runs adoption
       when i18n builds the selector late), so `#lobby-language-cluster` is a
       permanently empty box and the corner paints exactly one 44px trigger,
       `#audio-volume-control`.

       Measured on the running lobby, all three engines, empty space between the
       account bar's right edge and that trigger: 213px at 844x280 with the
       owner's 47/47 insets, 229px without them, 289px at 932x300. Meanwhile the
       bar on the other side of that gap was over-full by ~120px and paying for
       it out of the Moonlight meter, which spilled its percentage out of its
       own pill — reported as "its text is shooting out of the element".

       So: reserve the trigger and its edge, which is what is there.
       `--mf-rail-fullscreen-slot` stays OUTSIDE this token and is subtracted at
       the consumer: a custom property is substituted at the element that
       declares it, and at `:root` that slot is still 0px, so folding it in here
       would freeze it closed (contract: chromeRail.test.js). The account bar's
       own `0.75rem + --mf-chrome-gap` gutter keeps it clear of the trigger even
       when a coarse pointer raises `--mf-touch` to 48px on the corner's own
       subtree — measured clearance 6px at the narrowest box, 57px at 844x280. */
    --mf-chrome-lane: calc(var(--mf-chrome-edge) + var(--mf-touch));

    --mf-radius: 14px;
    --mf-radius-sm: 10px;
  }

  /* ---- global mobile baseline ------------------------------------------- */
  body {
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
    -webkit-tap-highlight-color: transparent;
    overscroll-behavior: none;
  }

  /* The DOCUMENT never scrolls on a phone — asked for on every screen ("it's
     still possible to scroll when deck building and it fucks the screen up").
     Every surface owns an inner scroll lane (lobby-view, detail stages,
     catalog grids), so a scrollable document is always a few px of layout
     error that lets iOS rubber-band the whole page and animate its bars
     mid-gesture. Lock it at the root; inner lanes are unaffected. */
  html, body {
    overflow: hidden;
    height: 100dvh;
  }

  /* ---- utilities (consumed by M-B / M-C) -------------------------------- */

  /* hide desktop-only chrome on mobile (e.g. the lobby logo) */
  .mf-hide-mobile {
    display: none !important;
  }

  /* reveal mobile-only content */
  .mf-show-mobile {
    display: revert;
  }

  /* fit-to-viewport screen/modal shell: fills the dynamic viewport, respects
   * the safe area, lays children in a column that never needs the page to
   * scroll. Screens opt in with class `mf-sheet`. */
  .mf-sheet {
    position: fixed;
    inset: 0;
    height: 100dvh;
    width: 100vw;
    display: flex;
    flex-direction: column;
    padding:
      calc(var(--mf-safe-top) + var(--mf-gap))
      calc(var(--mf-safe-right) + var(--mf-gap))
      calc(var(--mf-safe-bottom) + var(--mf-gap))
      calc(var(--mf-safe-left) + var(--mf-gap));
    gap: var(--mf-gap);
    box-sizing: border-box;
    overflow: hidden; /* the shell never scrolls; one inner region may */
  }

  /* the single region inside a sheet allowed to scroll when its content is
   * genuinely larger than the viewport (a catalog/list) */
  .mf-sheet-scroll {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
  }

  /* enforce the touch-target floor on any control that opts in */
  .mf-touch-target {
    min-width: var(--mf-touch);
    min-height: var(--mf-touch);
  }

  /* ---- Viewport-driven touch floors -----------------------------------------
     Every per-screen 44px tap bump is gated on @media (pointer: coarse), which a
     landscape phone in a fine-pointer emulator (and some hybrid touch devices)
     never trips — leaving 31-36px controls (measured across lobby + collection +
     foundry). Re-assert the floor here under the viewport gate, for every
     tappable control those coarse-only blocks target, so a phone ALWAYS gets a
     real touch target regardless of the reported pointer type. */
  #tutorial-btn, #solo-adventure-btn, #create-room-btn, #join-room-btn,
  #copy-code-btn, #copy-link-btn, #open-foundry-btn, #open-collection-btn,
  #go-back-btn, .pvp-discord-link, #room-code-input,
  .collection-modal .deck-tab, .deck-builder-search, .deck-filter,
  #collection-search, #collection-filter-collection, #collection-filter-mana,
  #collection-filter-spell-owner,
  #search-input, .foundry-tab, .sidebar-pagination button, .sidebar-footer button,
  .feedback-link, .foundry-language-select {
    min-height: var(--mf-touch);
  }

  /* An emoji flag renders well above its font-size on Android, so a 13px glyph
     needs more than a 13px line box or it is clipped top and bottom — reported
     as the flag being "partially cut off" in Moon Foundry. It was also the one
     control in that row still on the 34px desktop height while its neighbours
     had taken the 44px floor above. */
  .foundry-language-select {
    line-height: 1.5;
    padding-block: 6px;
  }

  /* main.css transitions every button property. A viewport switch would then
     animate the 44px touch floor itself, briefly shrinking controls and
     clipping the PvP panel. Only visual/press properties should transition. */
  #tutorial-btn, #solo-adventure-btn, #create-room-btn, #join-room-btn,
  #copy-code-btn, #copy-link-btn, #open-foundry-btn, #open-collection-btn,
  #go-back-btn, .pvp-discord-link {
    transition-property: background-color, color, border-color, box-shadow, filter, transform;
  }

  /* Collection tabs also inherit `transition: all` on desktop. Do not animate
     the mobile touch floor from 34px to 44px after a viewport change: the
     control must be safely tappable on its first rendered frame. */
  .collection-modal .deck-tab {
    min-width: var(--mf-touch);
    transition-property: background-color, color, border-color, box-shadow, filter, transform;
  }

  #room-info .pvp-discord-link {
    min-inline-size: var(--mf-touch);
    min-block-size: var(--mf-touch);
  }

  /* ---- battle: the left-only kill-log feed frees the whole right edge ------ */
  /* Summoner portraits are sized by indicators.css's viewport-driven battle
     block (36×26vmin, generous now the right edge is free). */

  /* The exit's phone sizing lived here and out-specified the menu row it is now
     part of — `body.game-active #skip-tutorial-btn` is (1,1,1) against
     `.chrome-menu-panel #skip-tutorial-btn`'s (1,1,0), so a phone got 13px text
     and different padding inside a panel whose other rows did not. Deleted:
     the row takes the panel's geometry like every other row.

     The guest tutorial, where the exit is handed BACK to the page as a floating
     Skip control, keeps its thumb size from `tutorial.css`'s
     `@media (pointer: coarse)` block — 44x44, 8px/14px, 13px text. */

  /* Coach marks are primary tutorial controls too. Their desktop vmin sizing
     can fall below a thumb-sized target on short landscapes. */
  .tutorial-tip-dismiss {
    min-height: var(--mf-touch);
    padding: 8px 14px;
    font-size: 13px;
    border-radius: 8px;
  }

  /* End Turn shares the summoner icons' lane exactly. The portraits are fixed
     at env(safe-area-inset-right) with --phone-command-banner-width, so any
     other right offset puts the command column off their centre line — the
     12px edge gap that used to live here measured 12px of drift at 932x300 and
     read as a crooked HUD. Return to Lobby takes the same lane; Audio is
     centred on End Turn by audio-settings.js and follows.

     The inset is NOT optional: this rule previously read `right: 0` on a
     comment that claimed the portraits did too. They never did, so on a
     notched phone the command column sat 59px right of the portraits AND
     inside the Dynamic Island, where the system takes the tap. */
  body.game-active #end-turn-btn {
    right: var(--mf-safe-right, 0px);
    left: auto;
  }

  /* The story toast/inspect popup must not blanket the board on a phone: keep
     it compact and hugging the left feed, and let long text scroll rather than
     grow over the play area. */
  #story-feed-popup {
    width: min(52vw, 320px);
    max-height: calc(
      100dvh
      - max(env(safe-area-inset-top, 0px), 8px)
      - max(env(safe-area-inset-bottom, 0px), 8px)
    );
    overflow-y: auto;
  }

  /* Lock the battle to pointer-driven play: with touch-action:auto a touch
     drag on the board pans the browser ("draggable board"). card-drag uses
     pointer events, which work under any touch-action value. The feed surfaces
     opt back into vertical scrolling.

     `pinch-zoom`, not `none`: the battle still refuses panning, but a pinch
     may reach the viewport, because a pinch that touches a card is the
     player's magnifier ("allow zooming on cards, just for purpose of
     enlarging text/assets", 2026-09-19). touch-action composes by RESTRICTION
     up to the viewport, so `none` here would take the pinch away from every
     hand and board card no matter what the root allows. Which pinches zoom
     is decided by `zoom-policy.js` (one finger on a card); a pinch on bare
     board is still cancelled there. Contract:
     tests/unit/styles/battleZoomPolicy.test.js. */
  body.game-active #game-view {
    touch-action: pinch-zoom;
  }

  /* The ROOT policy lives in `touch-policy.css`, which EVERY document links —
     this file is loaded by index.html alone, so a rule here never reached
     Moon Foundry, the storybook or the landing page. The per-surface rules
     below stay here because they are about these screens specifically.
     `manipulation` on a card = pan + pinch, no double-tap: the card itself
     never takes the pinch away that the root now grants (zoom-policy.js
     decides which pinches zoom — those with a finger on one of these). */
  .card,
  .deck-card-item,
  .mulligan-card-wrapper,
  .card-pack-card,
  .sidebar-element,
  .ds-deck-item,
  .ds-character-card,
  .db-character-card,
  .moon-dream-spell-card,
  .moon-dream-run {
    touch-action: manipulation;
  }

  .story-feed-column,
  #story-feed-popup {
    touch-action: pan-y;
  }

  /* ---- lobby: one top-aligned command center ------------------------------ */
  /* The wordmark yields the phone's vertical budget to the actual destinations.
     The lobby itself is the single scroll owner, so Quests stay reachable rather
     than being removed when the content is taller than a short landscape. */
  #game-logo,
  .lobby-scroll .logo-container {
    display: none;
  }

  #lobby-view {
    /* English is sized to fit without scrolling; long translations may grow
       this single outer scroll owner so Quests are never clipped. */
    overflow-y: auto;
    overflow-x: hidden;
    touch-action: pan-y;
    overscroll-behavior-y: contain;
    /* The account bar is FIXED, so anything this scroller brings into view can
       slide underneath it. Focusing the room-code field makes the browser
       scroll that input into view, and the command cards went with it: measured
       on a 932x300 page box shrunk to 170 by the keyboard, all three cards
       landed at y=-1, under a bar occupying 8-52. Reserve the bar (and the
       bottom safe area) in the SCROLL geometry so scroll-into-view stops clear
       of it instead of behind it. */
    /* The account bar is FIXED and overlays this scroller, so any reserve that
       lives INSIDE the scrolled content scrolls away with it. Focusing the
       room-code field scrolls that input into view, and the reserve went with
       it: measured on a 932x300 page box shrunk to 170 by the keyboard,
       scrollTop went 0 -> 57 and all three command cards landed at y=-1, under
       a bar occupying 8-52. Reserving the bar on the CONTAINER takes it out of
       the scrollport entirely, so content physically cannot reach it. */
    /* The account bar is FIXED and used to overlay this scroller. Any reserve
       placed INSIDE the scrolled content — or in this container's padding —
       scrolls away with the content, so focusing the room-code field dragged
       the command cards under the bar: measured on a 932x300 page box shrunk to
       170 by the keyboard, scrollTop went 0 -> 57 and all three cards landed at
       y=-1 against a bar occupying 8-52. Start the SCROLLPORT below the bar
       instead; then no scroll position can put content behind it. */
    /* A `top` + `height` pair OVERRIDES the desktop `bottom:
       var(--legal-footer-h)` reservation, so the scrollport ran to the bottom
       of the page box and the legal bar — fixed at bottom:0, z-index 1001,
       ~50px tall on a phone — sat on top of the last panel. Measured on an
       844x288 box: footer 238-288, #single-player-panel ending at 253, with
       nothing scrolling and nothing clipping those 15px. It also broke the
       centring below, because half of the box being centred in was not
       visible. Reserve BOTH ends here. */
    top: var(--mf-topbar-clear);
    height: calc(100% - var(--mf-topbar-clear) - var(--legal-footer-h, 40px));
    box-sizing: border-box;
    scroll-padding-bottom: calc(var(--mf-safe-bottom) + var(--mf-gap));
  }

  .lobby-scroll {
    /* #lobby-view now reserves the fixed account bar on the container, so this
       column starts below it already; keeping the old in-content reserve here
       would double-space it. */
    padding-top: 0;
    /* the desktop 58vmin cap is only ~232px on a landscape phone — far too
       narrow for a side-by-side row. Use (almost) the full width instead. */
    width: 100%;
    max-width: min(96vw, 900px);
    height: auto;
    min-height: 100%;
    /* The bar is reserved ONCE, by the container above (`top` + reduced
       `height`). Reserving it again here reserved it twice: the column then
       carried ~52px of top padding against ~8px of bottom padding, and
       `safe center` duly centred the cards inside THAT box — which is why the
       three panels read as sitting too low after the scrollport fix, having
       previously read as too high.

       Symmetry is measured against what BOUNDS the content, not against raw
       numbers: the bar above (already excluded from the scrollport) and the
       home indicator below (which safe-bottom excludes). Equal 8px gaps to
       each therefore centre it exactly. */
    padding-top: 8px;
    padding-right: calc(var(--mf-safe-right) + 8px);
    padding-bottom: calc(var(--mf-safe-bottom) + 8px);
    padding-left: calc(var(--mf-safe-left) + 8px);
    justify-content: safe center;
    gap: var(--mf-gap);
  }

  /* min-content, NOT 0. `min-height: 0` here let these flex/grid boxes shrink
     BELOW their own content, and because `.panel` clips its overflow the excess
     did not spill — it silently vanished. On a 288pt page box the panel row was
     squeezed to 62px while its content needed 135px, which hid Single Player's
     start buttons, Foundry and Collection COMPLETELY: present in the DOM, zero
     painted pixels, impossible to tap. #lobby-view is the declared single
     scroll owner (see above), so overflowing into that scroll is the correct
     behaviour and clipping never is. */
  .lobby-command-center {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto;
    min-height: min-content;
    gap: var(--mf-gap);
  }

  /* Quests leave the lobby FLOW on a phone and become a top-bar control.
     The three command cards plus a permanent quest strip is information
     overload on a page box — the strip spent a whole content row to say "New
     quests arrive daily" — but the quests themselves still matter, so the
     account bar carries a button that expands this exact panel. Desktop keeps
     its inline rail and never renders the button. */
  #lobby-quests {
    position: fixed;
    z-index: 9998;
    top: var(--mf-topbar-clear);
    left: calc(var(--mf-safe-left) + 0.75rem);
    width: min(
      420px,
      calc(100vw - var(--mf-safe-left) - var(--mf-safe-right) - 1.5rem)
    );
    /* Never taller than the visible page: on a phone `vh` is the viewport with
       the URL bar HIDDEN, which would put the last quest under the fold. */
    max-height: calc(100dvh - var(--mf-topbar-clear) - var(--mf-safe-bottom) - 12px);
    overflow-y: auto;
    overscroll-behavior: contain;
    box-sizing: border-box;
    margin: 0;
    padding: 10px 12px;
    border-radius: var(--mf-radius);
    box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
  }

  /* `is-visible` is quests.js's "the rail has mounted" flag and carries
     `display: flex` from lobby.css, which outranks a bare id selector here.
     The phone gate has to take the display decision back at the same class
     count — otherwise the sheet is simply always open. */
  #lobby-quests.is-visible {
    display: none;
  }

  #lobby-quests.is-visible.is-open {
    display: block;
  }

  /* One quest per row: the sheet is a list, and the desktop rail's three-across
     grid would put three narrow columns in a 420px popover. */
  .lobby-quests-list {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--mf-gap-xs);
    min-height: 0;
    overflow: visible;
  }

  .lobby-quests-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    gap: 6px;
    height: var(--mf-touch);
    min-height: var(--mf-touch);
    margin: 0;
    padding: 0 12px;
    color: rgba(247, 240, 223, 0.92);
    font-size: 13px;
    font-weight: 600;
    line-height: 1;
    white-space: nowrap;
    background: rgba(0, 0, 0, 0.75);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    border: 1px solid rgba(230, 191, 106, 0.42);
    border-radius: 999px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  }

  .lobby-quests-btn[aria-expanded="true"] {
    border-color: rgba(238, 199, 97, 0.85);
    background: rgba(30, 22, 12, 0.9);
  }

  .lobby-quests-btn-glyph {
    flex: 0 0 auto;
    color: #eec761;
    font-size: 14px;
  }

  .lobby-quests-btn-count {
    flex: 0 0 auto;
    color: rgba(230, 191, 106, 0.9);
    font-variant-numeric: tabular-nums;
  }

  /* Only the narrowest page boxes drop the word — and narrowness is a WIDTH
     problem. Keying this on height too hid the label on a 932px-wide landscape
     phone with room to spare, leaving a bare glyph and a ratio that reads like
     a currency chip rather than "Quests". */
  @media (max-width: 420px) {
    .lobby-quests-btn-label {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0 0 0 0);
      clip-path: inset(50%);
      white-space: nowrap;
    }
  }

  /* ---- the command deck does not resize when a room is created ----------
     `align-items: stretch` makes the row as tall as the TALLEST card, so the
     PvP card growing dragged Single Player and Foundry with it — "creating a
     room resizes all 3 boxes. i asked specifically to avoid this". Two changes
     hold the geometry still:

       1. `start` alignment, so a change inside one card can never resize its
          neighbours. The cards keep their own natural heights.
       2. The PvP card's two states are equalised row-for-row below, so the
          card itself does not change height either. Both the idle state
          (actions + code field) and the created state (code head + invite row)
          are a 44px control row followed by a second 44px row, so pinning both
          rows to the touch floor makes the two states measure the same without
          hard-coding a pixel height that would drift.
     ------------------------------------------------------------------------ */
  .lobby-panels-container {
    display: grid;
    /* Content-sized columns, centred as a group: equal 1fr columns stretched
       the row across the whole page box and left 55% empty space around the
       outer two buttons on every phone. */
    /* The MIDDLE column is pinned. Sized by content it measured 222px idle and
       247px once the room code and invite row appeared (844x288, WebKit), and
       because the row is centred that 25px pushed both neighbours sideways —
       which is what "creating a room resizes all 3 boxes" looks like. A track
       that does not depend on the card's own content cannot do that. It still
       shrinks on a narrower page box (the `min()` term), and the room code
       truncates rather than widening it. */
    /* THREE EQUAL BOXES. Content-sized outer columns made them measure
       249/250/184px wide and 66/114/67px tall on a 932x300 page box — three
       different sizes in a row that reads as one control group. Equal `1fr`
       tracks inside a centred, capped row make every card the same width, and
       `stretch` makes them the same height, while staying independent of their
       own content — which is what keeps "creating a room resizes all 3 boxes"
       fixed. The cap stops the row sprawling edge to edge on a wide page box,
       which is why plain 1fr was rejected before. */
    grid-template-columns: repeat(3, minmax(0, 1fr));
    justify-content: center;
    align-items: stretch;
    max-width: min(100%, 760px);
    margin-inline: auto;
    min-height: min-content;
    gap: var(--mf-gap);
    width: 100%;
  }

  /* Row 1 of the PvP card in EITHER state, and row 2 likewise. Equal rows mean
     the idle and created states measure the same, so the card's own height is
     unchanged by creating a room. */
  /* The room code is the one string that can outgrow the pinned column, so it
     truncates instead. Its full value stays available from Copy Room Code. */
  #pvp-game .room-state-head > * {
    min-width: 0;
  }

  #pvp-game #room-code {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  #pvp-game > .button-row,
  #pvp-game > #room-state-head {
    min-height: var(--mf-touch);
    box-sizing: border-box;
  }

  #pvp-game > #join-room-bar,
  #pvp-game > #room-info,
  #pvp-game > #waiting-host {
    min-height: var(--mf-touch);
    box-sizing: border-box;
  }

  .lobby-panels-container > .panel {
    min-width: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    min-height: 0;
    /* Fill the stretched track so all three cards are the same height. */
    height: 100%;
    box-sizing: border-box;
    padding: 12px clamp(10px, 1.8vw, 16px);
    /* The cards inherited the DESKTOP `text-align: left`, so every heading hugged
       its own left padding edge while the controls beneath it were centred and
       full width — three titles leaning out of a row that is otherwise one
       symmetric control group. Centre the box's text on the box. */
    text-align: center;
  }

  /* Each card's own text — heading, subtitle, room-state strings — centres with
     the card. Longhand, because the auto side margins that centre a capped
     subtitle block say nothing about the ragged text inside it. */
  .lobby-panels-container > .panel > h2,
  .lobby-panels-container > .panel > .panel-subtitle,
  .lobby-panels-container > .panel .room-code-label,
  .lobby-panels-container > .panel #waiting-host p {
    text-align: center;
  }

  /* ---- the three headings sit on ONE line -------------------------------
     The PvP card has one more persistent control than its two peers, and it
     used to reclaim that budget from `padding-block` — which moved its heading
     off the line its neighbours share by however much the active height band
     had reduced the shared padding (2px up at ≤360, 2px down at ≤320, 8px up
     at the base size). Reclaim from the BOTTOM only: the top padding stays
     whatever the band gave every card, so all three titles start together.
     ------------------------------------------------------------------------ */
  .lobby-panels-container > #pvp-game {
    padding-bottom: 4px;
  }

  /* PvP's subtitle used to be the one hidden line in the row: between 361px and
     560px of page-box height its two neighbours printed a subtitle and it did
     not, so its control well sat 44-57px ABOVE theirs (measured 44.3px in
     English and 52.3px in Japanese at 900x460) — the same broken row the
     alignment work above fixes at phone heights. There is room for it: every
     card measured zero overflow with the line restored, and the lobby column
     does not scroll. The short height tiers below still drop ALL THREE
     subtitles together, which keeps the phone budget untouched. */

  /* ---- one heading rhythm for all three cards ----------------------------
     The gap under a title decides where the control well beneath it starts, so
     three different gaps put the three wells on three different lines. Two of
     the cards carried an ID-strength gap of their own (`#deck-manager h2`,
     `#pvp-game > h2`), which no class-level rule here could reach; the height
     bands below then moved only the card that had none. Publish the rhythm as
     ONE token that the bands re-declare, and spend it through a selector that
     outranks both — the card IDs. */
  .lobby-panels-container > .panel {
    --mf-lobby-title-gap: 1px;
    --mf-lobby-subtitle-gap: 8px;
  }

  .lobby-panels-container > #single-player-panel > h2,
  .lobby-panels-container > #pvp-game > h2,
  .lobby-panels-container > #deck-manager > h2 {
    margin-bottom: var(--mf-lobby-title-gap);
  }

  /* A title has to fit ONE line inside a third of the row, or the card whose
     title wrapped drops its control well a line below its neighbours'. The
     desktop clamp is sized against the viewport, which is three times the box
     the heading actually gets: at 700x420 the German PvP title wrapped and cost
     21px of alignment. Bound it by the row's own width instead; the shorter
     height bands below still take it further down. */
  .lobby-panels-container > .panel > h2 {
    font-size: clamp(12px, 2.35vw, 18px);
  }

  /* ---- and one SUBTITLE rhythm, for the same reason ----------------------
     Where the subtitles do print (361-560px of page-box height), they wrap to a
     different number of lines per card and per locale, and `#deck-manager
     .panel-subtitle` carried an ID-strength bottom margin its peers did not —
     so the wells still read 5px apart in English and 27px apart in Japanese.
     Reserve two lines for every card's subtitle and give all three the same
     gap under it; a one-line subtitle then holds the row open instead of
     lifting its own card's controls. (The token is declared with the heading's,
     above.) */
  .lobby-panels-container > #single-player-panel > .panel-subtitle,
  .lobby-panels-container > #pvp-game > .panel-subtitle,
  .lobby-panels-container > #deck-manager > .panel-subtitle {
    line-height: 1.35;
    min-height: 2.7em;
    margin-bottom: var(--mf-lobby-subtitle-gap);
  }

  /* Likewise the wells themselves: `margin-top: auto` floated Single Player's
     and Foundry's to the bottom of the equal-height card while PvP's stayed
     under its heading. Every well starts on the heading's line. */
  .lobby-panels-container > #single-player-panel > .button-row,
  .lobby-panels-container > #pvp-game > .button-row,
  .lobby-panels-container > #deck-manager > .button-row {
    margin-top: 0;
  }

  /* Back shares the replacement room-state row; it never creates a third row
     or shifts the equal-height command cards. */
  #lobby-view.show-back-button #go-back-btn {
    position: static;
    top: auto;
  }

  #deck-manager {
    grid-column: auto;
    display: flex;
    flex-direction: column;
  }

  /* ---- LANDSCAPE PHONE, <=360pt page box -----------------------------------
     A phone in landscape hands the page ~288pt once the browser's bars and tab
     strip are subtracted, and the legal bar claims 50 of those. At the sizes
     above, the three action cards plus a real Quests panel need ~279pt of the
     ~153pt that leaves, so the lobby's ONE job — start a game — sat below the
     fold (and, before the min-content fix, was clipped away entirely).

     The trade: the card subtitles go, so every action BUTTON is on screen
     without scrolling. The button labels already name the action, and Quests
     stays reachable through #lobby-view's scroll. Measured by
     tests/e2e-playwright/real/mobile-page-box.real.spec.js against a real
     Convex account, because an empty one has no Quests row and never reproduced
     the squeeze. */
  @media (max-height: 360px) {
    .lobby-scroll {
      gap: var(--mf-gap-sm);
    }
    .lobby-command-center { gap: var(--mf-gap-sm); }
    .lobby-panels-container { gap: var(--mf-gap-sm); }
    /* 6px, not 8: a Pixel 7 page box (915x336) measured its command center 3px
       taller than the scroll box — an invisible scrollbar's worth of "there is
       more below", with nothing below. Padding yields; tap targets never. */
    .lobby-panels-container > .panel { padding: 6px 10px; }
    .lobby-panels-container > .panel > .panel-subtitle { display: none; }
    .lobby-panels-container > .panel { --mf-lobby-title-gap: 4px; }
    .lobby-panels-container > .panel > h2 { font-size: 15px; }
  }

  /* ---- LANDSCAPE PHONE, <=320pt page box -----------------------------------
     A phone in Brave keeps a URL bar AND a tab strip, which leaves the page
     ~275-300pt. Measured overflow at that size was 41px (844x288), 29px
     (932x300) and 50px (667x275). Everything below is padding and line count;
     every control keeps its 44px tap target. */
  @media (max-height: 320px) {
    .lobby-scroll {
      gap: 2px;
    }
    /* The worst measured overflow at this tier was 6px (932x300, Firefox).
       Reclaimed from gaps, panel padding and heading size — comfortably more
       than the deficit, with every 44px tap target intact. */
    .lobby-command-center { gap: 2px; }
    .lobby-panels-container { gap: 2px; }
    .lobby-panels-container > .panel { padding: 2px 8px; }
    .lobby-panels-container > .panel { --mf-lobby-title-gap: 1px; }
    .lobby-panels-container > .panel > h2 { font-size: 13px; }
  }

  /* The 667x275 anchor (an SE in a browser that keeps both bars) is the
     tightest page box we support; the panel headings drop one more point. */
  @media (max-height: 290px) {
    .lobby-panels-container > .panel > h2 { font-size: 12px; }
  }

  /* Every action well owns an explicit layout and the same 8px rhythm. The old
     cascade left Foundry as a block while Single Player/PvP were flex, which is
     why their controls looked uncentered despite matching widths. */
  .lobby-panels-container .button-row,
  .lobby-panels-container .pvp-room-actions {
    display: flex;
    align-items: stretch;
    gap: var(--mf-gap-sm);
    flex-wrap: nowrap;
    width: 100%;
  }

  .lobby-panels-container .button-row {
    justify-content: center;
  }

  .lobby-panels-container .button-row > button,
  .lobby-panels-container .button-row > a,
  .lobby-panels-container .pvp-room-actions > button {
    width: 100%;
    margin: 0;
    box-sizing: border-box;
  }

  /* PvP keeps its two primary room actions in one calm action well. */
  .lobby-panels-container .pvp-room-actions {
    flex: 1 1 auto;
    min-width: 0;
    flex-direction: column;
  }

  /* Create/Join share one row; the room-code field gets the full width below. */
  #pvp-game {
    position: relative;
  }

  #pvp-game > .button-row {
    display: block;
    flex: 0 0 auto;
    margin-bottom: 0;
  }

  #pvp-game > .button-row > .pvp-room-actions {
    display: grid;
    flex-direction: row;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--mf-gap-xs);
    width: 100%;
  }

  #pvp-game > .button-row > .pvp-room-actions > button {
    min-width: 0;
    padding: 4px 2px;
    line-height: 1.1;
  }

  #pvp-game #join-room-bar {
    min-width: 0;
    margin-top: 0;
  }

  /* Discord belongs to the PvP card it is about — but as the card's quiet last
     line, not a full-width brand slab competing with Create/Join. `fit-content`
     plus `max-width: 100%` means it centres under the actions at every page box
     and can never push past the card, in any translation. It holds this one
     position through every room state so the card's height never moves. */
  #pvp-game > .pvp-discord-link {
    order: 99;
  }

  #pvp-game > .pvp-discord-link {
    display: flex;
    width: fit-content;
    max-width: 100%;
    min-width: 0;
    margin: var(--mf-gap-xs) auto 0;
    padding: 0 14px;
    gap: 7px;
    align-items: center;
    justify-content: center;
    font-size: 13px;
    border-radius: 999px;
    background: rgba(88, 101, 242, 0.18);
    border: 1px solid rgba(129, 140, 248, 0.55);
    color: #d5d9ff;
    box-shadow: none;
  }

  #pvp-game #room-code-input {
    width: 100%;
    min-width: 0;
    margin-bottom: 0;
    box-sizing: border-box;
  }

  #pvp-game .room-state-head {
    display: flex;
    flex: 0 0 44px;
    width: 100%;
    min-height: 44px;
    margin-top: auto;
    gap: var(--mf-gap-xs);
  }

  #pvp-game .room-code-label,
  #pvp-game .room-state-head > #go-back-btn {
    min-height: 44px;
    margin: 0;
    padding: 4px 6px;
    box-sizing: border-box;
    line-height: 1.1;
  }

  #pvp-game .room-code-label {
    flex: 1 1 auto;
  }

  /* THE CODE IS THE PAYLOAD. The pill shares a 44px row with Go Back, which
     leaves it ~105px at 667x275 and ~132px at 932x300 — not enough for the
     label AND the code, so the label wrapped to two lines and the code was
     ellipsised to ":3d944…". The words go to the accessibility tree and the
     code gets the pill; Copy Room Code sits directly below it either way. */
  /* The inner `[data-i18n]` span carries the same visually-hidden geometry as
     its wrapper. Not decoration: the localisation sweep in
     helpers/lobbyLayout.js asks each translated node whether its own box clips
     its text, and a normal span inside a 1px clipped parent answers "yes" —
     reporting a hidden label as a broken one. */
  #pvp-game .room-code-name,
  #pvp-game .room-code-name > [data-i18n] {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  #pvp-game .room-code-label > #room-code {
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.02em;
  }

  #pvp-game .room-state-head > #go-back-btn {
    flex: 0 1 42%;
  }

  #pvp-game #room-info {
    flex: 0 0 44px;
    width: 100%;
    min-height: 44px;
    margin-top: 0;
    padding: 0;
    border-radius: 0;
    background: transparent;
  }

  #pvp-game #room-info .room-invite-row {
    display: block;
    width: 100%;
    height: 44px;
    min-height: 44px;
  }

  #pvp-game #room-info .room-invite-actions {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    width: 100%;
    height: 44px;
    min-height: 44px;
    gap: var(--mf-gap-xs);
  }

  #pvp-game #room-info .room-invite-actions > button {
    min-width: 0;
    min-height: 44px;
    margin: 0;
    padding: 4px 2px;
    line-height: 1.1;
  }

  #pvp-game #waiting-host {
    flex: 0 0 44px;
    width: 100%;
    min-height: 44px;
    margin: 0;
    padding: 0 6px;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
  }

  #pvp-game #waiting-host > p {
    margin: 0;
    text-align: center;
  }

  /* Single Player and Foundry carry exactly two peer actions. A centered
     two-column well uses the short landscape width and removes the tall empty
     slabs created by full-width stacking. Labels may wrap; the 44px floor is
     still authoritative. */
  #single-player-panel .button-row,
  #deck-manager .button-row {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    flex: 1 1 auto;
    /* `center` floated these two wells in their leftover height while PvP's sat
       directly under its heading, so the outer cards' buttons read ~22px below
       their neighbour's on a 932x300 page box. Starting the well on the
       heading's line is what fixes that, and `start` is what does it.

       `stretch` did too — and it also handed the one-row cards' slack to the
       BUTTONS. Measured against this file's own pre-change copy at 844x280
       through `page.route`: Start Tutorial, Start Adventure, Moon Foundry and
       Collection each went 113x44 -> 113x90, while PvP's two-row well kept its
       44px because it had no slack to spend. A 90px-tall box holding one line
       of 13px text stops reading as a button, which is what the owner reported
       ("the menu buttons got super bulky, no longer looking like buttons, at
       least on mobile"). The leftover space under a one-row well is breathing
       room, not a defect. */
    align-content: start;
    gap: var(--mf-gap-sm);
  }

  #single-player-panel .button-row > button,
  #deck-manager .button-row > button {
    width: auto;
    padding-inline: 10px;
  }

  /* the panel subtitle pins to 11px on a phone — lift it over the 13px
     legibility floor (still clamps down on desktop, which never hits the gate) */
  .panel-subtitle {
    font-size: clamp(13px, 2.1vmin, 16px);
  }

  #feedback-link {
    position: static;
    align-self: center;
    margin: 0;
  }

  /* Legal footer links pin to 9-12px on a phone — too small to read or tap.
     Lift to a legible 13px with padding for a comfortable hit target. */
  .legal-footer-bar {
    font-size: 13px;
    padding: 7px 12px;
    gap: 2px 10px;
  }

  .legal-footer-bar a {
    /* a taller hit area for these secondary legal links without enlarging the
       glyph text — inline-flex keeps them on the footer line. */
    display: inline-flex;
    align-items: center;
    min-height: 36px;
    padding: 4px 6px;
  }

  /* Top account bar → ONE clean row. The desktop max-width:480 rule wraps
     .user-controls to 2-3 rows that overlap the first panel. Keep every pill
     (store + currency stay — they are the monetization CTA and gacha number),
     but reclaim width by rendering Moon Store as an icon so the row fits and
     the panels only need to clear a single 30px header. */
  .user-controls {
    top: calc(var(--mf-safe-top) + 0.5rem);
    left: calc(var(--mf-safe-left) + 0.75rem);
    flex-wrap: nowrap;
    /* Reserve exactly the lane the opposite corner occupies, plus one gap.

       THE LARGER OF TWO CONSTRAINTS, NOT THEIR SUM. The right end of this row
       has to clear two different things and they overlap: the notch band
       (`--mf-safe-right`, so no chip is painted under a rounded corner) and the
       corner control (`--mf-chrome-lane` + the full-screen slot). Adding them
       reserved both — and the corner control deliberately sits at a PHYSICAL
       0.5rem from the glass on a phone, i.e. INSIDE the notch band it was being
       reserved next to (see `--mf-rail-edge` further down this file, and the
       reasoning the owner accepted for it). So the row was paying twice for one
       piece of screen: 62px on top of the 130px the stale lane already cost.

       `max()` reserves whichever is actually further in, which stays correct if
       the corner control is ever re-seated to respect the inset. Measured
       clearance to that control after the change: 10px at 844x280 with 47/47
       insets, 13px at 59/59, 10px at 667x275 — and the row then holds every
       chip at its natural width on every landscape page box, meter included. */
    max-width: calc(
      100vw - var(--mf-safe-left) - 0.75rem
      - max(
        var(--mf-safe-right),
        var(--mf-chrome-lane) + var(--mf-rail-fullscreen-slot)
      )
      - var(--mf-chrome-gap)
    );
    gap: 5px;
  }

  /* EVERY chip in this strip, enumerated. `.user-moonlight` was missing and
     stayed at its own hard 30px while its siblings sat at 44px — the owner
     reported it as "on mobile it doesn't have the same height as the others",
     and a live measurement gave Moonlight 30px beside Stardust's 44px in both
     engines. `topBarTouchTargets.test.js` now fails if a new chip is added to
     the strip and left off this list. */
  .user-controls > .user-stardust,
  .user-controls > .user-username,
  .user-controls > .user-moonlight,
  .user-controls > .pvp-discord-link,
  .user-controls > .feedback-link {
    min-height: var(--mf-touch);
  }

  /* Rows are buttons too. The saved-deck list and the Moon Fuse inventory both
     render tappable rows at 35-36px, under the 44px floor — measured by the
     phone button audit, which is the whole reason it exists. */
  .deck-list-view,
  .sidebar-element {
    min-height: var(--mf-touch, 44px);
  }

  /* Referrals are a single top-bar action on phones. The full promotional
     rail belongs to desktop; placing that rail in the phone lobby consumed a
     complete content row and could leave its button beyond the page box. The
     same accessible opener moves into the established utility bar instead.

     FLEX, not block. As a block box this <aside> put its inline-flex child on a
     TEXT BASELINE: the pill rendered 4px below every 44px peer and hung out of
     its own wrapper, while every height and hit-target assertion still passed
     because the box was the right SIZE — just in the wrong PLACE. A flex
     context has no baseline to sit on. */
  .user-controls > .referral-banner {
    position: relative;
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    width: auto;
    height: var(--mf-touch);
    min-height: var(--mf-touch);
    padding: 0;
    margin: 0;
    overflow: visible;
    background: none;
    border: 0;
    border-radius: 999px;
    box-shadow: none;
  }

  .user-controls > .referral-banner::after,
  .user-controls > .referral-banner > .referral-banner-mark,
  .user-controls > .referral-banner > .referral-banner-content {
    display: none;
  }

  /* The one gold control in the bar. It is the reward CTA, so it keeps the
     invitation gradient while matching its neighbours' pill geometry exactly:
     same 44px box, same 999px radius, same optical centre. The label is sized
     to be READ — a 12px capped-at-88px squeeze was the reported defect. */
  .user-controls > .referral-banner > .referral-banner-open {
    display: inline-flex;
    grid-column: auto;
    width: auto;
    height: var(--mf-touch);
    min-height: var(--mf-touch);
    /* main.css gives every <button> a vmin margin. `.user-controls > *` zeroes
       it for direct children only, and this pill is a grandchild — that margin
       is what made the wrapper 8px wider than the control inside it. */
    margin: 0;
    padding: 0 14px;
    align-items: center;
    justify-content: center;
    gap: 7px;
    color: #26152f;
    font-size: 14px;
    font-weight: 800;
    letter-spacing: 0.01em;
    line-height: 1;
    border-radius: 999px;
  }

  .user-controls > .referral-banner .referral-open-full-label {
    display: none;
  }

  .user-controls > .referral-banner .referral-open-icon,
  .user-controls > .referral-banner .referral-open-compact-label {
    display: inline;
  }

  .user-controls > .referral-banner .referral-open-icon {
    flex: 0 0 auto;
    font-size: 15px;
  }

  /* Give the invitation action its room by letting only the account name
     yield. All icons, Stardust and tap targets retain their physical size.

     IT YIELDS TO A FLOOR, NOT TO NOTHING. With `min-width: 0` the name was the
     row's unbounded shock absorber, and once the Moonlight chip stopped being
     one (its automatic minimum is back, above), the name became the only one:
     clamped narrow, it collapsed to 29.2px — its own padding, with not a single
     character between — in all three engines. That is the "part of the text
     gets cut off, and it looks bad and unpolished" report wearing a new hat.

     5.5rem is the floor the two other bands of this file already use, and it is
     chosen for the same reason there: roughly nine characters plus the
     ellipsis, so the pill yields to "lunar-koi-6…" and never to "l". Past that
     floor the row runs over its own end, which is visible and diagnosable —
     unlike a name silently erased to make room. */
  .user-controls:has(> .referral-banner) > .user-username {
    flex: 1 1 auto;
    min-width: 5.5rem;
    max-width: min(9rem, 20vw);
  }

  /* ---- the top-right chrome cluster: Language · Feedback · Mute ----------
     Same sequence as desktop, ordered by importance with the least important
     control outermost. These three used to be three independently
     fixed-positioned elements whose left/right offsets were hand-computed
     against each other, which is how the phone ended up with Mute on the LEFT
     of Language while desktop had the opposite order. One flex row removes the
     arithmetic: only the row is positioned, and the order is the DOM order. */
  .lobby-language-cluster {
    position: fixed;
    top: calc(var(--mf-safe-top) + 0.5rem);
    right: calc(
      var(--mf-safe-right) + var(--mf-chrome-edge)
      + var(--mf-rail-fullscreen-slot)
      + var(--mf-touch) + var(--mf-chrome-gap)
    );
    z-index: 9999;
    display: flex;
    align-items: center;
    gap: var(--mf-chrome-gap);
    height: var(--mf-touch);
  }

  .lobby-language-cluster .feedback-link,
  .lobby-language-cluster .language-selector {
    position: relative;
    top: auto;
    right: auto;
    bottom: auto;
    z-index: auto;
    margin: 0;
    height: var(--mf-touch);
  }

  .lobby-language-cluster .feedback-link {
    width: var(--mf-touch);
    min-width: var(--mf-touch);
    padding: 0;
    justify-content: center;
  }

  .lobby-language-cluster .feedback-link .feedback-link-label,
  .user-controls > .feedback-link .feedback-link-label {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .user-controls > .pvp-discord-link,
  .user-controls > .feedback-link {
    width: var(--mf-touch);
    min-width: var(--mf-touch);
    height: var(--mf-touch);
    padding: 0;
    margin: 0;
    justify-content: center;
  }

  /* THE STRIP CLIPPED ITS OWN CHIPS. The owner: "the username and the stardust
     elements are too short ... part of the text gets cut off, and it looks bad
     and unpolished ... just make them wider."

     Measured at 667x275 in WebKit: the username was handed 27px of client width
     for 109px of text, and Stardust 49px for 53px — enough to slice the ☆ down
     its middle, which is a defect this strip has shipped before. `min-width: 0`
     plus `flex: 1 1 auto` let flex take everything from the two chips that had
     the least to give, while the Moonlight chip sat at a hard 177px and yielded
     nothing.

     The order is now explicit rather than accidental. Stardust never shrinks —
     its string is short and bounded, so there is nothing to gain by cutting it.
     Moonlight yields first, because it is the widest thing in the row by a
     factor of three. The username yields last and only down to a floor that
     still reads as a name, then ellipsises, which is the designed behaviour
     rather than a collapse to a single letter. */
  .user-controls > .user-stardust {
    flex: 0 0 auto;
  }

  /* SHRINK, BUT NEVER GROW, AND NEVER BELOW WHAT IS INSIDE IT.

     `flex-grow: 1` made the Moonlight chip absorb every spare pixel in the row
     and then take three more out of the username, which ellipsised at 844x280
     in Chrome and Firefox while the row had space for it. It yields when the
     row is over-full; it does not compete for the row when it is not.

     `min-width: 0` used to sit here too, and that was the reported defect. A
     flex item's automatic minimum size is the ONLY thing that stops it being
     shrunk below its own min-content, and this was the one chip in the strip
     that had it removed — so it absorbed the row's entire deficit alone,
     collapsed to 24-119px around ~140px of meter and number, and painted the
     pips out of its left end and "1067%" out of its right. Measured 4-28px of
     ink outside the pill at every landscape page box, in all three engines.

     Without it the chip is sized by its own contents and nothing else, which
     all three engines agree on to the tenth of a pixel: measured at 168.6px in
     Chromium and 177.2px in Firefox and WebKit with the strip clamped from
     660px down to 300px. What the pill promises is always what fits inside it.

     It can afford to be rigid because the row has the width now: the chrome
     lane above stopped reserving 186px for a corner that holds one 44px
     control, and every landscape page box holds the whole strip with 6-140px to
     spare. When something does over-fill the row — a wider locale, a longer
     Stardust number — the account name yields instead, down to the floor a few
     rules up, which is the ladder this strip was designed with. */
  .user-controls > .user-moonlight {
    flex: 0 1 auto;
  }

  .user-username {
    /* THE NAME TAKES THE WIDTH THE NAME NEEDS, up to a cap. As the only
       shrinkable chip in the row it absorbed every rounding difference between
       engines: Firefox lays "lunar-koi-603" out 3px wider than Chrome and
       WebKit do, so the same row that fitted in two engines ellipsised in the
       third. It no longer competes — the Moonlight dial, which loses a pixel of
       pip rather than a character, yields instead. `max-width` still stops a
       long name from taking the row. */
    max-width: 30vw;
    /* Roughly nine characters plus the ellipsis: a name, not an initial. */
    min-width: 5.5rem;
    flex: 0 0 auto;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* Sign Out now lives in the account menu (tap the username) on a phone, so
     drop the redundant top-bar button — that frees the width the single row
     needs and removes it from the cramped cluster. Still clickable via the
     menu (a display:none button still fires .click()). */
  #signout-btn {
    display: none !important;
  }

  /* Language selector → flag + chevron only (hide the native name) so a
     free-floating corner pill stays a compact, fixed width and can never
     collide with the mute toggle (the reported overlap).

     THIS IS NOT THE RULE FOR THE CHROME MENU. Inside the panel the selector is
     a full-width row, and `chrome-menu.css` turns the name back on there —
     with the flag hidden instead, because a colour emoji cannot take the
     panel's single ink. Left unscoped, the two rules hid one half of the label
     each and the row painted nothing but a chevron: "i ve seen it on mobile
     look empty, even tho actually english is already selected." */
  .language-btn-name {
    display: none;
  }

  .language-btn {
    padding: 0 0.6rem;
    /* match the 44px audio button beside it (was a 30px pill — undersized tap
       target and visually mismatched next to the round audio toggle). */
    min-height: var(--mf-touch);
  }

  /* Mute is the RIGHTMOST control, exactly as on desktop. It used to sit left
     of Language on phones only, which is the inversion that was reported. */
  /* SQUARE CORNER ON A PHONE TOO — measured from the SAFE BOX, not the page.

     The two insets were `0.5rem` on top and `--mf-chrome-edge` (0.75rem) on the
     right, i.e. 8px against 12px, which is the same unequal corner the owner
     reported on desktop. They cannot simply be the same NUMBER here, because
     `--mf-safe-top` and `--mf-safe-right` are different physical insets on a
     notched phone in landscape (0 and ~47pt) — squaring the raw values would
     shove the button 47px down. What has to match is the gap BEYOND the safe
     box, so both axes now add the same token to their own inset. */
  /* ONE TOP ROW, ONE LINE, ONE CORNER.

     Reported: "menu button is not aligned vertically with the other buttons. it
     also has a weird placement, not top right corner."

     Both were true and both were measurable. `.user-controls` (username,
     Quests, Invite, Discord) sits at `--mf-safe-top + 0.5rem`; this rule put the
     menu at `--mf-safe-top + --mf-chrome-edge` (0.75rem). 8px against 12px, so
     the menu rode 4px BELOW the row it shares — measured at exactly dCY = -4 on
     all six landscape page boxes. The 12px was introduced to make the menu's own
     two axes match each other, which is a real goal and was pursued against the
     wrong reference: the thing it has to line up with is the row, not itself.

     And `--mf-safe-right + 0.75rem` is not the corner. On the owner's iPhone 13
     the landscape insets are 47/47, so the menu sat 59px in from the glass —
     measured, and with asymmetric insets injected (notch right) it is 59px while
     the left cluster hugs 12px.

     0.5rem ON BOTH AXES, and the right one is PHYSICAL. That single number is:
     the row's own line, so the menu is level with every other top control; the
     menu's own two gaps, so its corner is square; and exactly where the ✕'s
     PAINTED disc used to sit — the ✕ was a 36px disc inside a 44px border box at
     4px, so its ink began at 8px. "make sure menu button would be in the exact
     same spot on mobile where x button used to be" is about the ink, not the
     hit box.

     The physical corner repeats the safe-area trade the owner accepted for the
     ✕ on this device, and it is now one seat for the lobby AND the match rather
     than two — the `body.game-active` override below is deleted, so the control
     no longer moves when a game starts. */
  body:not(.foundry-surface-active) {
    --mf-rail-top: calc(var(--mf-safe-top) + 0.5rem);
    --mf-rail-edge: 0.5rem;
  }

  body:not(.foundry-surface-active) #audio-volume-control {
    display: flex;
    align-items: center;
    height: var(--mf-touch);
  }

  /* The 0.85 phone override that used to live here is gone with the rule it
     compensated for: soundtrack.css no longer fades the control during a match,
     so there is nothing to restore and the menu is opaque on every surface. */

  body.foundry-surface-active {
    --mf-rail-top: calc(var(--mf-safe-top) + 4px);
    /* 0.9rem is what the base clamp resolves to at every phone page box
       (1.3vmin never reaches it), written out because a token cannot
       reference itself. */
    --mf-rail-edge: calc(var(--mf-safe-right) + 0.9rem);
  }

  /* Native WebKit keeps its intrinsic 26px select box unless height is
     explicit, even when min-height is 44px. The language picker is a primary
     Foundry command, so force its physical touch box at the phone gate. */
  .foundry-surface-root .foundry-language-select {
    min-height: var(--mf-touch);
    height: var(--mf-touch);
    box-sizing: border-box;
  }

  /* The language selector's OWN mobile rule is width-gated (@media max-width:480)
     so it never fires on a landscape phone (845px wide) and stays at the desktop
     right:4rem — landing ON TOP of the audio control. The cluster above owns its
     placement now; this only drops the desktop 30px cap so the 44px tap target
     applies, and keeps a sane corner anchor if the cluster host is ever absent
     (a non-lobby document that still renders the picker). */
  .language-selector {
    top: calc(var(--mf-safe-top) + 0.5rem);
    right: calc(var(--mf-safe-right) + var(--mf-chrome-edge));
    height: auto;
  }

  .audio-settings-toggle {
    min-width: var(--mf-touch);
    min-height: var(--mf-touch);
  }

  /* ---- cardbacks: fix the collapse-to-0 bug ------------------------------- */
  /* The desktop rule sizes a cardback by height — max-width: calc((96svh-380px)
     * 9/16) — which goes to ~0px on any short viewport, hiding the art. Size by
     width instead and let the column count adapt. */
  .cardback-grid {
    grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
  }

  .cardback-item {
    max-width: none;
  }

  /* ---- Moon Fuse: prioritize the tactile canvas while retaining a bounded,
     readable inventory rail. ----------------------------------------------- */
  #moon-fuse-panel {
    --moon-fuse-sidebar-width: clamp(210px, 28vw, 280px);
  }

  #play-area {
    flex: 1 1 auto;
    min-width: 0;
  }

  #sidebar {
    width: var(--moon-fuse-sidebar-width);
    flex: 0 0 var(--moon-fuse-sidebar-width);
    min-width: 0;
  }

  #element-list {
    --foundry-inventory-item-min-width: 180px;
    --foundry-inventory-item-height: 60px;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(var(--foundry-inventory-item-min-width), 100%), 1fr));
    grid-auto-rows: var(--foundry-inventory-item-height);
    align-content: start;
    gap: 8px;
    overflow-x: hidden;
    overflow-y: hidden;
  }

  /* The centered hint and the bottom-left trash zone share the canvas's bottom
     band, so on a phone's shrunken #play-area they overlap. Lift the empty-
     canvas hint to the vertical centre (translateX keeps it horizontally
     centred) and keep the trash tucked in the corner, clear of it. */
  #hint-text {
    bottom: auto;
    /* sit just under the ghosted fusion glyph (foundry.css ::after at 46%) so the
       drop-target + glyph + hint read as one centered anchor in the empty canvas. */
    top: 62%;
    width: min(66%, 210px);
    text-align: center;
  }

  .moon-fuse-cost {
    white-space: normal;
  }

  /* Keep the rare multi-editor banner inside the Moon Fuse canvas. */
  .foundry-workspace-notice {
    top: calc(var(--mf-safe-top) + 8px);
    right: calc(var(--moon-fuse-sidebar-width) + 8px);
    left: calc(var(--mf-safe-left) + 8px);
    max-width: none;
    font-size: 12px;
    padding: 8px 10px;
    transform: none;
  }

  .foundry-workspace-notice button {
    min-height: var(--mf-touch);
  }

  /* ---- store: let the stardust packs wrap instead of cramming 3-across ----- */
  .store-stardust {
    flex-wrap: wrap;
  }

  .store-stardust .plan-card {
    flex: 1 1 160px;
  }

  /* ---- mulligan: the 2.5rem title overflows the top and clips the confirm
     button on a phone (its phone rule is coarse-gated, so it misses the
     emulator). Shrink the header text and pad below the fixed top chrome. */
  .mulligan-header {
    padding-top: calc(env(safe-area-inset-top, 0px) + 2px);
  }

  .mulligan-header h2 {
    font-size: clamp(15px, 3.4vmin, 22px);
  }

  .mulligan-header p {
    font-size: clamp(11px, 2.2vmin, 14px);
  }

  /* ---- Collection / Deck-builder modal: a full-bleed mobile sheet ----------
     Desktop centers a max-1300px container and lays the deck-builder body out
     as a 2-column grid with a NOWRAP filter row (~372px min-content). On a
     phone that min-content exceeds the viewport, so the flex-centered
     container overflows symmetrically and CLIPS the title + tab strip on BOTH
     edges (the "Collection"→"ollection" bug). Fill the viewport (min-width:0 →
     the container can finally shrink), scroll the tab strip, wrap the filters,
     and stack the body so nothing is clipped, crushed, or overlapped. */
  .deck-builder-modal {
    padding: 0;
    align-items: stretch;
  }

  .deck-builder-container,
  .collection-modal .deck-builder-container {
    width: 100vw;
    max-width: 100vw;
    min-width: 0;
    height: 100dvh;
    max-height: 100dvh;
    border: none;
    border-radius: 0;
    padding-right: var(--mf-safe-right);
    padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 6px);
    padding-left: var(--mf-safe-left);
  }

  .collection-container {
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  .collection-container > .deck-builder-header {
    grid-template-columns: minmax(0, 1fr) auto;
  }

  .collection-container > .deck-builder-header .collection-tabs {
    grid-column: 1;
    min-width: 0;
  }

  .collection-header-actions {
    grid-column: 2;
    justify-self: end;
  }

  .collection-stardust {
    min-height: var(--mf-touch);
    min-width: 0;
    flex-direction: row;
    padding: 6px 10px;
  }

  .collection-stardust-label {
    display: none;
  }

  /* The five destinations share the title/currency/close command bar. Long
     translations can pan within that one row without creating another band. */
  .collection-tabs {
    flex-direction: row;
    flex-wrap: nowrap;
    overflow-y: hidden;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }

  .collection-tabs::-webkit-scrollbar {
    display: none;
  }

  .collection-tabs .deck-tab {
    flex: 0 0 auto;
    width: auto;
    white-space: nowrap;
  }

  /* The content frame is not a second scrollbar. Paginated surfaces stay
     bounded; true data lanes (market results, saved decks) scroll internally. */
  .collection-content {
    overflow: hidden;
    padding-top: 4px;
  }

  /* CHROME COMPACTION — the header + 5-tab strip + subtabs + search + filter
     row otherwise eat a third of a phone in tall bands before a single card
     shows. Squeeze every band so the CARDS (the content) dominate: drop the
     redundant subtitle, shrink the title + paddings, tighten the tab strips. */
  .collection-subtitle {
    display: none;
  }

  .collection-heading {
    display: none;
  }

  .collection-modal .deck-builder-header {
    padding: 8px 12px;
  }

  .collection-modal .deck-builder-header h2 {
    font-size: 1.25rem;
  }

  .collection-tabs {
    padding: 6px;
  }

  .collection-tabs .deck-tab {
    padding: 8px;
    font-size: 13px;
  }

  .collection-card-tabs {
    margin-bottom: 4px;
  }

  .collection-card-tabs .deck-tab {
    padding: 6px 10px;
  }

  .collection-controls-row,
  .deck-builder-collection .deck-builder-section-header {
    margin-bottom: 4px;
  }

  /* stack catalog above the current deck, both full width; block flow lets the
     one container scroll naturally instead of the desktop fixed-height split
     clipping its overflow */
  .deck-builder-content {
    display: block;
    overflow: visible;
    padding: 8px 10px 12px;
  }

  .deck-builder-collection,
  .deck-builder-deck {
    min-width: 0;
    width: 100%;
  }

  .deck-builder-deck {
    max-height: none;
    margin-top: 10px;
  }

  /* The search + filter row (shared by the deck builder AND the Collection
     "Cards" tab, which wraps it in .collection-controls-row) is a desktop
     NOWRAP row (~372px min-content) crammed to the right — on a phone it
     overflows or stacks awkwardly. Stack the tabs above a full-width search
     and a wrapping filter row, left-aligned, so it reads as one clean block. */
  .deck-builder-collection .deck-builder-section-header,
  .collection-controls-row {
    flex-direction: row;
    align-items: center;
    flex-wrap: nowrap;
    gap: 6px;
  }

  .deck-builder-collection .deck-builder-controls,
  .collection-controls,
  .collection-controls-row .deck-builder-controls {
    flex-wrap: nowrap;
    justify-content: flex-start;
    margin-left: 0;
    width: 100%;
    gap: 6px;
  }

  .deck-builder-collection .deck-builder-search,
  .collection-controls-row .deck-builder-search {
    width: auto;
    flex: 1 1 120px;
    min-width: 0;
  }

  .deck-builder-collection .deck-filter,
  .collection-controls-row .deck-filter {
    flex: 0 1 auto;
  }

  .collection-controls {
    position: relative;
  }

  .collection-controls-row .deck-builder-controls {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
  }

  .collection-mobile-filters-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 76px;
    min-height: var(--mf-touch);
    padding: 7px 11px;
    border: 1px solid rgba(238, 219, 241, 0.42);
    border-radius: 10px;
    background: rgba(35, 27, 49, 0.96);
    color: #f4e9f6;
    font: inherit;
    font-size: 13px;
    font-weight: 700;
    cursor: pointer;
    touch-action: manipulation;
  }

  .collection-mobile-filters-toggle[aria-expanded="true"] {
    border-color: rgba(246, 226, 122, 0.82);
    background: rgba(78, 61, 104, 0.98);
    box-shadow: 0 0 0 2px rgba(246, 226, 122, 0.16);
  }

  .collection-mobile-filters-toggle:focus-visible {
    outline: 3px solid #f6e27a;
    outline-offset: 2px;
  }

  .collection-filter-popover {
    position: absolute;
    z-index: 80;
    top: calc(100% + 6px);
    right: 0;
    display: none;
    width: min(420px, calc(100vw - var(--mf-safe-left) - var(--mf-safe-right) - 24px));
    max-width: calc(100vw - var(--mf-safe-left) - var(--mf-safe-right) - 24px);
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 6px;
    padding: 8px;
    border: 1px solid rgba(238, 219, 241, 0.38);
    border-radius: 12px;
    background: rgba(17, 12, 29, 0.98);
    box-shadow: 0 16px 34px rgba(0, 0, 0, 0.58);
  }

  .collection-filter-popover.is-open {
    display: grid;
  }

  .collection-filter-popover .deck-filter {
    width: 100%;
    min-width: 0;
    min-height: var(--mf-touch);
    font-size: 16px;
  }

  .collection-filter-popover #collection-filter-spell-owner {
    grid-column: 1 / -1;
  }

  /* catalog grid (shared by the Collection "Cards" tab AND the deck builder):
     the desktop --catalog-card-scale of 1.05 renders ~52px cards against the
     phone --card-unit floor — tiny and unreadable, and marooned inside over-
     wide auto-fill tracks. Enlarge the cards to a comfortable, tappable size
     and size the columns to THAT width (no 1fr stretch) so cards sit snug and
     the row centres, at natural height so a page never rides over pagination. */
  .deck-builder-cards {
    --catalog-card-scale: var(--deckbuilder-fit-card-scale, 1.7);
    grid-template-columns: repeat(auto-fit, calc(var(--card-width) * var(--catalog-card-scale)));
    justify-content: center;
    gap: 12px;
    overflow: hidden;
    flex: 1;
    min-height: 0;
  }
}

/* A short landscape phone has enough horizontal room for the feedback action
   to explain itself. Keep portrait and narrower landscape headers compact, but
   restore the localized label at the reviewed 667-845px widths without
   weakening the one-row account bar or its touch target. */
@media (orientation: landscape) and (max-height: 560px) and (min-width: 667px) {
  .user-controls > .feedback-link {
    flex: 0 0 auto;
    width: auto;
    gap: 6px;
    padding: 0 12px;
    white-space: nowrap;
  }

  .user-controls > .feedback-link .feedback-link-label {
    position: static;
    width: auto;
    height: auto;
    margin: 0;
    overflow: visible;
    clip: auto;
    clip-path: none;
  }
}

/* ---- Moon Dream: on a landscape phone keep the two-column layout so the runs
   / selections sit BESIDE the prompt (visible) instead of stacking below it and
   forcing a scroll just to confirm a generation worked. foundry.css collapses
   it to one column at <=900px; a landscape phone is wide enough for two. This
   sits outside the main gate so it only affects short landscape viewports. */
@media (orientation: landscape) and (max-height: 560px) and (max-width: 932px) {
  /* The volume trigger paints at 28px on a phone, matching the exit X, while
     keeping a 44px touch target.

     It sits directly below End Turn (owner-specified), so the PAIR is what has
     to fit the seam between the two summoner clusters. At 44px painted the pair
     is 92px, which forces the crest small enough that the bottom-anchored
     summoner status plate grows off the top of the screen on the max ledgers.
     28px painted takes the pair to 76px and gives that height back to the crest.
     Touch is unaffected: the hit target below restores the full 44px. */
  /* THE VOLUME TRIGGER JOINS THE X IN THE CORNER.

     Owner-directed: "move the vol button below the x button, make them same
     size, give them decent padding". This is also what unlocks the layout —
     with the trigger off End Turn, the seam between the summoner clusters only
     has to hold End Turn itself (44px) instead of a 92px stacked pair, which is
     what was forcing the crests small enough to push the status ledger off the
     top of the screen.

     Same 36px as the X, same 8px edge inset, 8px between them. CSS owns the
     seat; audio-settings.js stands down on these page boxes. */
  /* END TURN IS CENTRED ON THE SUMMONER COLUMN, not merely right-aligned to it.

     Reported: "that end turn button is not centered with the summoner icons".
     Both are anchored to the same right edge, but they are different widths —
     the button uses --phone-command-banner-width so its label fits, the crest
     uses its own narrower token — so sharing a right edge puts their centre
     lines (banner-crest)/2 apart, and the column reads crooked.

     Offsetting the button by half that difference lines the two centres up. The
     term is negative when the button is wider than the crest, which nudges it
     outward toward the screen edge rather than inward over the board; `max()`
     keeps it from crossing the safe inset.

     IT BELONGS IN THIS WIDTH-BOUNDED BLOCK, not the broad phone one. Every
     token it reads is declared only under `max-width: 932px`. Written one block
     up — landscape with `max-height: 560px` and NO width bound — the `calc()`
     hit three undefined custom properties on a 1024x560 landscape tablet, and
     an invalid var() does not fall back to the previous declaration: the whole
     property became `right: auto`, so a `position: fixed` End Turn dropped to
     its static position and floated at x=456 in the middle of the board.
     Measured. Anything reading a --phone-* token has to be scoped where that
     token exists. */
  body.game-active #end-turn-btn {
    right: max(
      0px,
      calc(
        var(--phone-summoner-column-right)
        + (var(--phone-summoner-portrait-width) - var(--phone-command-banner-width)) / 2
      )
    );
  }

  /* THE PHONE IS THE ONE TIER WHERE THE MATCH SEAT MUST DIFFER, and it is a
     measurement, not a preference.

     Desktop, tablet and 4K hold the identical corner in the lobby and in a
     match — that is the instruction and it is met there. A landscape phone
     cannot: measured at 844x280 with the real 59pt insets injected, the lobby
     corner puts the menu at [741,12,44,44] and the enemy summoner's banner
     occupies [697,0,100,86], so the button lands ENTIRELY inside it. There is
     no 44px spot that clears the banner and stays inside the safe box either —
     End Turn starts at y=118, the banner ends at x=797, and the safe box ends
     at x=785. A phone battle has no free corner; `fullscreen-toggle.js` said so
     in prose for years before this.

     So the menu takes the slot the exit ✕ used to hold — the phone's
     established chrome lane, which `--phone-command-corner-lane` in
     game-board.css already reserves from the summoner column. It is the SAME
     44px as the lobby (the old rule also shrank it to 36px; that half stays
     deleted), and it is the only control there now, because full screen and
     Return to Lobby are both rows inside it.

     `soundtrack.css` still owns the layer (10002, above the mulligan veil) —
     that was never this rule's business and is not restated here. */
  /* THE MATCH SEAT IS DELETED, not moved. It existed because the lobby seat was
     safe-inset-based and landed on the enemy summoner's banner in a match, so a
     phone needed its own corner during play. The lobby seat is the physical
     corner now — the ✕'s own ink position — which is exactly where a match
     wants it too, so one rule serves both and the control cannot jump when a
     game starts. */

  /* The `--mf-battle-exit-standalone` flag that used to be published here is
     gone with the ✕ it described: `chrome-menu.js` folds the exit into the menu
     on every viewport now, so there is nothing for a gate to exempt.

     The mulligan lift moved to tutorial.css, unscoped: the overlay outranks
     Skip on desktop too, so a phone-gated copy left the same hole everywhere
     else. Kept as a note rather than a duplicate rule — one source. */

  /* The companion 36px toggle sizing went with that re-seat — the container and
     the button inside it are one size again, straight from `--mf-rail-btn`. */

  /* A phone hand must still read as a physical fan at rest. The old negative
     margin and matching clip removed roughly forty percent of every card,
     making the fan look as if it materialized halfway through the screen edge.
     game-board.css now budgets a complete card below the lower board row; this
     safe-area lift keeps its bottom edge above the system gesture strip without
     sacrificing any painted or interactive part of the card. */
  #player-hand {
    /* Rotation pivots at each card's bottom centre, so the outer bottom corner
       sits a few pixels below the unrotated edge. Reserve 3.5 card units for
       the widest supported fan angle; otherwise a nominally uncropped hand
       still loses those corners at the viewport boundary. */
    --mf-hand-gesture-lift: calc(var(--mf-safe-bottom, 0px) + 1.28 * var(--card-unit));
    transform: translateY(calc(-1 * var(--mf-hand-gesture-lift)));
    clip-path: none;
  }
}

@media (orientation: landscape) and (max-height: 560px) {

  /* Fit the whole screen: the panel stops scrolling as a unit and the runs list
     owns its own scroll, so a full page of 8 generated summoners scrolls INSIDE
     the list while the pager + prompt stay pinned on-screen. The height chain
     (#foundry-app 100% -> #foundry-panels flex:1 -> .foundry-panel flex:1)
     bounds the panel, so minmax(0,1fr) gives the columns a real height to flex
     against. */
  #moon-dream-panel {
    padding: 8px 12px;
    overflow: hidden;
  }

  .moon-dream-content {
    grid-template-columns: minmax(190px, 260px) minmax(0, 1fr);
    grid-template-rows: minmax(0, 1fr);
    gap: 12px;
    /* fill the space BELOW the panel header (flex item of the column panel), not
       100% of the panel — else header + content overflows and clips the pager.
       Tight vertical padding so the 44px pager still clears a 375px-tall phone. */
    flex: 1 1 auto;
    min-height: 0;
    padding: 6px 14px;
  }

  .moon-dream-left,
  .moon-dream-right {
    min-height: 0;
    gap: 8px;
  }

  .moon-dream-right {
    overflow-y: auto;
  }

  #moon-dream-runs {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
  }

  .moon-dream-pagination {
    flex: 0 0 auto;
    margin-top: 4px;
  }

  .moon-dream-header {
    padding: 6px 16px;
  }

  /* Keep the compact input rule visible beside the stage price. It costs no
     extra row and prevents mobile players from guessing the prompt contract. */
  .moon-dream-hint {
    position: static;
    flex: 1 1 auto;
    width: auto;
    height: auto;
    min-width: 0;
    margin: 0;
    overflow: hidden;
    clip: auto;
    clip-path: none;
    white-space: nowrap;
    text-overflow: ellipsis;
    font-size: 10px;
    line-height: 12px;
  }

  /* Collection modal on a SHORT landscape phone (~400px tall): the header +
     5-tab strip + subtabs + search + filter row eat ~260px, leaving one sparse
     card row. Reclaim vertical space — drop the subtitle, shrink the title,
     tighten the body padding — so the catalog actually breathes. Portrait
     (tall) keeps the roomier chrome. */
  .collection-subtitle {
    display: none;
  }

  .collection-modal .deck-builder-header {
    padding-top: 10px;
    padding-bottom: 8px;
  }

  .collection-modal .deck-builder-header h2 {
    font-size: 1.2rem;
  }

  .collection-tabs {
    padding-inline: 2px;
  }

  .collection-tabs .deck-tab {
    padding-inline: 4px;
    font-size: 12px;
  }

  .deck-builder-content {
    padding-top: 4px;
    padding-bottom: 8px;
  }

  .deck-builder-collection .deck-builder-section-header,
  .collection-controls-row {
    gap: 5px;
  }
}

/* ---- Lobby PORTRAIT: a true top-aligned single column --------------------
   Keep the same command-center hierarchy as landscape, but collapse Play,
   PvP, and Foundry to full-width rows. Quests follow naturally below them. */
@media (orientation: portrait) and (max-width: 768px) {
  .lobby-scroll {
    max-width: min(94vw, 520px);
    justify-content: flex-start;
  }

  #lobby-view {
    overflow-y: auto;
  }

  .lobby-panels-container {
    grid-template-columns: 1fr;
    margin-block: 0;
  }

  .lobby-panels-container > .panel {
    flex: 0 0 auto;
    width: 100%;
    max-width: none;
  }

  /* Foundry's desktop summary/action split is too aggressive in a phone-width
     column: the action rail can leave a long localized heading only a few
     characters wide. Stack the summary above its two peer actions instead. */
  #deck-manager {
    display: flex;
    flex-direction: column;
    align-items: stretch;
  }

  #deck-manager .button-row {
    width: 100%;
    margin-top: var(--mf-gap-sm);
  }
}

/* ---- Collection "Cards"/Deck-Builder chrome on the SHORTEST landscape phones
   (<=480px tall): fold the Characters/Spells sub-tabs onto the search line so
   the control chrome is 2 rows instead of 3, lifting a full card row above the
   fold. Every control keeps its 44px touch floor. Later source order than the
   <=560 column stack, so it wins at these heights. ------------------------- */
@media (orientation: landscape) and (max-height: 480px) {
  .collection-controls-row {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: center;
    width: 100%;
    min-width: 0;
    gap: 6px;
  }

  .collection-controls-row .collection-card-tabs {
    flex: 0 0 auto;
    margin-bottom: 0;
  }

  .collection-controls-row .collection-card-tabs .deck-tab {
    padding-inline: 6px;
    font-size: 13px;
  }

  .collection-controls-row .collection-controls,
  .collection-controls-row .deck-builder-controls {
    min-width: 0;
    width: 100%;
  }

  .collection-controls-row .deck-builder-controls {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    gap: 4px;
  }

  .collection-controls-row :is(
    .deck-builder-search,
    #collection-filter-collection,
    #collection-filter-mana,
    #collection-sort-cards
  ) {
    width: 100%;
    min-width: 0;
  }

  .collection-controls-row .deck-builder-search {
    font-size: 16px;
  }

  .collection-controls-row .deck-filter { font-size: 16px; }
}

/* The smallest supported landscape cannot afford a second action row: it
   shrinks all three peer panels and clips their lower controls. Keep the paired
   destinations side-by-side, let their labels wrap inside 44px targets, and
   drop only the redundant subtitles. */
@media (orientation: landscape) and (max-height: 560px) and (max-width: 700px) {
  /* A 420px two-column drawer leaves only ~196px per native select. That is
     enough for English but clips the longest Japanese collection label in
     both Chromium and Firefox. Four full-width 44px rows still fit above the
     bottom edge at 667x375, so readability wins over horizontal density. */
  .collection-filter-popover {
    grid-template-columns: minmax(0, 1fr);
  }

  .lobby-panels-container .panel-subtitle {
    display: none;
  }

  /* Firefox rounds the title + two 44px controls three pixels taller at the
     narrowest width. Reclaim non-interactive padding without touching text or
     target sizes, so the PvP card stays internally unclipped — from the BOTTOM
     only, because an unequal padding-top takes this card's heading off the line
     its two neighbours share. */
  .lobby-panels-container > #pvp-game {
    padding-bottom: 2px;
  }

  #single-player-panel .button-row,
  #deck-manager .button-row {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-template-rows: minmax(var(--mf-touch), auto);
    gap: var(--mf-gap-xs);
  }

  #single-player-panel .button-row > button,
  #deck-manager .button-row > button {
    min-width: 0;
    overflow-wrap: normal;
    word-break: normal;
  }
}

/* ---- Visual-review polish (mobile-scoped so desktop is byte-identical) ----- */
@media (orientation: portrait) and (max-width: 768px),
       (orientation: landscape) and (max-height: 560px) {
  /* Cards-tab search: same opaque fill as the filter selects (was a lighter
     lavender that let the panel backdrop bleed through unequally). */
  .collection-controls-row .deck-builder-search {
    background: rgba(0, 0, 0, 0.4);
  }

  /* Cardback names: reserve a uniform two-line height + centre so a short label
     that wraps ("Founder's Legacy") doesn't leave the row ragged vs a one-line
     label ("Stellar Crucible"). */
  .cardback-name {
    min-height: 2.4em;
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    line-height: 1.2;
  }

  /* Moon Dream: fill + vertically centre the "select a summoner" placeholder in
     the already-full-height right column (was pinned to the top with a void). */
  .moon-dream-right > .moon-dream-empty {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
  }
}

/* ---- Shortest phones (<=390px tall): trim the Collection top chrome so the
   Cardbacks card row + Equip buttons clear the fold with balanced margins. The
   400px+ heights already breathe, so gate below them. -------------------------*/
@media (orientation: landscape) and (max-height: 390px) {
  .collection-modal .deck-builder-header {
    padding-top: 4px;
    padding-bottom: 4px;
  }
  .collection-modal .deck-builder-header h2 {
    font-size: 1.05rem;
  }
  .collection-cardbacks-header h3 {
    font-size: 0.95rem;
  }
  .collection-cardbacks-header {
    gap: 8px;
  }
}

/* ---- Card Packs empty vault (mobile): with 0 packs the disabled "DRAG" disc
   reads as a dead control. Hide it and let the "No packs in the vault" hint be a
   clean empty state (the BUY PACK CTA sits in the header above). Desktop keeps
   its current disc — the JS-added class is a no-op there. ---------------------*/
@media (orientation: portrait) and (max-width: 768px),
       (orientation: landscape) and (max-height: 560px) {
  .card-packs-stage-empty .card-packs-pack {
    display: none;
  }
  .card-packs-stage-empty .card-packs-pack-hint {
    font-size: 0.95rem;
    opacity: 0.92;
  }
}

/* ---- Deck-builder summoner cards: a loading shimmer behind the art so a card
   whose Convex art hasn't arrived yet reads as "loading" rather than a blank
   frame. The <img> (object-fit:cover, 100%x100%) covers it once loaded, so the
   loaded state is unchanged. Mobile-scoped; keyframes are inert on desktop
   (the animation is only assigned inside the phone gate). --------------------*/
@keyframes db-char-art-shimmer {
  0% { background-position: 220% 0; }
  100% { background-position: -20% 0; }
}

@media (orientation: portrait) and (max-width: 768px),
       (orientation: landscape) and (max-height: 560px) {
  .db-char-image-wrapper.db-char-image-loading {
    background-image: linear-gradient(110deg,
      rgba(28, 28, 48, 0.95) 25%,
      rgba(52, 48, 86, 0.95) 50%,
      rgba(28, 28, 48, 0.95) 75%);
    background-size: 220% 100%;
    animation: db-char-art-shimmer 1.5s ease-in-out infinite;
  }
}

@media (prefers-reduced-motion: reduce) {
  .db-char-image-wrapper.db-char-image-loading { animation: none; }
}

/* ============================================================================
   The exit control on short landscape phones: a compact X, fixed top-right.

   Owner-directed — "lobby button can be just an X button on top right" — and it
   is also what stops the wandering. The wide "Go to Lobby" had no home that
   survived a rotation or a growing narration lane, so a seat search re-homed it
   every frame: onto the storyboard column ("it must not under any circustance
   sit undreneath the storyboard squares"), onto the summoner crest, or into the
   far-left table art where it simply "looks random".

   A 44px icon at a DECLARED anchor cannot wander, and the storyboard keeps the
   entire left edge to itself, free to run full height.

   THIS LIVES IN mobile.css ON PURPOSE. The rule ~25 lines above sets
   `right: var(--mf-safe-right)` on this same element at equal specificity, and
   mobile.css loads last, so it won on order — the exit rendered flush right,
   directly on the opponent's crest ([800,6,844,50] against a portrait at
   [788,0,844,54]). Placement belongs beside the lane rule it must agree with.

   IT SITS INBOARD OF THE CREST, NOT IN THE LITERAL CORNER. The corner was
   tried — the owner asked for it directly — by stepping the whole command
   column inboard by one touch target. That works in an inset-free capture and
   fails on real hardware: `viewport-fit=cover` gives a notched iPhone 59pt of
   safe inset per side, so an 844px page box has 726px of usable width, and the
   extra 54px pushes the deck stacks onto the board slots at every notched
   profile. The literal corner is not available on this device class; inboard of
   the crest is the only 44px hole on the top edge that exists everywhere.

   NOTHING IN JS SKIPS THE SEAT SEARCH ANY MORE. An earlier version of this
   block positioned the control and audio-settings.js stood down via a duplicated
   media query (EXIT_IS_CSS_OWNED), guarded by a parity test. All three are gone:
   the solver owns the seat again and this block owns only the finish.
   ========================================================================= */
@media (orientation: landscape) and (max-height: 560px) and (max-width: 932px) {

  /* THE EXIT IS A MENU ROW HERE TOO — there is no phone-only control.

     This block used to lift `#skip-tutorial-btn` out of the chrome menu and pin
     it to the screen corner as a drawn X: `position: fixed`, its own z-index,
     a transparent 44px border for touch, and two rotated bars for the glyph,
     because a font-centred U+2715 would not sit straight. About 130 lines of
     phone-only apparatus for a control the desktop already had a home for.

     Reported: "on mobile menu button and x button coexist ... i sayd we should
     remove x button. lobby function should be inside menu button" — and then,
     decisively: "please dont create some hacky special scenario. just whatever
     is on desktop should also be on mobile."

     So it is all gone. `chrome-menu.js` adopts the exit into the menu's Lobby
     section on every surface, `chrome-menu.css` styles it as a row like every
     other one, and a phone now gets exactly that. Deleting this also deletes
     the two defects it created: a menu that advertised a "LOBBY" heading over
     an empty body (the row had been pinned out of it), and a second corner
     control competing with the one the fold exists to be.

     Nothing replaces it here. The one state where the element is not a menu
     row — the GUEST TUTORIAL's floating Skip — keeps the desktop's control and
     gets a phone SEAT (top-left, beside the story rail) in tutorial.css, because
     the desktop seat, "one row below End Turn", is the player's summoner crest
     on every phone page box. That is a placement, not a phone-only control. */

  /* The menu's phone seat is NOT re-declared here. A match already has its own
     seat further up this file — the phone is the one tier where it must, because
     in battle the lobby's corner is occupied by the enemy summoner's banner —
     and that seat now points at the ✕'s old box. The lobby keeps the
     safe-inset seat it has always had. Two competing seats in two media queries
     is how the corner ended up holding two controls in the first place. */


  /* THE COUNTDOWN MOVES OUT OF THE CORNER THE X NOW OWNS.

     `.mulligan-timer` is absolutely positioned inside `#mulligan-overlay`,
     which is `position: fixed` at all four viewport edges — so its "top right
     of the overlay" has always been the top right of the SCREEN. That was free
     until the exit became a corner X; now the overlay's z-index 9700 prints the
     digits straight over it. Owner: "make the mulligan timer happen in down
     right corner of screen, top right is now taken by X buttn".

     THE LITERAL CORNER IS NOT FREE, so this is the bottom-right of the space
     that exists. Measured at 844x280: the summoner crest column owns x 697-797
     and the deck lane x 639-677, both running to the bottom edge. A first pass
     did put the countdown flush at `--mf-safe-right + 8px` — every oracle
     green — and the screenshot showed it sitting squarely on the player's crest
     over the health and mana discs. Covering portrait art is the accepted cost
     of the exit X; covering live state is not, and the same rule binds here.

     So the right edge is the deck lane's left edge, derived from the same three
     tokens the deck itself is positioned by (indicators.css: column-right +
     status rail + 8px, then the deck's own 44px max width) plus a gap. That
     lands 8-14px clear of the deck on all six landscape page boxes and stays
     clear of Keep Hand, which ends at x 522 there.

     `--mf-safe-bottom` still governs the vertical: unlike the X — deliberately
     outside the safe box, a documented one-control trade — the countdown is
     text a player reads against a clock, and `viewport-fit=cover` puts the home
     indicator exactly where unclamped digits would land.

     AND IT HAD TO BECOME LEGIBLE. `--mulligan-unit` is 1vmin, so `3 * unit`
     rendered the countdown at 8.25-10.3px — a 15x9px smear at 70% white over
     painted table. It satisfied a bounding-box assertion and was invisible to a
     person. The rest of the phone HUD floors at 13px; this takes 15px on a dark
     chip so it reads against the table at a glance.
     Contract: tests/unit/styles/mulliganTimerPhoneCorner.test.js and the
     rendered geometry in mulligan-phone-corner.spec.js. */
  .mulligan-timer {
    /* THE PHONE TURN-TIMER SEAT, so the two countdowns share a place here too.

       This was the bottom-right, chosen when the exit X took the top-right
       corner: "make the mulligan timer happen in down right corner of screen,
       top right is now taken by X buttn". The owner has since asked for the two
       countdowns to be "more unifromized in style/look/location", and the phone
       turn timer lives on the End Turn ROW — mid-right, inboard of the command
       lane. That satisfies both instructions at once: it is still nowhere near
       the top-right corner the X owns, and it is now the same seat the turn
       timer uses a moment later.

       Values copied from `#turn-timer`'s own phone tier in game-board.css
       rather than re-derived, so the pair cannot drift. That tier's comment
       carries the clearance measurements for this strip: 136px at 932x300,
       74px at 852x344, 11px at 667x275. */
    top: calc(50% - (var(--mf-touch, 44px) + 1.5 * var(--mf-dvmin)) / 2);
    bottom: auto;
    right: calc(
      var(--mf-safe-right, 0px)
      + var(--phone-command-banner-width)
      + var(--mf-touch, 44px)
      + 10px
    );
    left: auto;
    /* THE DESKTOP BASE IS TOP-ANCHORED AND THIS ONE IS NOT.

       The base rides the End Turn rail, so it carries `translateY(-100%)` to
       hang its bottom edge off `top`. This tier anchors by `bottom` instead,
       where that transform would lift the whole pill by its own height, out of
       the lane measured for it below. `margin-right` mirrors a button margin
       that phone End Turn does not have. Both are reset, deliberately. */
    transform: translateY(-50%);
    margin-right: 0;
    /* A phone battle page box is ~300px tall and the lane below is 96px wide:
       the column layout the base uses for its bar does not fit here, so the
       phone keeps a single-line chip. The bar is hidden rather than shrunk —
       3px of gradient in a 15px chip reads as an artefact, and the pill's own
       colour already carries the urgency tier. */
    display: block;
    min-width: 2em;
    padding: 5px 7px;
    font-size: 15px;
    line-height: 1;
    letter-spacing: 0.02em;
    text-align: center;
    /* Rounder than the desktop pill on purpose — every other phone HUD chip is
       a capsule — but the same ink, ground and border FAMILY, so the two
       countdowns still read as one object across devices. */
    border-radius: 999px;
    border-width: 1px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
    text-shadow: none;
  }

  .mulligan-timer .mulligan-timer-bar {
    display: none;
  }

  /* NO COUNTDOWN LANE IS RESERVED HERE ANY MORE, and re-adding one would put
     the primary action back off centre.

     This block used to carry `.mulligan-buttons { padding-right: 96px }`. It
     was correct when it was written: the countdown then sat in the BOTTOM-RIGHT
     corner, on the action row's own horizontal band, and at 667x275 — the
     tightest supported page box — Keep Hand left it a 31px hole that no legible
     countdown fits in. One-sided padding bought the lane without costing the
     button width, touch target or type size.

     The countdown has since moved onto the phone turn timer's seat, mid-right
     and vertically centred (see `.mulligan-timer` above), so the band it was
     being kept out of is not the band it occupies. The reservation protected
     nothing and the 48px it shifted the row by was all that was left of it.

     Reported: "select both mulligan button in tutorial is not centered on
     mobile". Measured on the tutorial mulligan before the deletion: -48.01px
     from the page-box centre in Chromium, WebKit and Firefox, at all six
     landscape page boxes, under a centred header, a centred hand and a centred
     status line. Portrait was already centred — the rule lives only in this
     landscape gate.

     WHAT REPLACES IT IS A BOUND, NOT AN OFFSET — because centring a control
     does not shrink it. Freed of the 48px, three translations were wide enough
     to reach the command lane: at 667x275, the tightest supported page box, the
     French, Spanish and Portuguese labels rendered 297-330px and a centred
     330px button ended at x 498 with the deck stack starting at 465 (Chromium
     and WebKit both). English at 223 and German at 245 never come close, and no
     other page box is narrow enough for any of them to matter.

     So the row is capped to the free band between the command lanes, mirrored —
     `column-right + rail + 8px` is the deck wrapper's own anchor
     (indicators.css) and `--phone-deck-width` is the stack it hangs there, both
     read rather than re-derived. Mirroring is what keeps the cap a cap: the
     same amount comes off each side, so the row is still centred. A label that
     would exceed it wraps instead of printing over the deck — and because a
     two-line button does not fit 275px either, the five labels that would have
     wrapped were shortened instead, exactly as `Redraw` was. The cap is the
     guard for the next translation, not the mechanism for these.

     Contracts: tests/unit/styles/mulliganActionRowCentre.test.js for the
     declarations, tests/e2e-playwright/mulligan-action-row.spec.js for the
     rendered geometry and for every label in every language. */
  /* THE BAND, once, on the overlay both consumers live inside. The action row
     is capped to it and so is the description, and they must agree: two copies
     of this expression is how a lane silently stops matching. */
  #mulligan-overlay {
    --mulligan-free-band: calc(
      100vw - 2 * (
        var(--phone-summoner-column-right)
        + var(--phone-status-rail-width)
        + 8px
        + var(--phone-deck-width)
      )
    );
  }

  .mulligan-buttons {
    /* THE FLOOR IS NOT DECORATION. This gate has no minimum width — a desktop
       window dragged to 600x400 matches it — and below about 612px the band
       drops under the button's own `min-width` floor. A flex child that cannot
       shrink to its container's cap overflows it to the RIGHT, which would have
       put the button back off centre at exactly the sizes this whole change
       exists to fix. `max()` makes the cap bind only where it can actually be
       honoured; the floor restates the button's own min-width plus its 4px
       margins. */
    max-width: max(calc(min(200px, 46vw) + 8px), var(--mulligan-free-band));
  }

  /* 62.5vmin IS A DESKTOP MEASURE WEARING THE WRONG AXIS HERE.
     `.mulligan-header p` is capped at `62.5 * --mulligan-unit`, and that unit
     is 1vmin — which on a landscape phone is the HEIGHT. So the paragraph gets
     62.5% of the height as its WIDTH: 172px on a 667x275 page box, 187px on a
     932x300 one. A 187px column of prose on a 932px-wide screen is the same
     axis-flip the tablet tier documents, one device class down.

     It is not cosmetic. At 667x275 the German copy needed FIVE lines in that
     column (four in Chromium, three in English), and the overlay column then
     overran its own page box at both ends — the title's box started at y -5.9
     with its glyph tops cut and the confirm button's bottom edge landed at
     y 276.9 in a 275px page. A/B-measured in the same page against the CSS
     before this change: identical, so it predates the action-row work.

     The band is the width the overlay actually owns, and 42ch keeps a readable
     measure on the wider boxes rather than stretching one line across the
     screen. `24ch` is the same degenerate-size floor the action row carries. */
  .mulligan-header p {
    max-width: max(24ch, min(var(--mulligan-free-band), 42ch));
  }
}


/* ============================================================================
   LANDSCAPE-TABLET TOP-BAR CHROME — Quests, Invite and Feedback as pills.

   Reported: "quests invite feedback buttons should be minimized top left like
   on mobile". On a tablet those three were the desktop treatment — Quests a
   full-width ribbon under the play cards, Invite a full-width banner pinned to
   the bottom, Feedback a button floating between them. Three bands of chrome
   for three controls, on a screen whose play cards are the point.

   The phone already solved this: all three collapse into 44px pills in the
   account bar, and Quests expands a fixed dropdown under it. This gives a
   landscape tablet the same treatment at the same 44px geometry.

   DUPLICATED FROM THE PHONE BLOCK ON PURPOSE. The rules above live inside a
   900-line phone gate whose other contents — battle layout, card units,
   touch-action contracts, story feed — must not reach a tablet. Widening that
   gate would drag all of it along, and lifting these rules out of it would
   reorder them against the phone rules interleaved between them. The phone
   block is therefore left untouched and byte-identical, which
   `scripts/lobbyShotProbe.js` verifies.
   ========================================================================== */
@media (orientation: landscape) and (min-width: 960px) and (min-height: 561px) and (max-width: 1560px) and (max-aspect-ratio: 17/10) {
  :root {
    /* Declared in the phone gate's own `:root`, so a tablet has to restate the
       two the pills are measured in. Same values: a thumb is a thumb. */
    --mf-touch: 44px;
    --mf-radius: 14px;
    --mf-topbar-clear: calc(var(--mf-safe-top) + 0.5rem + var(--mf-touch) + 4px);
  }

  /* ---- Quests: a pill in the bar, not a ribbon in the page ---------------- */
  .lobby-quests-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    gap: 6px;
    height: var(--mf-touch);
    min-height: var(--mf-touch);
    margin: 0;
    padding: 0 14px;
    color: rgba(247, 240, 223, 0.92);
    font-size: 14px;
    font-weight: 600;
    line-height: 1;
    white-space: nowrap;
    background: rgba(0, 0, 0, 0.75);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    border: 1px solid rgba(230, 191, 106, 0.42);
    border-radius: 999px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  }

  .lobby-quests-btn[aria-expanded="true"] {
    border-color: rgba(238, 199, 97, 0.85);
    background: rgba(30, 22, 12, 0.9);
  }

  .lobby-quests-btn-count {
    padding: 2px 8px;
    font-size: 12px;
    font-weight: 700;
    color: #f7f0df;
    background: rgba(238, 199, 97, 0.22);
    border-radius: 999px;
  }

  /* The panel the pill opens: anchored under the bar, never taller than the
     page box, and out of the command centre's flow so its row collapses and
     the play cards get the height back. */
  #lobby-quests {
    position: fixed;
    z-index: 9998;
    top: var(--mf-topbar-clear);
    left: calc(var(--mf-safe-left) + 0.75rem);
    width: min(
      460px,
      calc(100vw - var(--mf-safe-left) - var(--mf-safe-right) - 1.5rem)
    );
    max-height: calc(100dvh - var(--mf-topbar-clear) - var(--mf-safe-bottom) - 12px);
    overflow-y: auto;
    overscroll-behavior: contain;
    box-sizing: border-box;
    margin: 0;
    padding: 12px 14px;
    border-radius: var(--mf-radius);
    box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
  }

  /* `is-visible` is quests.js's "the rail has mounted" flag and carries
     `display: flex` from lobby.css, which outranks a bare id selector here.
     This block has to take the display decision back at the same class count
     or the sheet is simply always open — which is exactly what it did on the
     first run of this change. */
  #lobby-quests.is-visible {
    display: none;
  }

  #lobby-quests.is-visible.is-open {
    display: block;
  }

  /* One quest per row: the sheet is a list, and the desktop rail's three-across
     grid would put three narrow columns in a popover. */
  .lobby-quests-list {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 8px;
    min-height: 0;
    overflow: visible;
  }

  /* ---- Invite: the gold pill, once the banner is moved into the bar ------ */
  /* FLEX, not block. As a block box this <aside> puts its inline-flex child on
     a TEXT BASELINE — the pill then renders below every 44px peer and hangs out
     of its own wrapper while every size assertion still passes. */
  .user-controls > .referral-banner {
    position: relative;
    /* The banner's own rule floats it against the page: at this tier
       `.referral-banner` is `position: fixed` with
       `bottom: calc(var(--legal-footer-h) + 10px)`, and `position: relative`
       alone does not discard that offset — a relative box with `bottom: 37px`
       simply moves UP 37px. Measured in the bar before this line: the pill sat
       at y -25 with its top half off the screen. */
    inset: auto;
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    width: auto;
    height: var(--mf-touch);
    min-height: var(--mf-touch);
    padding: 0;
    margin: 0;
    overflow: visible;
    background: none;
    border: 0;
    border-radius: 999px;
    box-shadow: none;
  }

  .user-controls > .referral-banner::after,
  .user-controls > .referral-banner > .referral-banner-mark,
  .user-controls > .referral-banner > .referral-banner-content {
    display: none;
  }

  .user-controls > .referral-banner > .referral-banner-open {
    display: inline-flex;
    grid-column: auto;
    width: auto;
    height: var(--mf-touch);
    min-height: var(--mf-touch);
    /* main.css gives every <button> a vmin margin and `.user-controls > *`
       zeroes it for direct children only; this pill is a grandchild. */
    margin: 0;
    padding: 0 14px;
    align-items: center;
    justify-content: center;
    gap: 7px;
    color: #26152f;
    font-size: 14px;
    font-weight: 800;
    letter-spacing: 0.01em;
    line-height: 1;
    border-radius: 999px;
  }

  .user-controls > .referral-banner .referral-open-full-label {
    display: none;
  }

  .user-controls > .referral-banner .referral-open-icon,
  .user-controls > .referral-banner .referral-open-compact-label {
    display: inline;
  }

  .user-controls > .referral-banner .referral-open-icon {
    flex: 0 0 auto;
    font-size: 15px;
  }

  /* THE ACCOUNT NAME IS NOT THE ROW'S SHOCK ABSORBER.

     "Only the account name yields when the bar fills" was the rule, and with
     `min-width: 0` it yielded all the way to 27px of client width for 109px of
     text — the owner's "part of the text gets cut off ... it looks bad and
     unpolished". It also made the strip engine-dependent: Firefox lays this
     name out 3px wider than Chrome and WebKit, so the identical row ellipsised
     in one engine and not the others.

     The name keeps its width up to `max-width`, and the Moonlight dial yields
     instead — it loses a pixel of pip, which costs nothing readable, where the
     name loses characters. Every tap target still keeps its physical size. */
  .user-controls:has(> .referral-banner) > .user-username {
    flex: 0 0 auto;
    min-width: 5.5rem;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* ---- Feedback: top RIGHT, beside Language and Mute, as on the phone ----
     The cluster is `display: contents` at this band, so its children are placed
     by whatever rule happens to position them — and only `.language-selector`
     has one. Feedback appended there fell to the document origin (measured box
     0,0) underneath the account bar. One fixed flex row removes the arithmetic:
     only the row is positioned and the order is DOM order. */
  .lobby-language-cluster {
    position: fixed;
    /* 0.75rem, matching `.user-controls` — the two corners are one visual row.
       At 0.5rem the right cluster sat 4px above the left bar, and the
       stardust-centring assertion reads that as the account pill and the
       language selector disagreeing by 4px. */
    top: calc(var(--mf-safe-top) + 0.75rem);
    /* RESERVE THE WHOLE RAIL, not one control of it — read off the rail's own
       tokens so the reservation cannot drift from what is actually parked
       there. The corner holds TWO triggers, maximize outermost and mute one
       slot inboard, and this reserved a single control's width: Language then
       printed exactly on top of mute, 48x48 of overlap at 1366x1024 and
       1194x784. The phone copy of this rule has always included
       `--mf-rail-fullscreen-slot`; this one simply never did, and the fault
       was hidden while mute was itself mis-seated into the corner.

       `--mf-rail-edge` already carries the safe-area inset, so it is not added
       again. The slot collapses to 0px where the Fullscreen API does not
       exist, and then this returns to reserving exactly one control. */
    right: calc(
      var(--mf-rail-edge) + var(--mf-rail-btn)
      + var(--mf-rail-fullscreen-slot) + 0.5rem
    );
    z-index: 9999;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    height: var(--mf-touch);
  }

  .lobby-language-cluster .feedback-link,
  .lobby-language-cluster .language-selector {
    position: relative;
    top: auto;
    right: auto;
    bottom: auto;
    z-index: auto;
    margin: 0;
    height: var(--mf-touch);
  }

  /* Icon only, like the phone: the word costs width the corner does not have
     once Language sits beside it. */
  .lobby-language-cluster .feedback-link {
    width: var(--mf-touch);
    min-width: var(--mf-touch);
    padding: 0;
    justify-content: center;
  }

  .lobby-language-cluster .feedback-link .feedback-link-label {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* Mute is the RIGHTMOST control, on the SAME row as Feedback and Language.
     `soundtrack.css` drops it to `top: 3rem` under `(max-width: 1100px)`, which
     is a narrow-desktop stacking rule that a 1024x768 iPad also matches —
     measured at 964,48 while the cluster sat at y=8, so the corner read as two
     disconnected rows. One row, same as the phone. */
  /* NO LONGER LOBBY-ONLY. This used to be scoped `:not(.game-active)` because
     the battle seated the control against the board chrome with its own rule at
     the same specificity, and leaving it unscoped would have moved it. There is
     no battle seat any more — the menu holds one corner everywhere — so scoping
     it out of a match would only make the tablet battle wrapper differ from the
     tablet lobby wrapper, which is the exact thing this round is removing. */
  /* NO `top`/`right` HERE ANY MORE. Seating one control moved it off the rail
     and left `#fullscreen-toggle` on the old tokens, so the maximize button
     printed on top of mute (41.6 x 37px of overlap on an iPad Pro 12.9
     landscape). The row is now seated once, by `--mf-rail-top` and
     `--mf-rail-edge` in responsive.css's touch-tablet band, which both
     triggers read — and which also preserves the full-screen slot reservation
     that keeps mute one place inboard of the corner. */
  body:not(.foundry-surface-active) #audio-volume-control {
    display: flex;
    align-items: center;
    /* THE WRAPPER MATCHES WHAT IT WRAPS. Sized from `--mf-touch` it was 44px
       around a `--mf-rail-btn` trigger that is only 34px wherever the coarse
       pointer band does not apply — a mouse-driven window at tablet
       dimensions — and `align-items: center` then pushed the trigger 5px below
       the full-screen button beside it. Reading the trigger's own token keeps
       the two on one row at every size, touch or not. */
    height: var(--mf-rail-btn);
  }

  /* The rail existed to hold the bottom banner. With Invite in the bar it has
     nothing left to carry, and an empty rail still takes a grid row. */
  .lobby-utility-rail:empty {
    display: none;
  }
}

/* ==========================================================================
   THE ACCOUNT STRIP AT A NARROW LANDSCAPE BOX
   ==========================================================================

   The owner: "the username and the stardust elements are too short ... part of
   the text gets cut off, and it looks bad and unpolished ... just make them
   wider."

   Measured at 667x275 in WebKit, the row is 463px wide and its six chips want
   more than that. Two of them refused to give any of it back — Quests kept a
   123px labelled pill and the referral pill 96px — while `min-width: 0` let
   flex take the shortfall entirely out of the username (27px of client width
   for 109px of text) and the Moonlight meter. The two things a player actually
   reads were paying for the two that had already been compacted once.

   Nothing is removed here. Discord stays in the strip, because it has no entry
   in the chrome menu to move into and hiding it would delete the feature
   rather than compact it. Instead the two remaining fixed costs give a little:
   Quests drops to its glyph, which it already does below 420px, and the
   Moonlight dial narrows its segments. Together that is roughly 110px, which
   is what the username and the meter needed.

   Gated on the referral pill's presence because that is what tips the row over;
   without it the strip fits at this width and nothing here applies. */
@media (max-width: 700px) {
  .user-controls:has(> .referral-banner) .lobby-quests-btn-label {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* The dial is a bar of pips; it reads the same a little tighter, and it is
     the single widest thing in the row. */
  .user-controls:has(> .referral-banner) .moonlight-track {
    gap: 1px;
  }

  /* 3px, measured rather than chosen: the percentage is the part that must
     stay legible, and an over-charged account reads "1067%" — four digits, the
     widest string this chip can hold. The dial keeps every pip; each one is
     just narrower. */
  .user-controls:has(> .referral-banner) .moonlight-seg {
    width: 3px;
  }

  /* The referral pill takes its third and last compaction: glyph only. It has
     already dropped "Invite & earn" for "Invite" further up the file; here the
     word goes too. The button keeps `aria-label="Invite & earn"` and its glyph
     is `aria-hidden`, so the accessible name is unchanged — this is the pill
     getting smaller, not the control losing its name. */
  .user-controls:has(> .referral-banner) .referral-open-compact-label {
    display: none;
  }

  /* Glyph-only, the pill is its padding plus a 15px star: 43px, one short of
     the touch floor every other chip in this bar keeps. Measured at 667x275
     in all three engines. */
  .user-controls:has(> .referral-banner) > .referral-banner > .referral-banner-open {
    min-width: var(--mf-touch);
  }

  /* Restores the floor that `min-width: 0` removed two rules up the file. The
     name still ellipsises — it just does so at a width that reads as a name. */
  .user-controls:has(> .referral-banner) > .user-username {
    /* At the NARROWEST box the row genuinely cannot hold six chips at their
       natural widths — Firefox lays this name out ~5px wider than Chrome and
       WebKit, and 463px is all there is. So here, and only here, the name may
       ellipsise again. The floor is what makes that acceptable: it yields to
       "lunar-koi-6…", never to "l". */
    flex: 0 1 auto;
    min-width: 5.5rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}
