/**
 * Rail-960 live topology.
 * Extracted from the signed-off inline sheet with tuning values, component
 * placement, and gated fallback source order preserved. Development guides
 * and alternate topologies are intentionally absent.
 */


    /* ═══ 0 · TOKENS ═════════════════════════════════════════════════════ */

    html[data-lobby-topology="rail-960"] {
      /* SCREEN space — read on .lobby-body, which is not zoomed. */
      --rail-960-rail: 322px;    /* mode-rail track                              */
      --rail-960-band: 280px;    /* retention band row                           */
      --rail-960-band-ks: 176px; /* …in Killstreaks — Quick Play + Objectives    */
      --rail-960-gap: 12px;      /* gap between regions                          */

      /* DESIGN space — read inside .lobby-left's zoom. */
      --rail-960-quests: 288px;  /* Objectives column inside the band            */
      --rail-960-inner: 8px;     /* gap between the band's own cells             */
    }

    /* ═══ 1 · TOPOLOGY ═══════════════════════════════════════════════════
       Named areas rather than line numbers: the rail spans two rows and the
       ledger spans two columns, and `grid-area: rail` says which region an
       element IS. Line arithmetic says only where it landed. */

    html[data-lobby-topology="rail-960"] .lobby-body {
      display: grid;
      grid-template-columns: var(--rail-960-rail) minmax(0, 1fr);
      /* The independent ceiling lets §6.3 tighten the band at short heights.
         Whichever value is smaller wins. */
      grid-template-rows: minmax(0, 1fr) min(var(--rail-960-band), var(--rail-960-band-max, 100vh)) auto auto;
      grid-template-areas:
        "rail   stage"
        "rail   band"
        "ledger ledger"
        "legal  legal";
      column-gap: var(--rail-960-gap);
      row-gap: var(--rail-960-gap);
      align-items: stretch;
      overflow: hidden;
    }

    html[data-lobby-topology="rail-960"] .lobby-body > .lobby-right   { grid-area: rail; }
    html[data-lobby-topology="rail-960"] .lobby-body > .lobby-center  { grid-area: stage; }
    html[data-lobby-topology="rail-960"] .lobby-body > .lobby-left    { grid-area: band; }
    html[data-lobby-topology="rail-960"] .lobby-body > .below-stage-slot { grid-area: ledger; }
    html[data-lobby-topology="rail-960"] .lobby-body > .legal-rail    { grid-area: legal; }

    /* ═══ 2 · THE MODE RAIL ══════════════════════════════════════════════
       One positioned box holding five mutually exclusive panels. This is also
       the fix for the Store panel's ≤1280 fallback, which flips Store and
       Inventory to `position: relative; min-height: 520px`, which assumes a
       page that scrolls. The flex shell forbids that (.lobby-body is
       overflow:hidden), so in any box shorter than 520 that rule renders a
       block no one can reach. Everything goes back to absolute. */

    html[data-lobby-topology="rail-960"] .lobby-body > .lobby-right {
      position: relative;
      display: block;
      width: 100%;
      max-width: none;
      height: 100%;
      max-height: 100%;
      min-height: 0;
      padding: 0;
      overflow: hidden;
    }

    html[data-lobby-topology="rail-960"] .lobby-right > .play-right-stack,
    html[data-lobby-topology="rail-960"] .lobby-right > .store-shop-panel,
    html[data-lobby-topology="rail-960"] .lobby-right > .inventory-panel,
    html[data-lobby-topology="rail-960"] .lobby-right > .leaderboard-panel,
    html[data-lobby-topology="rail-960"] .lobby-right > .custom-match-panel {
      position: absolute;
      inset: 0;
      width: auto;
      height: auto;
      min-height: 0;
      max-height: none;
      flex: 0 1 auto;
    }

    /* The rail is 342 design px — the width `repeat(3, 1fr)` was drawn for.
       The Store panel swaps to `auto-fill minmax(104px, 1fr)` at ≤1280
       because it assumed the ≤1280 rail was the full-width STACKED one. It is
       not stacked here, so take the designed grid back.

       The grid scrolls and shows a part-height row at the cut. That is the
       shipped desktop behaviour at 1920 too, so it is left alone rather than
       masked: a static bottom fade would still be painting when the list is
       scrolled to its end, which turns a truthful cue into a permanent one. */
    html[data-lobby-topology="rail-960"] .lobby-right .inventory-grid {
      grid-template-columns: repeat(3, minmax(0, 1fr));
      min-height: 0;
      overflow-y: auto;
      overscroll-behavior: contain;
    }

    html[data-lobby-topology="rail-960"] .lobby-right .boost-list {
      grid-template-columns: minmax(0, 1fr);
    }

    /* The absorb contract floors Friends at 276px because
       in the flex shell the rail runs the full page height and 276 is the
       comfortable four-row read. Here the rail stops at the ledger, so at the
       short end of the gate — 1180×830 — the stack's intrinsic demand (claim 96
       + Friends 276 + Killstreaks 208 + gaps) is 596 against 563, and Friends
       pushed 40px of itself past the rail with `overflow: hidden` on top.

       Floor it at one visible row plus its chrome instead and let it flex. A
       partial next row is the standard "more below" cue in a list that already
       scrolls; 36px of a hidden fifth row is not a cue, it is a clip.

       Scoped to the stack rather than to `body[data-lobby-panel="play"]` the way
       the absorb rule is: §6 keeps the stack on screen during Killstreaks too,
       and measured at 1180×830 that mode clipped the same 40px for the same
       reason. Scoping to the container the rule is about covers every mode that
       shows it, instead of the one mode the original was written in. */
    html[data-lobby-topology="rail-960"] .lobby-right .play-right-stack > .squad-hub {
      flex: 1 1 auto;
      min-height: 140px;
      max-height: none;
    }

    /* ═══ 3 · THE STAGE ══════════════════════════════════════════════════
       .lobby-center is now exactly the hero box. .killstreaks-aperture is
       already `position: absolute; inset: 0` on it (loadout-mode.css:90), so
       Killstreaks opens at hero size with no span rule and no markup change —
       the feedback item, answered by placement rather than by a size cap. */

    html[data-lobby-topology="rail-960"] .lobby-body > .lobby-center {
      position: relative;
      display: flex;
      flex-direction: column;
      height: 100%;
      max-height: 100%;
      min-height: 0;
      overflow: hidden;
    }

    /* Release the hero ceiling. density-bake.js writes an inline
       `max-height: 77%` on .hero-preview from the compact-1280 bake, and that
       number exists for one reason: in the 3-column shell the stage column
       ALSO carried the Combat Record, so an unbounded hero would push it out.
       The record is a full-bleed ledger now, so the ceiling is answering a
       question this topology does not ask — it was just leaving 23% of the
       stage cell as black.

       `!important` is required, not preferred: an important author declaration
       is the only thing in the cascade that outranks a normal inline one, and
       the inline write is re-applied on every runtime apply(). */
    html[data-lobby-topology="rail-960"] .lobby-center .hero-preview {
      max-height: none !important;
    }

    /* ═══ 4 · THE RETENTION BAND ══════════════════════════════════════════
       Quick Play + Select Mode stack over the Duty Log; Objectives take the
       full band height beside them. Objectives being a full-height column is
       the whole reason it stops clipping: v1 gave it whatever was left in a
       vertical rail after three other surfaces, which was never enough for
       three rows.

       Band height is mode-invariant. v1 had to compress the Duty Log in mode
       views (drop the intel cards, ~110px) because the deck grew from 264 to
       340 underneath it. The rail does not change size between modes, so
       nothing has to be deleted to make room, and the Duty Log shows the same
       three days whether you are on Play or in the Store. */

    html[data-lobby-topology="rail-960"] .lobby-body > .lobby-left {
      display: grid;
      grid-template-columns: minmax(0, 1fr) var(--rail-960-quests);
      grid-template-rows: auto minmax(0, 1fr);
      grid-template-areas:
        "play quests"
        "duty quests";
      gap: var(--rail-960-inner) var(--rail-960-gap);
      width: 100%;
      max-width: none;
      height: 100%;
      max-height: 100%;
      min-height: 0;
      overflow: hidden;
    }

    html[data-lobby-topology="rail-960"] .lobby-left > .play-cluster        { grid-area: play; }
    html[data-lobby-topology="rail-960"] .lobby-left > .duty-log            { grid-area: duty; }
    html[data-lobby-topology="rail-960"] .lobby-left > .module--objectives  { grid-area: quests; }

    html[data-lobby-topology="rail-960"] .lobby-left > * {
      min-width: 0;
      min-height: 0;
    }

    /* ── 4.1 Quick Play cluster ──────────────────────────────────────────
       quick-play.css pins `.play-cluster { width: min(340px, …) }` for the
       340px rail it was drawn in. The band cell is ~292 design px, and a 340px
       child in a 292px grid cell overflows silently under `overflow: hidden`.
       Track the cell instead of a remembered number. */
    html[data-lobby-topology="rail-960"] .lobby-left .play-cluster {
      display: flex;
      flex-direction: column;
      flex: 0 0 auto;
      width: 100%;
      max-width: none;
      min-height: 0;
      margin-top: 0;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .mode-select {
      flex: 0 0 auto;
      margin-top: 6px;
    }

    /* ── 4.1b Mode row — one line, short labels ───────────────────────────
       Measured at 1100×900 (rail on): play column ≈409 wide, mode-select was
       81 tall (22 head + 7 gap + 52 grid) and left Duty Log only 114 / cards
       59. The head row ("Select mode | Custom Match") was the waste — Custom
       is not a mode radio, but it is a peer control and fits beside the three
       modes once the names collapse to TDM / FFA / RND.

       Hide the eyebrow (kept as #mode-label for the radiogroup), put Custom
       on the mode row, and drop the grid from 52 → 38. That returns ~43px to
       the duty cell without touching band height or Objectives. Full names
       stay on aria-label + data-name; Quick Play still reads data-short. */
    html[data-lobby-topology="rail-960"] .lobby-left .mode-select__label {
      position: absolute;
      width: 1px;
      height: 1px;
      margin: -1px;
      padding: 0;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .mode-select__row {
      gap: 6px;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .mode-grid {
      height: 38px;
      gap: 4px;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .mode-card {
      height: 38px;
      gap: 2px;
      padding: 2px 2px 4px;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .mode-card__icon,
    html[data-lobby-topology="rail-960"] .lobby-left .mode-card__icon svg {
      width: 15px;
      height: 15px;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .mode-card__name {
      font-size: 10px;
      font-weight: 800;
      letter-spacing: 0.08em;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .mode-card::after {
      right: 28%;
      left: 28%;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .custom-match {
      align-self: stretch;
      min-height: 0;
      padding: 0 8px;
      font-size: 9px;
      letter-spacing: 0.1em;
    }

    /* ── 4.2 Duty Log ────────────────────────────────────────────────────
       The band gives the Duty Log a wide, short box: header row on top, three
       intel cards filling what is left. `.intel-card` carries `aspect-ratio: 1`
       — drawn for a 340px rail where a square card was ~100px tall. Here the
       cards are ~90 wide and the box has ~64 to give, so the square would
       overflow by ~26px per card and `.duty-log { overflow: hidden }` would
       eat the reward line without reporting anything.

       Release the ratio and let the card's own `grid-template-rows: auto 1fr
       auto` absorb the height — day and status pin to the top, the reward to
       the bottom, and the flexible middle takes the difference at any band
       height. Same shape of fix as v1 §5.4 on .spin-card__art: when a fixed
       ratio meets a variable box, the ratio is the thing that is wrong. */
    html[data-lobby-topology="rail-960"] .lobby-left .duty-log {
      display: flex;
      flex-direction: column;
      flex: 0 1 auto;
      min-height: 0;
      max-height: none;
      padding: 9px 10px 10px;
      overflow: hidden;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .duty-log .duty-log__header {
      flex: 0 0 auto;
      gap: 10px;
      margin-bottom: 7px;
    }

    /* The header is `heading | week strip` on one line, and at the rail's 340px
       that is 94 + 12 + 158 + 20 = 284 of intrinsic demand. The band's play
       column is ~266, so the h2 wrapped to two lines ("DUTY" / "LOG") and ate
       the intel cards' height from above — the kind of overflow that reads as
       a styling bug rather than a fit one.

       Take the 20px week cell down to 16 and the title to 17px: 252 of demand
       against 266. Both numbers are load-bearing, so nowrap makes any future
       shortfall show up as overflow (which the readout catches) instead of as
       a silent second line. */
    html[data-lobby-topology="rail-960"] .lobby-left .duty-log__title {
      font-size: 17px;
      white-space: nowrap;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .duty-log__eyebrow {
      font-size: 8.5px;
      letter-spacing: 0.14em;
      white-space: nowrap;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .week-days {
      grid-template-columns: repeat(7, 16px);
      gap: 3px;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .week-day {
      width: 16px;
      height: 16px;
      font-size: 8px;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .duty-log .intel-window {
      flex: 1 1 auto;
      min-height: 0;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .duty-log .intel-track {
      height: 100%;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .duty-log .intel-card {
      aspect-ratio: auto;
      height: auto;
      min-height: 0;
      padding: 5px 6px 6px;
    }

    /* The card says the same thing twice: `.intel-card__context` is a small
       label in the top-right ("Collected" / "Claim reward" / "Locked") and the
       chip under it is the same state in the card's own voice ("SECURED" /
       "READY" / "TOMORROW"). At the rail's 340px both fit beside the weekday.
       At the band's ~78px card the label truncates mid-word — "CLAIM REW" —
       which reads as a bug and costs the row its top line to say nothing new.

       Drop the duplicate, keep the chip. The day gets the top row to itself and
       the reward line stops being squeezed off the bottom. */
    html[data-lobby-topology="rail-960"] .lobby-left .duty-log .intel-card__context {
      display: none;
    }

    /* ── 4.3 Objectives ──────────────────────────────────────────────────
       Scrolls rather than clips. A rail that silently deletes the third
       objective is worse than one that admits there is more — but at the
       dialed band height all three fit, so the scroller is the safety net for
       a fourth objective, not the resting state. */
    html[data-lobby-topology="rail-960"] .lobby-left .module--objectives {
      display: flex;
      flex-direction: column;
      flex: 0 1 auto;
      min-height: 0;
      max-height: none;
      overflow: hidden;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .module--objectives .module__header {
      flex: 0 0 auto;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .module--objectives .objective-list {
      flex: 1 1 auto;
      min-height: 0;
      overflow-y: auto;
      overscroll-behavior: contain;
      scrollbar-width: thin;
      scrollbar-color: rgba(255, 157, 10, 0.3) transparent;
    }

    /* ── 4.4 The objective row at 288px ──────────────────────────────────
       `.objective__title-row` is `title | reward chip` on one line, and
       `.objective__top` reserves a fixed 46px CTA column so the Collect button
       cannot shove the chip when an objective becomes claimable — a good call
       at the 452px rail this was drawn against. At 288 the arithmetic runs out:
       248 usable − 14 node − 46 CTA − 12 gaps = 176 for the title row, and the
       chip plus its gap takes 67 of it. "Login 5 days this week" needs ~145 in
       the 109 that remains, so objective-title-marquee.js turned it into a
       permanently scrolling ticker. Three headlines sliding under each other is
       noise, not motion design.

       The 46px reservation is not the thing to give up — a CTA that appears and
       shoves the row is worse. The chip is: it is a static value, and the
       bottom-right of the card is empty in every state that matters. Lifting it
       out of flow returns the full 176 to the title, which clears every string
       in the set with room to spare, and the marquee goes back to being what it
       was for — the exceptional long objective, not all of them.

       `.objective__delta` occupies that corner for a transient "+1" flourish on
       progress. It yields: a permanent reward value beats an animation, and the
       progress bar beside it already carries the same news. */
    html[data-lobby-topology="rail-960"] .lobby-left .objective {
      min-height: 60px;
      padding: 8px 10px 9px;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .objective__title-row > .objective__reward {
      position: absolute;
      right: 10px;
      bottom: 8px;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .objective__delta {
      display: none;
    }

    html[data-lobby-topology="rail-960"] .lobby-left .objective__meta-row {
      grid-template-columns: minmax(0, 1fr);
    }

    /* ═══ 5 · THE LEDGER ══════════════════════════════════════════════════
       .below-stage-slot is re-parented from .lobby-center to .lobby-body by
       the topology runtime, so the Combat Record spans the full page instead
       of the stage column. At 960 that is 904px against the 556 it had — the
       same box the record was designed for at 1920, which is why nothing
       inside it needs reworking.

       density-bake.js keeps writing the inline height (crPx, 126 at this
       bake) and the inner zoom; both survive re-parenting untouched, because
       they are written on the slot itself, not on its ancestry. */

    html[data-lobby-topology="rail-960"] .lobby-body > .below-stage-slot {
      position: relative;
      width: 100%;
      min-width: 0;
      align-self: stretch;
    }

    /* ═══ 6 · KILLSTREAKS ═════════════════════════════════════════════════
       v1 collapsed the deck and hid the rail, the claim banner and the Combat
       Record, so opening Killstreaks rebuilt the page around itself. That is
       the thing to stop. The rail keeps Friends and the streak card, the
       ledger keeps the record, Quick Play stays where it was — and the
       aperture is the stage cell, which is `position: absolute; inset: 0` on
       .lobby-center already and therefore needs no rule at all. */

    html[data-lobby-topology="rail-960"] body[data-lobby-panel="loadout"] .lobby-right > .play-right-stack,
    html[data-lobby-topology="rail-960"] body[data-lobby-panel="loadout"] .lobby-body > .below-stage-slot,
    html[data-lobby-topology="rail-960"] body[data-lobby-panel="loadout"] .below-stage-slot > .combat-record-shell {
      opacity: 1;
      visibility: visible;
      pointer-events: auto;
      transform: none;
    }

    /* Custom Match owns this rail. §6 unhides the Play stack for Friends;
       drop the claim card so its z-index:5 body cannot paint over the lobby header. */
    html[data-lobby-topology="rail-960"] body[data-play-path="custom"][data-lobby-panel="loadout"] .lobby-right .lr-card--featured {
      display: none;
    }

    /* ── 6.1 The one place the aperture cannot just be the hero box ───────
       MEASURED, not assumed. At 960×900 the stage cell is 556×330 and the KS
       editor's fixed chrome alone is header 44 + progression 40 + body padding
       26 + slot label 18 + slot rail 84 = 212, leaving 118 for an arsenal whose
       own floor is 2×(22 label + 80 card) + a 10 band gap = 214. It is 96px
       short before a single card is drawn, and it cannot scroll its way out:
       the frame sets `.ks-body { overflow: hidden }` under the fill
       contract on purpose, because that contract fits CARD HEIGHT to the box
       rather than letting the box scroll — and its clamp floor is 80px.
       Probed at 330 the result is 259px of unreachable content.

       So the band yields the difference, and it yields the right part of
       itself. `.play-left-dim` already marks what is inert in this mode: the
       Duty Log and the Objectives are at 40% and pointer-events:none, while
       Quick Play stays live (loadout-mode.css:50 goes out of its way to
       re-assert that). The Duty Log — the dimmed surface with the lowest
       information density — steps out, the band drops to the height of the
       active Quick Play control, and the stage takes the 116px back:

           band 292 → 176   ·   stage 330 → 446

       Quick Play and Objectives do not move a pixel horizontally, the rail and
       the ledger do not move at all, and the arsenal gets a box it fits in.
       That is the difference between a layout yielding and a layout collapsing. */

    html[data-lobby-topology="rail-960"] body[data-lobby-panel="loadout"] .lobby-body {
      grid-template-rows: minmax(0, 1fr) var(--rail-960-band-ks) auto auto;
    }

    html[data-lobby-topology="rail-960"] body[data-lobby-panel="loadout"] .lobby-body > .lobby-left {
      grid-template-rows: minmax(0, 1fr);
      grid-template-areas: "play quests";
    }

    html[data-lobby-topology="rail-960"] body[data-lobby-panel="loadout"] .lobby-left > .duty-log {
      display: none;
    }

    /* Trim the aperture's own chrome. Probed at band 176 the arsenal was still
       38px short with cards already on their 80px clamp floor, and the floor is
       inside density-bake.js's fit contract — not something a stylesheet can
       argue with. So the 38 comes out of chrome that is not carrying it: the
       title bar's 60px floor, the progression band's padding and rank glyph,
       and the slot rail's own padding. Nothing here changes what is SHOWN, only
       how much air is around it, which is the right thing to spend first. */
    html[data-lobby-topology="rail-960"] .killstreaks-aperture .ks-header {
      min-height: 44px;
      padding: 6px 46px 6px 14px;
    }

    html[data-lobby-topology="rail-960"] .killstreaks-aperture .ks-progress {
      gap: 12px;
      padding: 6px 12px;
    }

    html[data-lobby-topology="rail-960"] .killstreaks-aperture .ks-progress__rank img {
      width: 30px;
      height: 30px;
    }

    html[data-lobby-topology="rail-960"] .killstreaks-aperture .ks-body {
      padding: 9px 12px 11px;
    }

    html[data-lobby-topology="rail-960"] .killstreaks-aperture .ks-label {
      margin-bottom: 5px;
    }

    html[data-lobby-topology="rail-960"] .killstreaks-aperture .ks-rail {
      --pad-t: 6px;
      padding: var(--pad-t) 6px 6px;
    }

    html[data-lobby-topology="rail-960"] .killstreaks-aperture .ks-arsenal {
      margin-top: 7px;
    }

    /* ═══ 6.3 · SHORT-BAND RELIEF ════════════════════════════════════════
       the mode resolver's gate opens at ≥820 tall, and the numbers above are
       dialed at 900. Between the two the fixed furniture does not shrink —
       ledger 126 + legal 28 + gaps 36 = 190 whatever the viewport — so the
       band and the stage split what is left, and at 830 that is 741. A 292
       band leaves the stage 259, which at the gate's widest (1180 → a 776px
       stage) is an aspect of 2.98. That is a letterbox, not a stage.

       Rather than let the corner of the band render badly, the band gives up
       the same surface it gives up for Killstreaks, for the same reason: the
       Duty Log is the lowest-density thing in it, Quick Play is the live
       control, and the Objectives are what a player checks before queueing.
       196px carries both of those, and the stage goes 259 → 355 (ar 2.19).

       Killstreaks goes further here, because it has to: measured at 830 with
       the §6.1 band the arsenal is still 57px short, and there is nothing left
       to trim that is not information. At this height the aperture takes the
       column — the rail and the ledger still stay, so the page around it is
       intact; only Quick Play travels with the band.

       880 is the crossover, not a guess: it is the height at which a 292 band
       still leaves the stage 300px, which is where a 960-wide stage passes
       ar 1.85 and a 1180-wide one passes 2.6. */

    @media (max-height: 879px) {
      html[data-lobby-topology="rail-960"] {
        --rail-960-band-max: 196px;
      }

      html[data-lobby-topology="rail-960"] .lobby-body > .lobby-left {
        grid-template-rows: minmax(0, 1fr);
        grid-template-areas: "play quests";
      }

      html[data-lobby-topology="rail-960"] .lobby-left > .duty-log {
        display: none;
      }

      html[data-lobby-topology="rail-960"] body[data-lobby-panel="loadout"] .lobby-body > .lobby-center {
        grid-column: 2;
        grid-row: 1 / 3;
      }

      html[data-lobby-topology="rail-960"] body[data-lobby-panel="loadout"] .lobby-body > .lobby-left {
        display: none;
      }
    }

    /* ═══ 6.4 · MODE ROW — GATED FALLBACK ════════════════════════════════
       The 9 Aug mode row is the only part of Rail-960 that is a MARKUP change:
       `.mode-select__head` (label | Custom Match) over `.mode-grid` became
       a bare label over `.mode-select__row` (grid | Custom Match). Markup does
       not stop applying at a gate the way a stylesheet does — same failure mode
       as the ledger re-parent (§11.11), just quieter. Measured at 1920×945
       against the signed-off wide fallback: `.play-cluster` 235 → 217 and
       `.module--objectives` 334 → 353. Nothing looked broken; the fallback had
       simply stopped matching the fallback.

       So rebuild the fallback head out of the new markup whenever the rail
       topology is NOT painting. `display: contents` lifts the row's two children
       into `.mode-select`'s own grid — safe here precisely because
       `.mode-select__row` is a bare wrapper with no box of its own — and the
       label and Custom Match share row 1 at the fallback's `min-height: 22px`
       and 7px gap, with the mode grid on row 2.

       Two selectors because the condition is NOT(rail AND on), and CSS has no
       `:not()` across two attributes on one element without `:is()` gymnastics
       that read worse than the pair. */

    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-select,
    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-select {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      grid-template-rows: minmax(22px, auto) auto;
      grid-template-areas:
        "label custom"
        "grid  grid";
      align-items: center;
      column-gap: 10px;
      row-gap: 7px;
    }

    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-select__row,
    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-select__row {
      display: contents;
    }

    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-select__label,
    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-select__label {
      grid-area: label;
      height: auto;
      margin: 0;
    }

    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-select .custom-match,
    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-select .custom-match {
      grid-area: custom;
      align-self: center;
    }

    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-grid,
    html:not([data-lobby-topology="rail-960"]) .lobby-left .mode-grid {
      grid-area: grid;
    }

