    :root {
      --bg-base: #09090b; --bg-surface: #111113; --bg-elevated: #18181b;
      --bg-hover: #27272a; --bg-active: #3f3f46;
      --bg-card: #111113; --bg-detail: #18181b; --bg-page: #09090b;
      --bg-raised: #27272a; --bg-sidebar: #0e0e10;
      --border: rgba(255,255,255,0.1); --border-subtle: rgba(255,255,255,0.06);
      --border-default: rgba(255,255,255,0.1); --border-strong: rgba(255,255,255,0.16); --border-card: rgba(255,255,255,0.22);
      --accent: #10b981; --accent-hover: #059669; --accent-bg: rgba(16,185,129,0.12);
      --accent-muted: rgba(16,185,129,0.15); --accent-glow: #10b981;
      /* FIX 2026-07-23: the secondary/muted ramp cleared the 4.5:1 AA floor but
         still read as dim gray at a glance. Raised the whole muted end so the
         lowest rung sits ~7:1 and the top of the ramp ~11:1 on --bg-surface,
         keeping the ordering primary > secondary > muted > faint > dim. */
      --text-primary: #f4f4f5; --text-secondary: #e4e4e7; --text-muted: #d4d4d8; /* 2026-09-11 ladder up one step: was #d4d4d8 / #c6c6cd; #b4b4bc before that */
      --text-faint: #c6c6cd; /* was #b0b0ba (2026-09-11); #8a8a94 before that, 5.6:1 → 9.9:1 → 11.4:1 */
      --text-dim: #b8b8c0;   /* was #a3a3ad (2026-09-11); #86868f before that, 5.2:1 → 7.5:1 → 9.5:1 (#71717a before that was a 3.9:1 AA fail) */
      --accent-blue: #10b981; --accent-blue-light: #34d399;
      --accent-green: #22c55e; --accent-green-bg: rgba(34,197,94,0.12);
      --accent-red: #f87171; --accent-red-bg: rgba(239,68,68,0.12); /* #ef4444 text was 4.49:1 on light rows — WCAG 1.4.3 nudge to red-400 (FIX 2026-07-18, ~6.8:1); bg tint unchanged */
      --accent-amber: #f59e0b; --accent-amber-bg: rgba(245,158,11,0.12);
      --ev-pos: #22c55e; --ev-neg: #f87171; /* lightened from #ef4444 for WCAG 1.4.3 AA (FIX 2026-07-18) */
      --shadow-card: 0 2px 8px rgba(0,0,0,0.4); --shadow-card-sm: 0 1px 3px rgba(0,0,0,0.4); --shadow-hover: 0 4px 16px rgba(0,0,0,0.5);
      --radius-sm: 6px; --radius-md: 8px; --radius-lg: 12px; --radius-pill: 999px;
      --transition-fast: 120ms ease-in-out; --transition-normal: 200ms ease;
      --font-mono: 'IBM Plex Mono','JetBrains Mono','SF Mono','Fira Code',monospace;
      --space-xs: 4px; --space-sm: 8px; --space-md: 12px; --space-lg: 16px; --space-xl: 24px;
      /* FIX 2026-07-23: type ramp was 15/13/11/9 — body copy at 13px and micro
         labels at 9px are well under any comfortable reading floor. Raised across
         the board, and every hardcoded px/rem font-size in the app was swept onto
         the same curve. 12px is now the smallest text any surface can render; a
         live /ui pass is the check (209 text nodes sat at 11px after the first
         pass — game times, book names, grade chips — so the floor moved again). */
      --text-lg: 22px; --text-base: 18px; --text-sm: 17px; --text-xs: 16px; /* 2026-09-11 floor 12→14→16 the same day: was 18/15/13/12, then 20/16/15/14 */
    }
    /* Light theme removed — dark mode only */
    html { scroll-behavior: smooth; }
    body { font-family: 'IBM Plex Sans',system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; margin:0; background:var(--bg-page); color:var(--text-secondary); font-size:var(--text-base); line-height:1.55; }
    :focus-visible { outline:2px solid var(--accent-blue); outline-offset:2px; }
    /* Top Nav */
    /* gap 2026-09-05: xl->md. With Home removed the bar has two gaps, not
       three; --space-md (12px) x2 = 24 -- one piece of the real 235px
       deficit measured at 1440px, not the whole of it (2026-09-05 fix round
       1, finding 4: this alone does not "complete" anything; the tagline and
       .top-nav-tabs's own margin-left, removed elsewhere, are needed too).
       Already the gap the 640-1399px block below uses, so desktop is now
       consistent -- that block's own gap declaration is redundant but
       harmless; left in place. */
    .top-nav { position:fixed; top:0; left:0; right:0; height:56px; padding-top:env(safe-area-inset-top); background:var(--bg-surface); border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; padding-left:var(--space-xl); padding-right:var(--space-xl); padding-bottom:0; z-index:100; gap:var(--space-md); }
    .top-nav-logo-wrap { display:flex; flex-direction:column; line-height:1; min-width:160px; flex-shrink:0; }
    /* Brand lockup (Bill, 2026-10-14): wordmark only (tagline retired 2026-10-17), centered, brand green, as in the brand
       kit's 3x2 logo. Desktop only, where the sidebar column shows; tablets and phones keep the bare wordmark.
       From 1025px the logo block is as wide as the 260px sidebar column (less the nav's left padding and gap, and
       the tabs' margin in the 640-1399 band), so the first tab and its active underline start past the column:
       at 200 -> 260px the Games tab sat over the MARKETS rail. */
    .top-nav-lockup { display:inline-flex; flex-direction:column; align-items:center; gap:4px; }
    @media (min-width:1025px) {
      .top-nav-logo-wrap { width:calc(260px - var(--space-xl) - var(--space-md)); align-items:flex-start; }
    }
    @media (min-width:1025px) and (max-width:1399px) {
      .top-nav-logo-wrap { width:calc(260px - var(--space-md) - var(--space-md) - var(--space-sm)); }
    }
    .top-nav-logo { font-size:1.4rem; font-weight:300; color:var(--text-primary); white-space:nowrap; font-family:'IBM Plex Sans',sans-serif; letter-spacing:-0.02em; }
    .top-nav-logo .logo-pipe { font-weight:800; color:var(--accent); text-shadow:0 0 8px rgba(16,185,129,0.4); }
    .top-nav-logo .logo-bold { font-weight:700; }
    /* FIX 2026-10-05: the ∑ is the last letter of "Edge"; 0.75em of the phone logo (18px)
       drew it at 13.5px. max() keeps the floor on phones; desktop (22.4px logo) is unchanged.
       tests/test_logo_wordmark_floor.py. */
    .top-nav-logo .logo-sigma { font-weight:700; font-size:max(16px, 0.75em); vertical-align:0.1em; color:var(--accent); text-shadow:0 0 14px rgba(16,185,129,0.5); }
    .top-nav-tabs { display:flex; gap:var(--space-xs);
      /* No margin-left here (removed 2026-09-05, BASE/DESKTOP SCOPE ONLY):
         .top-nav's own flex gap already separates the strip from the logo,
         so this was redundant double-spacing that predated the gap -- worth
         24 of the real 235px deficit (the bar wanted 1675px at 1440, not the
         1651/211 an earlier draft of this arithmetic missed). The
         (min-width:640px) and (max-width:1399px) block below deliberately
         keeps its own smaller margin-left: var(--space-sm) (8px) on this
         same selector -- that band was not measured for this fix and is
         left untouched (2026-09-05 fix round 1, finding 3). */
      /* min-width:0 is the load-bearing bit: a flex item's default min-width is
         auto, i.e. its content, so this strip refused to shrink and shoved
         .top-nav-right (Sign In included) past the viewport edge with no way to
         scroll to it. Now the strip is the only thing that gives. */
      min-width:0; flex-shrink:1; overflow-x:auto; overscroll-behavior-x:contain;
      scrollbar-width:none; -ms-overflow-style:none; }
    /* A visible scrollbar inside a 56px nav would eat the tabs' underline. */
    .top-nav-tabs::-webkit-scrollbar { display:none; }

    /* Scroll affordance for the tab strip (2026-09-05).

       The strip is the only thing in this bar that scrolls, and its scrollbar
       is deliberately hidden above -- so between 1024px and ~1217px a clipped
       tab had NO signal at all that it existed. Measured on prod at 94c5c8cf:
       at 1280px the UFC tab was cut 7px, at 1024px 257px, with no scrollbar,
       no fade and no mask.

       A mask, not a scrollbar: a visible scrollbar inside a 56px nav eats the
       tabs' underline (the reason it was hidden in the first place), and a
       background gradient sits BEHIND the tab text so it cannot soften the
       cut edge. mask-image is the only one of the three that fades content.

       Applied via classes, never unconditionally: an always-on mask would dim
       the last tab at 1440px where nothing overflows. app.js toggles these
       from the strip's own scroll metrics, so a signed-out user (5 tabs) and
       a Pro user (7) each get the fade exactly when their own strip clips. */
    .top-nav-tabs.nav-fade-end {
      -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
              mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%); }
    .top-nav-tabs.nav-fade-start {
      -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 28px), transparent 100%);
              mask-image: linear-gradient(to left, #000 calc(100% - 28px), transparent 100%); }
    .top-nav-tabs.nav-fade-start.nav-fade-end {
      -webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
              mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%); }

    /* Sidebar chip strip fade (2026-09-05). Same problem as the nav strip and
       the same fix: at <=1024px #sport-tabs-container lays the league rows out
       horizontally with its scrollbar suppressed. Measured on prod at 52ace68f
       at a 375px viewport it was 1948px of content in a 375px box -- 1573px
       hidden, 12 of 15 chips entirely off-screen -- with no scrollbar, no mask
       and no fade. A partially cut chip at the right edge was the only hint.

       MEDIA-SCOPED, and that is load-bearing. app.js sets these classes from
       the element's own scroll metrics, and above 1024px the SAME element is
       the vertical sidebar -- where it still reports ~6px of incidental
       horizontal overflow (measured 146 scrollWidth against 140 clientWidth at
       1440px). That is enough to trip a >1px test and paint a 28px fade down
       the desktop league list. Caught in a browser before shipping; the class
       may still be set up there, but no mask is painted.

       Inside the query the classes still decide, so a narrow viewport whose
       chips happen to fit gets no fade either. */
    @media (max-width: 1024px) {
      #sport-tabs-container.chip-fade-end {
        -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
                mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%); }
      #sport-tabs-container.chip-fade-start {
        -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 28px), transparent 100%);
                mask-image: linear-gradient(to left, #000 calc(100% - 28px), transparent 100%); }
      #sport-tabs-container.chip-fade-start.chip-fade-end {
        -webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
                mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%); }
    }
    /* Icon-only pill (Settings). Keeps the pill's height and hit area while
       dropping the ~44px the word cost; the accessible name moves to
       aria-label + title on the button in index.html. */
    .top-nav-icon-pill { padding-left:9px; padding-right:9px; }
    .top-nav-icon-pill svg { display:block; }
    .top-nav-tab { padding:var(--space-sm) var(--space-sm); /* was 8px 12px: at 17px the seven tabs measured 687px in a 641px strip at 1280 (2026-09-11) */ font-size:17px; font-weight:500; color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent; transition:color var(--transition-fast),border-color var(--transition-fast); line-height:52px; white-space:nowrap; flex-shrink:0; }
    .top-nav-tab:hover { color:var(--text-secondary); }
    .top-nav-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
    .top-nav-right { margin-left:auto; display:flex; align-items:center; gap:var(--space-sm); flex-shrink:0; }
    .top-nav-settings { position:relative; }
    .top-nav-pill { background:none; border:1px solid var(--border-strong); border-radius:var(--radius-sm); padding:6px 14px; color:var(--text-secondary); cursor:pointer; font-size:17px; font-weight:500; transition:all var(--transition-fast); font-family:'IBM Plex Sans',sans-serif; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
    .top-nav-pill:hover { border-color:var(--accent); color:var(--text-primary); }
    .bankroll-pill { gap:2px; position:relative; }
    /* margin-left removed 2026-09-05: it separated "$" from the "Bankroll"
       label removed the same day; "$" is now the pill's first child, and the
       margin was leaving it visibly off-centre against the right side. */
    .bankroll-pill .bankroll-sep { color:var(--text-muted); font-family:var(--font-mono); font-weight:600; }
    .bankroll-pill .bankroll-input { background:none; border:none; color:var(--text-primary); font-family:var(--font-mono); font-size:17px; font-weight:600; width:60px; padding:0; outline:none; -moz-appearance:textfield; }
    .bankroll-pill .bankroll-input::-webkit-inner-spin-button,
    .bankroll-pill .bankroll-input::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
    .bankroll-pill .bankroll-input:focus { color:var(--accent); }
    .bankroll-pill .bankroll-units { color:var(--text-muted); font-family:var(--font-mono); font-size:17px; font-weight:600; border-left:1px solid var(--border-default); padding-left:6px; margin-left:4px; }
    /* 2026-09-20: the label is the unit's VALUE ("1u = $50" -- one unit is one
       percent of bankroll, so the count it printed before said nothing), 41px
       wider than "100u" in this type and 61px at "1u = $1000". Measured on
       prod, signed out, the nav has 34px of slack at exactly 1400px, where the
       640-1399 block below stops hiding the label: from 1400 to 1439 the wider
       label clipped the tab strip by 7px. 1440 is the width the whole nav is
       specced to fit at (tests/test_nav_tabs_fit.py, 74px of slack there), so
       the label shows from 1440 up. Below it, the confirmation line is the
       pill's feedback. */
    @media (max-width: 1439px) {
      .bankroll-pill .bankroll-units { display:none; }
    }
    /* Confirmation line (2026-09-17). The unit label is display:none from 0 to
       1439px (the rule above and the two nav bands below), so a bankroll change
       had no visible result on any phone or ordinary laptop. Anchored the way
       .top-nav-dropdown is: under the pill, right edge to the pill's, growing
       leftward, so no nav width is spent. Always in the DOM (role=status), and
       :empty paints nothing -- text set = visible, text cleared = an empty,
       still-rendered box. No
       animation, so nothing for prefers-reduced-motion to reduce. 16px is the
       type floor (tests/test_type_scale_floor.py). */
    .bankroll-note { position:absolute; top:calc(100% + 4px); right:0; white-space:nowrap; font-family:var(--font-mono); font-size:16px; font-weight:600; color:var(--text-primary); background:var(--bg-surface); border:1px solid var(--border-strong); border-radius:var(--radius-sm); padding:6px 10px; box-shadow:0 8px 24px rgba(0,0,0,0.4); z-index:200; }
    /* Rendered while empty, on purpose: a live region that is display:none
       at the moment its text arrives is not in the accessibility tree and
       announces nothing (final review, 2026-09-17). Empty, it is a zero box
       that paints nothing; :empty is still the visible/hidden switch. */
    .bankroll-note:empty { padding:0; border:0; box-shadow:none; }
    .top-nav-dropdown { position:absolute; top:calc(100% + 4px); right:0; width:260px; background:var(--bg-surface); border:1px solid var(--border-default); border-radius:var(--radius-md); padding:var(--space-md); display:none; box-shadow:0 8px 24px rgba(0,0,0,0.4); z-index:200; }
    .top-nav-dropdown.open { display:block; }
    /* The bottom sheet's own chrome (2026-09-22). Drawn only below 800px,
       where this panel is a sheet rather than the gear's dropdown. */
    .settings-backdrop { display:none; }
    .settings-sheet-handle { display:none; }
    .settings-done { display:none; }
    .top-nav-dropdown label { font-size:var(--text-sm); color:var(--text-secondary); display:block; margin-bottom:var(--space-xs); }
    /* The risk note (2026-09-22): what the chosen stop does, under its select.
       --text-xs is the 16px floor; :empty paints nothing, so the dropdown does
       not reserve a gap before app.js fills it. */
    .top-nav-dropdown-note { margin:calc(-1 * var(--space-xs)) 0 var(--space-sm); font-size:var(--text-xs); line-height:1.45; color:var(--text-secondary); }
    .top-nav-dropdown-note:empty { display:none; }
    .top-nav-dropdown input[type="number"] { width:100%; box-sizing:border-box; background:var(--bg-elevated); border:1px solid var(--border-default); border-radius:var(--radius-sm); padding:var(--space-xs) var(--space-sm); color:var(--text-primary); font-size:var(--text-base); margin-bottom:var(--space-sm); }
    /* 2026-09-22: the select was the browser default (white on dark) beside
       those inputs; same treatment, same sizing. */
    .top-nav-dropdown select { width:100%; box-sizing:border-box; background:var(--bg-elevated); border:1px solid var(--border-default); border-radius:var(--radius-sm); padding:var(--space-xs) var(--space-sm); color:var(--text-primary); font-size:var(--text-base); margin-bottom:var(--space-xs); }
    .top-nav-dropdown input:focus { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-muted); outline:none; }
    /* Hamburger (mobile only) + mobile menu sheet */
    .top-nav-hamburger { display:none; background:none; border:1px solid var(--border-strong); border-radius:var(--radius-sm); width:44px; height:44px; padding:11px 9px; cursor:pointer; flex-direction:column; justify-content:space-between; align-items:stretch; flex-shrink:0; }
    .top-nav-hamburger:hover { border-color:var(--accent); }
    .top-nav-hamburger:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
    .top-nav-hamburger span, .tour-offer-hint-menu span { display:block; height:2px; background:var(--text-secondary); border-radius:1px; }
    /* 2026-10-12: capped above the bottom nav, scrolling inside itself. It had
       no max-height and ran to y 645, so at 375x548 (iPhone SE/mini with
       Safari's bars) Betting Glossary, Send Feedback and Home were below the
       screen, and at 390x664 Home sat under the bottom nav (z-index 100, later
       in the DOM, so it paints over this). --menu-clear is everything that is
       not the menu: its own top (the 56px bar + inset + 4px), the bottom nav
       (65px + inset) and an 8px gap. border-box is stated, not inherited:
       /slate gets it only from parlay_builder.css's `*` reset, and the cap
       must count the padding either way. dvh after the vh fallback, so the
       iOS URL bar collapsing tracks the visible area. The rows must not
       shrink to fit: they keep their 44px and the menu scrolls instead.
       Pinned by tests/test_phone_menu_reachable.py. */
    .top-nav-mobile-menu { display:none; left:auto; right:var(--space-md); top:calc(56px + env(safe-area-inset-top) + 4px); width:240px; box-sizing:border-box;
      --menu-clear:calc(56px + env(safe-area-inset-top) + 4px + 65px + env(safe-area-inset-bottom) + 8px);
      max-height:calc(100vh - var(--menu-clear)); max-height:calc(100dvh - var(--menu-clear)); overflow-y:auto; overscroll-behavior:contain; }
    .top-nav-mobile-menu.open { display:flex; flex-direction:column; gap:6px; }
    .top-nav-mobile-menu > * { flex-shrink:0; }
    .top-nav-mobile-menu .top-nav-pill { width:100%; justify-content:flex-start; min-height:44px; }
    /* Bottom nav (mobile chrome) */
    .bottom-nav { display:none; position:fixed; left:0; right:0; bottom:0; padding-bottom:env(safe-area-inset-bottom); background:var(--bg-surface); border-top:1px solid var(--border-subtle); z-index:100; }
    .bottom-nav-inner { display:flex; align-items:stretch; justify-content:space-around; height:64px; }
    .bottom-nav-item { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; flex:1 1 0; min-width:44px; min-height:44px; background:none; border:none; color:var(--text-muted); font-size:16px; font-weight:500; cursor:pointer; transition:color var(--transition-fast); padding:6px 4px; font-family:'IBM Plex Sans',sans-serif; }
    .bottom-nav-item:hover { color:var(--text-secondary); }
    .bottom-nav-item.active { color:var(--accent); }
    .bottom-nav-item:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
    .bottom-nav-item .bn-icon { font-size:20px; line-height:1; letter-spacing:-0.05em; display:inline-flex; align-items:center; justify-content:center; }
    .bottom-nav-item .bn-icon svg { width:22px; height:22px; display:block; }
    .bottom-nav-item .bn-label { letter-spacing:0.02em; }
    /* Medium-desktop (640-1399px): full desktop chrome but tightened so it fits
       narrower laptop widths (1280, 1366, 1440) without overflowing. Without this,
       7 tabs + bankroll-pill + Settings + Parlay + Sign-In overflows the viewport
       at ~1363px minimum natural width.

       FIX 2026-08-22: tightening alone never closed the gap. Measured on prod at
       a 1100px viewport the nav still wanted 1445px, so ~345px ran off the right
       edge -- and because nothing here scrolls horizontally, everything past the
       edge was simply unreachable. Sign In is the last child of .top-nav-right,
       so it was the first thing lost: at 1280px it sat at x=1375. The whole band
       from 640px (where the hamburger takes over) to ~1400px was a dead zone.
       The real fix is the min-width:0 on .top-nav-tabs above; this block now only
       does what its name says, buying space rather than pretending to fix
       overflow. */
    @media (min-width: 640px) and (max-width: 1399px) {
      .top-nav { gap: var(--space-md); padding-left: var(--space-md); padding-right: var(--space-md); }
      .top-nav-tabs { margin-left: var(--space-sm); gap: 0; }
      .top-nav-pill { padding: 6px 10px; }
      .bankroll-pill .bankroll-units { display: none; }
    }
    /* 640-1204px: below ~860px even a zero-width tab strip cannot absorb enough --
       the fixed chrome alone (logo 160 + right-hand controls ~448 + gaps)
       needs ~688px now that the Home button and the Bankroll label are gone
       (2026-09-05); the ~860 figure above predates those removals and has
       not been re-measured since.
       CUTOFF 1204 (was 1039, widened 2026-09-05). The old comment justified
       1039 by a one-pixel cliff -- "a 900px viewport gave the tabs 27px while
       899px gave them 257px". That cliff is gone: re-measured on prod at
       0e8ac831, 899px gives the strip 351px and 900px gives it 352px. It was
       an artifact of the strip refusing to shrink, and the min-width:0 fix
       (2026-08-22) removed it. The stated reason for 1039 no longer holds.

       1039 was also 160px too early, which made the nav NON-MONOTONIC: at
       1039 the strip is 506px with 120px of overflow; at 1040 the full-size
       logo returns, the strip drops to 466px and overflow RISES to 160px.
       Widening the window by one pixel hid another tab.

       1204 is where the full-size logo stops costing anything. Measured on
       prod at 0e8ac831, anonymous (7 tabs, the worst case): overflow is 40px
       at 1160, 10px at 1190 and 0 at 1205. So the small logo now persists
       exactly as long as the strip needs the 40px, and the one remaining
       size change lands where there is no overflow on either side.

       Tier note: a user whose tabs do not include cricket/ufc fits sooner, so
       they keep the smaller logo a little past the point they needed it. That
       is cosmetic and one-directional -- it never causes clipping -- whereas a
       breakpoint set for the fewest-tabs case would clip the most-tabs case.

       Recover the space from the lowest-value pixels, never from Sign In:
         - the oversized logo, per the <=639px rules.
         - the Home button and the "Bankroll" word are both already gone
           (2026-09-05), so there is nothing left to hide there at any width.
       */
    @media (min-width: 640px) and (max-width: 1204px) {
      .top-nav-logo-wrap { min-width: auto; }
      .top-nav-logo { font-size: 1.125rem; }
    }
    /* Mobile breakpoint (<640px): collapse tabs + Settings + Parlay into hamburger; show bottom nav */
    @media (max-width: 639px) {
      .top-nav { padding-left:var(--space-md); padding-right:var(--space-md); gap:var(--space-sm); }
      .top-nav-hamburger { display:flex; }
      .top-nav-tabs { display:none; }
      .top-nav-logo-wrap { min-width:auto; }
      .top-nav-logo { font-size:1.125rem; }
      .top-nav-right { gap:var(--space-xs); margin-left:auto; }
      /* FIX 2026-07-12: collapse everything except the bankroll pill into the
         hamburger/bottom-nav on phones. The old selector targeted
         onclick*="openParlaySlip" — a handler that no longer exists (the
         Parlay Cart button now calls Slate.toggleCart) — so Parlay Cart,
         Builder and Sign In never hid and overflowed/collided with the
         hamburger at <=430px. Login stays reachable via the bottom-nav
         Account item. */
      /* 2026-09-22: :not(.top-nav-settings) -- hiding the CONTAINER meant
         #settings-dropdown could carry .open and still compute display:none,
         so every setting was unreachable on a phone and the mobile menu's
         own Settings row was dead. The gear BUTTON still hides (it costs
         44px of a width-bound nav); the panel opens as a bottom sheet. */
      .top-nav-right > *:not(.bankroll-pill):not(.top-nav-settings) { display:none; }
      .top-nav-settings > .top-nav-icon-pill { display:none; }
      /* 2026-05-17 audit S2: keep '$' on mobile so "1000" doesn't read as a phone number. */
      .bankroll-pill .bankroll-units { display:none; }
      .top-nav-pill { min-height:44px; padding:8px 12px; }
      .bottom-nav { display:block; }
      .feedback-fab { display:none; }
      /* Touch targets (WCAG 2.5.5). A live 375px audit found these well under
         the 44px floor this very block already applies to .top-nav-pill — the
         2026-07-18 sweep set the standard but nothing enforced it, so controls
         outside its scope never got it. Measured: bankroll input 19px, tutorial
         skip 17px (retired with the 16-step card, phase 2b), role="link"
         logo/home wrapper 16px.
         .bankroll-input drives unit sizing + Kelly math, so a mis-tap there
         mis-sizes every stake the product recommends.
         NOT included: the inline "See how it works" link inside the sign-in
         sentence — WCAG exempts inline links in a text block.
         Pinned by tests/test_slate_touch_targets.py. */
      .bankroll-pill .bankroll-input { min-height:44px; }
      /* 2026-10-17: the 44px input above sat inside .top-nav-pill's 8px
         vertical padding, so the pill measured 62px in the 56px bar (y -3.5
         to 58.5 at 375 and 390, its top border off-screen) from 4f61ca6b
         until now. The input is the tap target, so the pill drops its
         vertical padding: 44 + 2px border = 46px, inside the bar.
         Pinned by tests/test_top_nav_bankroll_pill_fits.py. */
      .bankroll-pill { padding-top:0; padding-bottom:0; }
      .top-nav-logo-wrap { min-height:44px; justify-content:center; }
    }
    /* 640-799px: the desktop bar does not fit here (2026-09-06).
       Measured on prod at 03b6bec5, anonymous, 7 tabs: the strip gets 107px at
       640 and 252px at 800 against a 355px fixed right rail, so 1 of 7 tabs is
       visible at 640 and 2 at 800. One pixel below 640 the same user gets a
       hamburger listing all seven at 44px each. This band takes that layout.

       NOT a copy of the <=639px block above: this keeps the account control in
       the bar. At 375px the rail has no room for it (the pill overflowed 61px
       on 2026-09-02, unreachable) and login lives in the bottom-nav Account
       item; here there is room, and Sign In is the primary CTA for anonymous
       users. Exactly one of #login-btn / #user-pill is ever visible, so both
       are exempted -- naming only #login-btn would drop a signed-in user's
       account menu across this whole band.

       The <=639px block is deliberately NOT edited: three test files pin rules
       inside it and the 375px rendering is already correct.

       Spec: docs/superpowers/specs/2026-09-05-hamburger-breakpoint-design.md
       Plan: docs/superpowers/plans/2026-09-06-hamburger-breakpoint.md */
    @media (min-width: 640px) and (max-width: 799px) {
      .top-nav-hamburger { display:flex; }
      .top-nav-tabs { display:none; }
      .bottom-nav { display:block; }
      /* Hidden wherever the bottom nav is shown, as the <=639px block already
         does: at 640-799px it sat ON the nav (review, 2026-09-13). */
      .feedback-fab { display:none; }
      /* Same as the <=639px block: the container stays, the gear goes. */
      .top-nav-right > *:not(.bankroll-pill):not(#login-btn):not(#user-pill):not(.top-nav-settings) { display:none; }
      .top-nav-settings > .top-nav-icon-pill { display:none; }
    }
    /* 800-1024px (Bill, 2026-10-13): the sidebar's footer (Tutorial, Glossary, Terms) is "hamburger-only" from
       1024px down (the tablet sidebar block below), but the hamburger showed only under 800px, so this band had
       no way to the tutorial at all (found at 820x1180 and 1024x768). The menu button shows here as on a phone and
       opens the same menu; the tab strip stays (tests/test_hamburger_breakpoint.py: 800 and up keep it), and the
       menu lists every tab as well. */
    @media (min-width: 800px) and (max-width: 1024px) {
      .top-nav-hamburger { display:flex; }
    }
    /* Layout */
    .app-layout { display:grid; grid-template-columns:260px 1fr; /* 260px since 2026-10-14 (Bill: the MARKETS rail was too narrow; 'Prediction Markets' needs ~240 with a Windows scrollbar); 200px from 2026-09-11, 180px before the 16px floor */ min-height:100vh; padding-top:56px; }
    /* The tour offer card (Bill's batch D, 2026-10-10; templates/index.html #tour-offer, static/js/tour_welcome.js),
       in the 16-step welcome card's place and layer: centred over the board, dimmed and blurred behind it. The
       logo, the record's line, its count and time on their own line, then Play (the tour's white primary, never
       the app's green) and Not now. Green only on the logo; every target 120x44; nothing under 17px. */
    .tour-offer { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; padding:16px; background:rgba(0,0,0,0.6); backdrop-filter:blur(4px); }
    .tour-offer[hidden] { display:none; }
    .tour-offer-card { width:min(440px, 100%); box-sizing:border-box; padding:20px 24px 24px; background:var(--bg-card); border:1px solid var(--border-strong); border-radius:var(--radius-lg); box-shadow:0 24px 80px rgba(0,0,0,0.5); text-align:center; }
    .tour-offer-logo { font-size:20px; font-weight:300; line-height:1.2; color:var(--text-primary); }
    .tour-offer-logo .pipe { margin:0 1px; font-weight:800; color:var(--accent); text-shadow:0 0 8px rgba(16,185,129,0.3); }
    .tour-offer-logo .bold { font-weight:700; }
    .tour-offer-logo .sigma { font-weight:700; color:var(--accent); text-shadow:0 0 8px rgba(16,185,129,0.3); }
    .tour-offer-line { margin:14px 0 0; font-size:22px; font-weight:600; line-height:1.35; letter-spacing:-0.01em; color:var(--text-primary); }
    /* The count and time sit on their own line at every width (a wrap left "tour ·" dangling at a line end); the
       separator (the record's joiner) stays for a screen reader. */
    .tour-offer-len { display:block; margin-top:2px; font-size:18px; font-weight:400; color:var(--text-dim); white-space:nowrap; }
    .tour-offer-sep { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
    .tour-offer-actions { display:flex; flex-wrap:wrap; justify-content:center; gap:12px; margin-top:20px; }
    .tour-offer-play, .tour-offer-later { display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:44px; min-width:120px; padding:0 20px; border-radius:var(--radius-sm); font:inherit; font-size:17px; cursor:pointer; }
    .tour-offer-play { border:1px solid var(--text-primary); background:var(--text-primary); color:#06080c; font-weight:700; }
    .tour-offer-play svg { flex:none; }
    .tour-offer-later { border:1px solid var(--border-strong); background:var(--bg-elevated); color:var(--text-primary); font-weight:500; }
    .tour-offer-play:focus-visible { outline:2px solid var(--text-dim); outline-offset:2px; }
    .tour-offer-later:focus-visible { outline:2px solid var(--text-primary); outline-offset:2px; }
    /* After any close of the card but Play (Bill, 2026-10-12; 10-13 on his iPhone: larger, longer, and a tap outside too): one line for about 10 s at 20px, top centre under the header, clear of the
       floating buttons at the bottom; no green; no taps (pointer-events:none). Empty, it takes no room. */
    .tour-offer-hint { position:fixed; top:calc(68px + env(safe-area-inset-top)); left:50%; transform:translateX(-50%); z-index:9500; box-sizing:border-box; width:max-content; max-width:calc(100vw - 32px); padding:10px 16px; background:var(--bg-elevated); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 8px 24px rgba(0,0,0,0.4); color:var(--text-primary); font-size:20px; font-weight:600; line-height:1.3; text-align:center; pointer-events:none; }
    .tour-offer-hint:empty { display:none; }
    /* Bill, 2026-10-13: where the menu button is the way to the tutorial, the line shows a copy of its icon beside the
       words: the button's own three bars (.top-nav-hamburger span, shared above) in its bordered box, smaller.
       Decorative (aria-hidden); the live region says the words only. */
    .tour-offer-hint-menu { display:inline-flex; flex-direction:column; justify-content:space-between; box-sizing:border-box; width:30px; height:30px; padding:8px 6px; margin-left:10px; vertical-align:middle; border:1px solid var(--border-strong); border-radius:var(--radius-sm); }
    /* ...and where the sidebar's Tutorial shows, it glows while the line shows, in the app's focus colour. */
    .sidebar-nav-item.tour-hint-glow { background:var(--accent-muted); box-shadow:0 0 0 2px var(--accent-blue), 0 0 16px var(--accent-glow); }
    /* Tutorial: the "Try it now" handoff panel (spec 2026-09-20 §8.6). app.js
       fills it from static/js/tutorial_handoff.js; bottom-left, over the
       board, once per browser (localStorage ce_tutorial_checklist). */
    /* Laptops (>799px): bottom-RIGHT (Bill, 2026-10-12: at the left it covered
       the sidebar, and every new reader lands on it now). The right edge holds
       the board's pill column, so the panel starts 12px above its top slot:
       above 1024px Ask (bottom:20px, chat.css) and Back to Top (76px), each
       44px tall, so 132px; 800-1024px adds Search under them (188px, the block
       below). --parlay-slip-h lifts it over the props parlay slip as it lifts
       the pills (§8.6: "clear of the chat pill and the feedback FAB"; the
       feedback FAB keeps the bottom-left). The <=799px blocks below restate it
       for the phone stack, unchanged. */
    .tutorial-handoff { position:fixed; left:auto; right:16px; bottom:calc(20px + 44px + 12px + 44px + 12px + var(--parlay-slip-h, 0px)); z-index:9000; width:min(340px, calc(100vw - 32px)); background:var(--bg-card); border:1px solid var(--border-default); border-top:3px solid var(--accent); border-radius:var(--radius-lg); padding:12px; box-shadow:0 12px 40px rgba(0,0,0,0.45); }
    .tutorial-handoff[hidden] { display:none; }
    .tutorial-handoff-head { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:8px; font-size:17px; }
    .tutorial-handoff-item { display:flex; align-items:center; gap:10px; width:100%; min-height:44px; padding:8px 10px; margin-bottom:6px; border:1px solid var(--border-default); border-radius:var(--radius-sm); background:none; color:var(--text-primary); font-size:17px; text-align:left; text-decoration:none; cursor:pointer; }
    .tutorial-handoff-item.is-done { border-color:var(--accent); color:var(--text-muted); }
    .tutorial-handoff-n { width:26px; height:26px; border-radius:50%; background:var(--accent-muted); color:var(--accent); display:flex; align-items:center; justify-content:center; font-family:var(--font-mono); font-size:16px; }
    .tutorial-handoff-later { min-height:44px; min-width:44px; background:none; border:1px solid var(--border-default); border-radius:var(--radius-sm); color:var(--text-muted); font-size:16px; cursor:pointer; }
    /* Screen-reader-only text: a ticked item's ", done" (tutorial_handoff.js
       render) and the panel's status node (#tutorial-handoff-status), which
       must stay in the accessibility tree while it is off screen. */
    .tutorial-handoff-sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
    /* Later lands focus on the main landmark (app.js showTutorialHandoff);
       a programmatic landing, so no ring around the whole board. */
    main[tabindex="-1"]:focus { outline:none; }
    /* Wherever the bottom nav shows (<=799px, both blocks above): full width,
       and lifted clear of the nav (65px + the safe-area inset) AND the two
       pills stacked on it bottom-right -- the search FAB (main.css, 44px) and
       the Ask FAB (chat.css, 44px above it, 12px gaps). Same arithmetic as
       chat.css's .ce-chat-fab, plus that pill's own height and gap, so the
       panel's bottom edge sits 12px above the Ask pill. A nav-plus-64px
       offset (the first draft) put the panel's bottom edge at 128px, inside
       the Ask pill's 133-177px band, so the panel covered it at 375px. */
    @media (min-width: 800px) and (max-width: 1024px) {
      .tutorial-handoff { bottom:calc(20px + 44px + 12px + 44px + 12px + 44px + 12px + var(--parlay-slip-h, 0px)); }
    }
    @media (max-width: 799px) {
      .tutorial-handoff { left:8px; right:8px; width:auto; bottom:calc(65px + env(safe-area-inset-bottom) + 12px + 44px + 12px + 44px + 12px + var(--parlay-slip-h, 0px)); }
    }
    /* A phone on its side (<=799px wide, <=500px tall): the stack above the
       nav and both pills leaves no room for a 230px panel (measured at
       667x375: its head sat 44px above the top edge). There is room BESIDE
       the pills instead, so the panel keeps its 340px at the left, clears the
       nav alone (65px + the inset + 12px, plus the parlay slip), and the
       pills keep their bottom-right corner (Task 28). */
    @media (max-width: 799px) and (max-height: 500px) {
      .tutorial-handoff { right:auto; width:min(340px, calc(100vw - 32px)); bottom:calc(65px + env(safe-area-inset-bottom) + 12px + var(--parlay-slip-h, 0px)); }
    }
    /* The 44px floors above hold at every width; repeated here because
       tests/test_slate_touch_targets.py only counts a declaration inside a
       phone block (<=768px; the same note as .board-search-fab). 639, the
       phone ceiling, not 768: tests/test_props_mobile_layout.py reads the
       FIRST <=768px block in this file as the props layout block. */
    @media (max-width: 639px) {
      .tutorial-handoff-item { min-height:44px; }
      .tutorial-handoff-later { min-height:44px; min-width:44px; }
    }
    .sidebar { background:var(--bg-sidebar); border-right:1px solid var(--border-default); padding:12px 0; position:sticky; top:56px; height:calc(100vh - 56px); overflow-y:auto; display:flex; flex-direction:column; }
    .sidebar-section { padding:12px 12px 4px; }
    .sidebar-section-title { font-size:var(--text-sm); font-weight:700; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.1em; padding:0 8px; margin-bottom:4px; }
    .sidebar-nav-item { display:flex; align-items:center; padding:8px 12px; border-radius:var(--radius-sm); cursor:pointer; font-size:var(--text-base); font-weight:600; color:var(--text-primary); transition:background var(--transition-fast),color var(--transition-fast); border-left:3px solid transparent; margin:1px 0; gap:8px; }
    .sidebar-count { margin-left:auto; }
    .sidebar-nav-item:hover { background:var(--accent-muted); color:var(--text-primary); }
    .sidebar-nav-item.active { background:var(--accent-muted); color:var(--accent-blue); font-weight:700; border-left-color:var(--accent-blue); }
    /* No flex-shrink meant this defaulted to 1: in a tight row the badge was
       compressed toward its 18px min-width while its digits needed 25px, and
       they spilled outside the pill. Measured on #ucl-count with a two-digit
       value. Data-dependent -- only shows with a long-named league AND a
       two-digit count -- so it is guarded by a test, not left to inspection. */
    .sidebar-count { font-size:var(--text-base); font-weight:600; background:var(--bg-hover); color:var(--text-muted); padding:2px 7px; border-radius:var(--radius-pill); min-width:18px; text-align:center; flex-shrink:0; }
    .sidebar-nav-item.active .sidebar-count { background:var(--accent-muted); color:var(--accent-blue); }
    /* Hierarchical category styles */
    .sidebar-category { border:none; padding:0; margin:0; }
    .sidebar-category > summary { display:flex; align-items:center; gap:6px; padding:6px 12px; font-size:var(--text-xs); /* was --text-sm: at the 14px floor BASKETBALL measured 160px in a 155px summary (2026-09-11) */ font-weight:700; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em; cursor:pointer; list-style:none; user-select:none; }
    .sidebar-category > summary::-webkit-details-marker { display:none; }
    .sidebar-category > summary::before { content:'\25BE'; font-size:var(--text-sm); width:10px; text-align:center; transition:transform 0.15s ease; color:var(--text-faint); }
    .sidebar-category:not([open]) > summary::before { transform:rotate(-90deg); }
    .sidebar-category > summary:hover { color:var(--text-primary); }
    .sidebar-category > summary .cat-icon { font-size:var(--text-base); line-height:1; }
    .sidebar-league-list { padding:0 0 2px; }
    /* Row budget: the row is 152px, of which up to 107px was chrome (28
       padding + 32 logo + 12 gaps + ~23 count + 12 padding), leaving the name
       36-50px against names needing up to 133px. Reclaiming padding, gaps and
       logo size gives back ~30px. Rows become 39px rather than 42 because the
       logo shrinks -- accepted.

       The <=1024px chip strip (main.css:~1170, `.app-layout > .sidebar
       .sidebar-league-item`) overrides this rule's padding, margin and
       border, but NOT its gap, and nothing in that media block overrides
       .league-icon or .league-icon img below -- so both the gap change
       (6px->4px) and the logo change (32px->22px) reach the chip strip too.
       Inspected, not covered by an automated test.

       Coupling with the count badge (Task 1, .sidebar-count flex-shrink:0):
       now that the badge no longer shrinks, the name's available width
       depends on the count's digit count -- measured ~75px with a one-digit
       count, ~66px with two digits. Bundesliga is the tightest real name at
       78px, so it has ~3px of slack at one digit and overruns slightly at
       two, absorbed by the flex layout without wrapping or spilling. A
       league name longer than ~75px, or a three-digit count, should prompt a
       re-measure. */
    .sidebar-league-item { display:flex; align-items:center; padding:6px 12px 6px 12px; border-radius:var(--radius-sm); cursor:pointer; font-size:var(--text-base); font-weight:600; color:var(--text-primary); transition:background var(--transition-fast),color var(--transition-fast); border-left:3px solid transparent; margin:1px 0; gap:4px; }
    .sidebar-league-item:hover { background:var(--accent-muted); color:var(--text-primary); }
    .sidebar-league-item.active { background:var(--accent-muted); color:var(--accent-blue); font-weight:700; border-left-color:var(--accent-blue); }
    .sidebar-league-item .league-icon { flex-shrink:0; line-height:0; width:22px; display:flex; align-items:center; justify-content:center; }
    .sidebar-league-item .league-icon img { height:22px; width:auto; max-width:22px; object-fit:contain; display:block; }
    .sidebar-league-item .sidebar-count { margin-left:auto; }
    .sidebar-league-item.active .sidebar-count { background:var(--accent-muted); color:var(--accent-blue); }
    .sidebar-league-item.placeholder { opacity:0.5; }
    /* MARKETS groups (Bill, 2026-10-13; app.js SIDEBAR_MARKETS). Three levels: the
       accent MARKETS header, a white 800-weight subheading per group, then the items.
       All of it is desktop only: each block is a direct div of .sidebar that is not
       .sidebar-section, which the <=1024px chip-strip rule below hides. */
    .sidebar-markets-title { padding:4px 20px 0; font-size:24px; font-weight:800; color:var(--accent); text-transform:uppercase; letter-spacing:0.08em; line-height:1.2; }
    .sidebar-market-subhead, .sidebar-section-title.sidebar-market-subhead { font-size:20px; line-height:1.3; font-weight:800; color:var(--text-primary); text-transform:none; letter-spacing:0.01em; padding:0 8px 4px; margin-bottom:4px; border-bottom:1px solid var(--border-subtle); }
    .sidebar-market-groups { padding:0 12px; }
    .sidebar-market-group { margin-top:16px; }
    .sidebar-market-item { width:100%; box-sizing:border-box; background:none; border:0; border-left:3px solid transparent; font-family:inherit; line-height:inherit; text-align:left; }
    /* The "Coming ..." line sits under the label as a muted caption that may wrap: "Coming late October" is ~173px
       as a pill against ~148 at the 16px floor (2026-10-13). */
    .sidebar-market-item.is-soon { cursor:default; color:var(--text-faint); flex-wrap:wrap; row-gap:2px; }
    .sidebar-market-item.is-soon:hover { background:none; color:var(--text-faint); }
    /* Bill, 2026-10-13: Exchanges and Arbs leave the top bar where the sidebar's MARKETS groups show (>1024px);
       2026-10-14: both leave the tablet top bar too (800-1024px), so the menu is their way in there. Hidden,
       not removed: switchTab refuses a tab whose #tab-<x> is absent, the phone/tablet menu is built from these
       elements, and the New badge and the launch telemetry read #tab-exchanges. */
    .top-nav-tabs > #tab-exchanges, .top-nav-tabs > #tab-arb, .top-nav-tabs > #tab-gaps { display:none; }
    .sidebar-soon { flex-basis:100%; font-size:var(--text-xs); font-weight:500; font-style:italic; color:var(--text-faint); }
    .main-content { padding:20px 24px; min-width:0; width:100%; box-sizing:border-box; display:flex; flex-direction:column; height:calc(100vh - 56px); overflow:hidden; }
    /* Mobile (<640px): subtract bottom-nav height + safe-area-inset from main content */
    @media (max-width: 639px) {
      .main-content { height:calc(100vh - 56px - 64px - env(safe-area-inset-bottom)); }
    }
    .main-header { margin-bottom:20px; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex-shrink:0; }
    .main-header h1 { margin:0 0 8px; font-size:1.1rem; font-weight:700; color:var(--text-primary); letter-spacing:-0.02em; }
    .how-pill { display:inline-flex; align-items:center; gap:5px; padding:10px 16px; border-radius:999px; border:1px solid var(--border-default); background:var(--bg-surface); color:var(--text-primary); font-size:var(--text-base); font-weight:600; cursor:pointer; white-space:nowrap; transition:all 0.15s; letter-spacing:0.02em; min-height:44px; }
    .how-pill:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-muted); }
    a.how-pill, a.top-nav-tab { text-decoration:none; }
    /* 2026-09-13 board search (templates/index.html .main-header-tools;
       app.js applyBoardSearch). Groups the box with the How pill at the right
       end of the header row. 16px literal on the input: iOS zooms a focused
       input under 16px and tests/test_input_zoom_guard.py cannot evaluate
       var(). 44px so the box is a real target with a mouse or a stylus. */
    .main-header-tools { display:flex; align-items:center; gap:10px; flex-shrink:0; }
    /* The freshness chip's phone copy: phones only (the <=640px block). */
    .fresh-mini-wrap { display:none; }
    /* 2026-10-10: on the Games tab the freshness row (chip, odds format, My
       book) joins this group (slate.js _placeFreshness). Too wide for one row
       on a narrow desktop, so the group may shrink and wrap, right-aligned,
       rather than push the title off the left edge. */
    .main-header-tools:has(> .slate-freshness) { flex-shrink:1; min-width:0; flex-wrap:wrap; justify-content:flex-end; }
    .board-search { display:flex; align-items:center; gap:8px; margin:0; }
    .board-search[hidden] { display:none; }
    .board-search-input { min-height:44px; width:220px; padding:8px 14px; border-radius:var(--radius-pill); border:1px solid var(--border-default); background:var(--bg-surface); color:var(--text-primary); font-family:var(--font-mono); font-size:16px; }
    .board-search-input::placeholder { color:var(--text-muted); }
    .board-search-input:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-muted); }
    /* From the keyboard: the app's own focus ring (:focus-visible above), not the 15% shadow alone (2b final review). */
    .board-search-input:focus-visible { outline:2px solid var(--accent-blue); outline-offset:2px; }
    .board-search-count { font-family:var(--font-mono); font-size:16px; color:var(--text-muted); white-space:nowrap; }
    .board-search-count:empty { display:none; }
    .board-search-clear { min-height:44px; padding:8px 14px; border-radius:var(--radius-pill); border:1px solid var(--border-default); background:var(--bg-surface); color:var(--text-primary); font-size:16px; font-weight:600; cursor:pointer; white-space:nowrap; }
    .board-search-clear:hover { border-color:var(--accent); color:var(--accent); }
    /* Zero matches: inserted at the top of #game-cards-container by
       applyBoardSearch. Dashed so it reads as a state, not a game card. */
    .board-search-empty { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; padding:14px; margin-bottom:10px; border:1px dashed var(--border-default); border-radius:var(--radius-lg); color:var(--text-primary); font-size:var(--text-base); }
    .board-search-fab { display:none; }
    .status-line { font-size:var(--text-base); color:var(--text-muted); margin-bottom:12px; padding:8px 14px; background:var(--bg-surface); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); display:flex; align-items:center; gap:8px; min-height:20px; }
    /* Above 1200px the header is one row (the 641-1200 bands below wrap it). Since the sidebar went 200 -> 260px
       (148b6a2a) the title block was squeezed to ~110px there and the count pill wrapped ("4 NFL" / "games",
       1280px, reported 2026-10-14). The title keeps its natural width; the pill is capped so a long status
       message still wraps instead of crowding the tools, which wrap control by control (021d2b6e). */
    @media (min-width:1201px) {
      .main-header > .main-header-title { flex:0 0 auto; }
      .main-header > .main-header-title > .status-line { max-width:320px; }
    }
    .status-line::before { content:''; display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--accent-green); flex-shrink:0; }
    .status-line:empty { display:none; }
    .status-line.loading::before { background:var(--accent-amber); animation:statusPulse 1.2s ease-in-out infinite; }
    @keyframes statusPulse { 0%,100%{opacity:1} 50%{opacity:0.3} }

    /* FIX 2026-08-07: the mobile header measured 228px and pushed Best Bets off
       the fold. Most of that was a SQUEEZE, not content. `.main-header` is a
       nowrap flex row; `.how-pill` is white-space:nowrap and so claims 220px of
       a 327px screen and refuses to shrink, leaving 117px for the whole text
       block. The one-sentence status line then wrapped to ~7 lines: measured
       181px at 117px wide vs 65px given full width.

       Stacking gives the status line the full width. Do NOT "fix" this by
       hiding or truncating the status text — it is unsqueezed, not removed.

       640px, not this file's usual 639px, deliberately: the width is pinned
       by tests/test_mobile_header_title.py. The trim is what fits a full game
       card above the fold on a phone (the "last 8px" note below); it once
       served the proof hero's fold, retired 2026-09-21. */
    @media (max-width: 640px) {
      .main-header { flex-direction: column; align-items: flex-start; }

      /* THE LAST 8px, measured 2026-09-06 on prod at 375x812.
         After the three cuts above and in slate.css, a full game card came to
         820px against an 812px viewport — eight pixels from the fold, having
         started the evening at 983px.
         Those eight are SPACING, not content. Above the board sat 52px of it
         around a single status sentence: .main-content's 20px padding-top,
         .main-header's 20px margin-bottom, and the status line's own 12px
         margin-bottom (which stays — it is the gap that keeps the sentence off
         the board, and main.css:424 forbids touching that line's legibility).
         Halving the outer two frees 16px and lands the card at 804px with 8px
         to spare. Measured live before it was written: 20/20 -> 820 (does not
         fit), 12/20 -> 812 (fits with ZERO headroom, one pixel of card growth
         from failing again), 12/12 -> 804.
         Mobile only. On desktop these paddings frame a 1260px column and have
         nothing to do with a fold.
         NOT a claim that every card now fits: card height varies with how many
         strips a game carries -- 583px here, 631px measured on another board an
         hour earlier. This makes the median card fit, not the tallest. */
      .main-header { margin-bottom: 12px; }
      .app-layout > .main-content { padding-top: 12px; }

      /* MEASURED 2026-09-06 at a real 375x812: 272px of chrome sits above the
         first game, and 112px of it is this header — 27px of which is an <h1>
         reading "Game Board", directly above the game board.

         Hidden ONLY because the wayfinding it provides already exists at this
         width: .bottom-nav is display:block from 0 to 799px and marks the
         active view (measured: items Games / All Bets / Trust / Account, with
         Games .active on the slate). The <h1> is the second answer to a
         question already answered on screen.

         That pairing is load-bearing, so tests/test_mobile_header_title.py
         asserts BOTH halves: hide this title and the bottom nav must still be
         shown here. If the bottom nav is ever hidden on phones, that test goes
         red and this rule must come out with it — otherwise the view loses its
         only label.

         NOT the status line. main.css:424 is explicit that its 181px was a
         SQUEEZE and must not be hidden or truncated; this removes a redundant
         heading, not information. */
      .main-header #main-title { display: none; }

      /* Bill 2026-08-07: this header pill is hidden on a phone to reclaim fold
         space. The Betting Glossary stays reachable there through the
         hamburger menu's own "Betting Glossary" entry (app.js
         toggleMobileMenu), so hiding the pill does not strand the glossary.
         Scoped to .main-header so it names the one pill it means. */
      .main-header .how-pill { display: none; }

      /* Betting Glossary: one scrolling row of level pills, not three wrapped rows
         (was ~290 of 812px at 375px, 2026-10-04 review). Scoped under .ref-modal so
         it outranks the base .ref-pills rule, which comes later in this file. */
      .ref-modal .ref-pills { flex-wrap:nowrap; overflow-x:auto; -webkit-overflow-scrolling:touch; scrollbar-width:none; }
      .ref-modal .ref-pills::-webkit-scrollbar { display:none; }

      /* Board search on phones (2026-09-13). The How pill is hidden here
         (above), so the group holds only the search row: closed by default
         (display:none, so the header's column gap does not apply and the fold
         budget above is untouched), opened by the fixed button below via
         toggleBoardSearch -> .search-open. The row is full width; the input
         takes what the count and Clear leave. 44px floors declared HERE,
         inside a mobile block, for tests/test_slate_touch_targets.py. */
      .main-header-tools { display: none; }
      .main-header-tools.search-open { display: flex; width: 100%; flex-wrap: wrap; }
      /* The freshness chip's phone copy (Bill, 2026-10-11): pinned at the
         right of the sport chips row, because at this width the header tools
         (and the chip in them) are hidden and the full row sits after the
         board. The strip keeps scrolling in what is left. */
      .app-layout > .sidebar .sidebar-section:has(> .fresh-mini-wrap) { display: flex; align-items: center; }
      .app-layout > .sidebar .sidebar-section > #sport-tabs-container { flex: 1 1 auto; min-width: 0; }
      .fresh-mini-wrap { display: inline-flex; position: relative; flex: none; margin-right: 12px; }
      .fresh-mini-wrap[hidden] { display: none; }
      /* Two classes: slate.css (loaded after this file) styles .fresh-chip. */
      .fresh-chip.fresh-mini { min-height: 44px; padding: 6px 12px; }
      .fresh-mini-wrap .fresh-pop { right: 0; width: calc(100vw - 32px); max-width: 24rem; }
      /* Measured 2026-09-13 at 375px: on one row the count ("1 of 15 games")
         and Clear squeezed the input to 116px. The form wraps: the input owns
         the first line, count + Clear take the second. Open-state only. */
      .main-header-tools.search-open .board-search { flex: 1 1 100%; flex-wrap: wrap; }
      /* The first-screen trim (Bill, 2026-09-27): the routine "Loaded 16 MLB
         games · refreshed just now" line is 86px of a phone's first screen and
         repeats the sport chip's count. Hidden while that is all it says
         (app.js wraps it in .status-loaded); loading, errors, "no games" and
         the props scanner replace the span, so they still show. */
      .main-header .status-line:has(> .status-loaded) { display: none; }
      .board-search-input { flex: 1 1 100%; width: 100%; min-height: 44px; }
      .board-search-clear { min-height: 44px; }
      /* The search button's size lives in the <=1024px block below; it is
         repeated here because tests/test_slate_touch_targets.py only counts
         a declaration inside a <=768px block. A pill since 2026-09-19: the
         width comes from its label, so the floors are minimums. */
      .board-search-fab { min-height: 44px; min-width: 44px; }
    }
    /* The fixed search button (#board-search-fab): bottom-right, thumb zone,
       0px of fold. Shown wherever the DOCUMENT scrolls -- <=1024px, where
       main.css hands scrolling back to the document and the header scrolls
       away with it; above that the header is fixed over #view-premium and the
       box is always in reach. Was <=640px only (2026-09-13); on a tablet the
       box was reachable only at the top of the page, which is the scrolling
       the search exists to avoid. A labeled pill in the .top-nav-pill style
       since 2026-09-19 -- the twin of the chat button (.ce-chat-fab in
       chat.css, which stacks 12px above it); it was a green circle with a
       bare emoji, which said nothing and rendered differently per device. */
    @media (max-width: 1024px) {
      .board-search-fab {
        display: flex; align-items: center; gap: 8px;
        position: fixed; right: 12px;
        /* level with .feedback-fab's bottom:20px; --parlay-slip-h (app.js
           _publishParlaySlipHeight) lifts it above the props parlay slip */
        bottom: calc(20px + var(--parlay-slip-h, 0px));
        min-height: 44px; padding: 8px 14px 8px 12px;
        border-radius: var(--radius-sm); border: 1px solid var(--accent);
        background: var(--bg-card); color: var(--text-primary);
        font-family: 'IBM Plex Sans', system-ui, sans-serif; font-size: 17px; font-weight: 500; line-height: 1.2; white-space: nowrap;
        box-shadow: 0 4px 16px rgba(0,0,0,0.4); z-index: 90; cursor: pointer;
        transition: background-color 120ms ease-out, color 120ms ease-out;
      }
      .board-search-fab-mark { flex: 0 0 auto; color: var(--accent); }
      .board-search-fab:hover, .board-search-fab:focus-visible, .board-search-fab[aria-expanded="true"] { background: var(--accent); color: #0d1117; }
      .board-search-fab:hover .board-search-fab-mark, .board-search-fab:focus-visible .board-search-fab-mark,
      .board-search-fab[aria-expanded="true"] .board-search-fab-mark { color: #0d1117; }
      .board-search-fab:focus-visible { outline: 3px solid var(--text-primary); outline-offset: 2px; }
      .board-search-fab[hidden] { display: none; }
    }
    /* Clear the fixed bottom nav where it is shown (<=799px: the <=639px
       block and the 640-799px band both set .bottom-nav display:block), 65px
       plus the safe-area inset. */
    @media (max-width: 799px) {
      .board-search-fab { bottom: calc(65px + env(safe-area-inset-bottom) + 12px + var(--parlay-slip-h, 0px)); }
    }
    /* Back to the top of the board (#board-top-fab, 2026-09-26): the same
       labeled pill as Search and Ask, one slot ABOVE Ask in the bottom-right
       column at every width -- Ask is bottom:20 on desktop, 76 at <=1024px
       (above Search), and above the bottom nav at <=799px (chat.css). Shown
       only once the board is scrolled past a screen (app.js
       updateBoardTopFab); hidden by the [hidden] rule otherwise. Guarded by
       tests/test_board_top_fab.py. */
    .board-top-fab {
      display: flex; align-items: center; gap: 8px;
      position: fixed; right: 20px;
      bottom: calc(20px + 44px + 12px + var(--parlay-slip-h, 0px));
      min-height: 44px; min-width: 44px; padding: 8px 14px 8px 12px;
      border-radius: var(--radius-sm); border: 1px solid var(--accent);
      background: var(--bg-card); color: var(--text-primary);
      font-family: 'IBM Plex Sans', system-ui, sans-serif; font-size: 17px; font-weight: 500; line-height: 1.2; white-space: nowrap;
      box-shadow: 0 4px 16px rgba(0,0,0,0.4); z-index: 90; cursor: pointer;
      transition: background-color 120ms ease-out, color 120ms ease-out;
    }
    .board-top-fab-mark { flex: 0 0 auto; color: var(--accent); }
    .board-top-fab:hover, .board-top-fab:focus-visible { background: var(--accent); color: #0d1117; }
    .board-top-fab:hover .board-top-fab-mark, .board-top-fab:focus-visible .board-top-fab-mark { color: #0d1117; }
    .board-top-fab:focus-visible { outline: 3px solid var(--text-primary); outline-offset: 2px; }
    .board-top-fab[hidden] { display: none; }
    @media (max-width: 1024px) {
      .board-top-fab { right: 12px; bottom: calc(20px + 44px + 12px + 44px + 12px + var(--parlay-slip-h, 0px)); }
    }
    /* Phones (<=799px, where the bottom nav shows), 2026-09-27: a tester's
       Galaxy S25 showed the last Best Bets card's chips under a 168px column
       of three pills (Top, Ask, Search) -- the right edge is exactly where the
       cards put their EV and price chips. Top is a rare action, so it leaves
       that column: a 44px icon-only circle in the bottom-LEFT corner, empty
       here because .feedback-fab is hidden wherever the bottom nav shows. The
       right column is back to Ask + Search (124px). The accessible name stays
       "Back to Top" (aria-label); only the visible word goes. */
    @media (max-width: 799px) {
      .board-top-fab {
        left: 12px; right: auto; justify-content: center;
        bottom: calc(65px + env(safe-area-inset-bottom) + 12px + var(--parlay-slip-h, 0px));
        width: 44px; height: 44px; padding: 0; border-radius: 50%;
      }
      .board-top-fab-label { display: none; }
      /* Scroll room under the board, so its last content can clear the right
         column (Ask 44 + Search 44 + three 12px gaps) instead of ending under
         it. main.css reserves the nav's own 65px only at <=639px, so the
         640-799px band adds it here. */
      #view-premium { padding-bottom: calc(12px + 44px + 12px + 44px + 12px); }
    }
    @media (min-width: 640px) and (max-width: 799px) {
      #view-premium { padding-bottom: calc(65px + env(safe-area-inset-bottom) + 12px + 44px + 12px + 44px + 12px); }
    }
    /* Phones: the floating buttons tuck away while the reader scrolls DOWN
       (app.js _updateFabTuck sets html.fabs-tucked; static/js/fab_tuck.js
       decides) -- Bill, 2026-09-27, after the Galaxy S25 report above.
       `html` in each selector out-ranks .ce-chat-fab's own rule in chat.css,
       which loads after this file. html.ce-sheet-open (static/js/card_sheet.js)
       tucks them the same way while the phone game sheet is open (2026-10-05).
       visibility, not display, so they can fade
       and cannot be tapped or tabbed to while hidden. The colour transitions
       are restated because this `transition` replaces theirs. */
    @media (max-width: 799px) {
      html .board-top-fab, html .ce-chat-fab, html .board-search-fab {
        transition: opacity 200ms ease-out, transform 200ms ease-out, visibility 200ms,
                    background-color 120ms ease-out, color 120ms ease-out;
      }
      html.fabs-tucked .board-top-fab, html.fabs-tucked .ce-chat-fab, html.fabs-tucked .board-search-fab,
      html.ce-sheet-open .board-top-fab, html.ce-sheet-open .ce-chat-fab, html.ce-sheet-open .board-search-fab {
        opacity: 0; visibility: hidden; transform: translateY(16px); pointer-events: none;
      }
    }
    @media (max-width: 799px) and (prefers-reduced-motion: reduce) {
      html .board-top-fab, html .ce-chat-fab, html .board-search-fab { transition: opacity 120ms linear, visibility 120ms; }
      html.fabs-tucked .board-top-fab, html.fabs-tucked .ce-chat-fab, html.fabs-tucked .board-search-fab,
      html.ce-sheet-open .board-top-fab, html.ce-sheet-open .ce-chat-fab, html.ce-sheet-open .board-search-fab { transform: none; }
    }
    /* 641-1024px (2026-09-13): between the phone stack above and the desktop
       row, the header is still ONE nowrap row, and the search box + How pill
       (487px) squeezed the title block to 153px at 700px -- the status line
       wrapped to six lines (measured: header 177px). Let the tools drop to a
       second row, right-aligned, whenever the title block cannot keep 320px;
       above ~1150px everything fits on one row even with a query active; the
       band runs to 1200px because at 1025-1150px the sidebar and padding
       return and the tools group (up to ~700px with a query active) squeezed
       the title again. Bounded below at 641px so
       `flex: 1 1 320px` can never become a HEIGHT in the phone block's column
       direction. Guarded by tests/test_board_search_phone.py. */
    @media (min-width: 641px) and (max-width: 1200px) {
      .main-header { flex-wrap: wrap; }
      .main-header > div:first-child { flex: 1 1 320px; min-width: 0; }
      .main-header-tools { margin-left: auto; }
    }
    /* Tablet and narrow desktop, 641-1200px (Bill, 2026-10-11: option B from
       live mockups at 768px, then extended to 1200 from mockups at 1025-1200,
       where the sidebar is back). The header was three rows -- "Game Board",
       the game count, then the tools -- 155px on a normal day; and a red
       freshness chip ("MLB lines 34 min old: check the book") pushed Learn
       onto a fourth, 209px. Now the title and count share a line, Learn is
       its icon (the link keeps the name "Learn"; the menu, and from 1025px
       the top nav, list it), and search is 170px wide. Measured: 768 and
       1025 120px in every state; 1100 and 1200 58px (one row) on a normal
       day, 120 in red. Above 1200 unchanged. */
    @media (min-width: 641px) and (max-width: 1200px) {
      .main-header > .main-header-title { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
      .main-header h1 { margin: 0; }
      .main-header .how-pill { padding: 10px 12px; }
      .main-header .how-pill-label { display: none; }
      .board-search-input { width: 170px; }
    }
    /* Each view tab fills remaining height and scrolls internally */
    #view-premium, #view-exchanges, #view-analytics { flex:1 1 0; min-height:0; overflow-y:auto; width:100%; box-sizing:border-box; }
    /* view-props uses an inner scroll container so the header row stays pinned */
    #view-props.view-props-flex { flex:1 1 0; min-height:0; overflow:hidden; width:100%; box-sizing:border-box; display:flex; }
    #view-analytics { padding:16px; }
    .game-cards-container { display:flex; flex-direction:column; gap:5px; max-width:none; }
    /* FIX 2026-08-06: the rule above outranks the UA `[hidden]{display:none}`,
       so Slate._toggleVisibility setting `.hidden = true` for the drilldown
       takeover changed the ATTRIBUTE and nothing else — measured live: hidden
       was true, computed display stayed `flex`, and all 15 cards were still
       4,625px tall on top of the takeover. A class selector beats a bare
       attribute selector; this one is 0-2-0 and wins without !important.
       Only mattered once the board moved ABOVE the slate. */
    .game-cards-container[hidden] { display:none; }
    /* Board search (2026-09-13) hides cards and their sport chips with the
       `hidden` attribute; an author display on either would silently defeat
       it, exactly as .game-cards-container above once found. */
    .ob-card[hidden], .ob-sport-chip[hidden] { display:none; }
    /* 2026-08-05: the board is visible again. `#game-cards-container { display:
       none }` (2026-07-18) hid the legacy Game Board because it re-rendered the
       full slate a 2nd time (~35,500px) below Today's Slate. That loop is gone;
       renderGameCards() now emits the per-market odds box (.ob-card, styled at
       the foot of this file) for the visible sport only. */
    .game-card { background:var(--bg-card); border:1px solid var(--border-default); border-top:2px solid var(--accent-muted); border-radius:var(--radius-lg); box-shadow:var(--shadow-card); transition:box-shadow var(--transition-normal),border-color var(--transition-normal); overflow:hidden; }
    .game-card:hover { box-shadow:var(--shadow-hover); border-color:var(--border-strong); border-top-color:var(--accent); }
    .game-card-summary { cursor:pointer; user-select:none; }
    .game-header-left { display:flex; align-items:center; gap:8px; font-size:var(--text-base); }
    .fallback-badge { display:inline-block; font-size:var(--text-base); padding:1px 5px; background:var(--accent-amber-bg); color:var(--accent-amber); border-radius:4px; font-weight:600; }
    @keyframes livePulse { 0%{box-shadow:0 0 0 0 rgba(248,113,113,0.6)} 50%{box-shadow:0 0 0 6px rgba(248,113,113,0)} 100%{box-shadow:0 0 0 0 rgba(248,113,113,0)} }
    .game-card-live { border-right:3px solid var(--ev-neg); }
    .game-card-final { opacity:0.7; }
    .game-card-bet { border-left:3px solid var(--accent-green); }
    .game-card-lock { border-left:3px solid #3b82f6; }
    .game-card-lean { border-left:3px solid var(--accent-amber); }
    .sizing-badge { display:inline-flex; align-items:center; gap:3px; padding:2px 8px; border-radius:var(--radius-pill); font-size:var(--text-sm); font-weight:700; text-transform:uppercase; letter-spacing:0.04em; }
    .card-col-headers { display:grid; grid-template-columns:1fr repeat(3, 80px); column-gap:4px; padding:0 0 2px; }
    .card-col-headers span { font-size:var(--text-sm); font-weight:700; color:var(--text-secondary); text-align:center; text-transform:uppercase; letter-spacing:0.06em; }
    .game-card-detail { display:none; padding:0 16px 16px; background:var(--bg-detail); border-top:1px solid var(--border-subtle); }
    .game-card.expanded .game-card-detail { display:block; animation:detailSlide 0.2s ease; }
    @keyframes detailSlide { from{opacity:0} to{opacity:1} }
    /* Game Board Row styles (new row-based board) */
    .detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:12px; }
    .detail-section { background:var(--bg-card); border:1px solid var(--border-default); border-radius:var(--radius-md); padding:14px 16px; }
    .detail-section-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; padding-bottom:8px; border-bottom:1px solid var(--border-subtle); }
    .detail-section-title { font-size:var(--text-sm); font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em; }
    .detail-row { display:flex; justify-content:space-between; align-items:baseline; padding:3px 0; font-size:var(--text-base); }
    .detail-row .label { color:var(--text-muted); font-weight:500; }
    .detail-row .value { color:var(--text-primary); font-weight:600; font-family:var(--font-mono); }
    .modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:9999; display:flex; align-items:center; justify-content:center; }
    .modal-backdrop.hidden { display:none; }
    .modal { background:var(--bg-card); color:var(--text-secondary); width:min(900px,92vw); max-height:85vh; overflow-y:auto; border-radius:var(--radius-lg); border:1px solid var(--border-default); border-top:3px solid var(--accent-blue); padding:14px 16px; box-shadow:0 20px 60px rgba(0,0,0,0.15); animation:modalFadeIn 0.25s ease; }
    @keyframes modalFadeIn { from{opacity:0;transform:translateY(12px) scale(0.98)} to{opacity:1;transform:translateY(0) scale(1)} }
    .modal h2 { margin:4px 0 10px; font-size:1.1rem; color:var(--text-primary); }
    .modal .row { display:grid; grid-template-columns:140px 1fr; gap:10px; padding:8px 0; border-bottom:1px solid var(--border-subtle); }
    .modal .k { color:var(--accent-blue); font-weight:600; font-size:var(--text-base); }
    .modal .v { color:var(--text-secondary); font-size:var(--text-base); }
    .modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:10px; }
    .close-btn { background:none; border:1px solid transparent; color:var(--text-faint); font-size:22px; font-weight:bold; cursor:pointer; line-height:1; padding:4px 8px; border-radius:var(--radius-sm); transition:background var(--transition-normal); }
    .close-btn:hover { color:var(--text-primary); background:var(--bg-hover); border-color:var(--border-default); }
    /* Betting Glossary modal (.ref-* class names are historical) */
    .ref-modal { background:var(--bg-card); color:var(--text-secondary); width:min(720px,94vw); max-height:88vh; display:flex; flex-direction:column; border-radius:var(--radius-lg); border:1px solid var(--border-default); border-top:3px solid var(--accent-blue); box-shadow:0 20px 60px rgba(0,0,0,0.15); animation:modalFadeIn 0.25s ease; overflow:hidden; }
    .ref-header { display:flex; align-items:center; justify-content:space-between; padding:14px 18px 10px; flex-shrink:0; }
    .ref-header h2 { margin:0; font-size:1.125rem; color:var(--text-primary); font-weight:700; }
    .ref-search-wrap { padding:0 18px 10px; flex-shrink:0; position:relative; }
    .ref-search-input { width:100%; padding:9px 14px 9px 36px; border:1px solid var(--border-default); border-radius:var(--radius-md); font-size:var(--text-base); background:var(--bg-page); color:var(--text-primary); outline:none; transition:border-color 0.2s; box-sizing:border-box; }
    .ref-search-input:focus { border-color:var(--accent-blue); box-shadow:0 0 0 3px var(--accent-muted); }
    .ref-search-icon { position:absolute; left:30px; top:50%; transform:translateY(-50%); color:var(--text-faint); font-size:var(--text-base); pointer-events:none; }
    .ref-search-count { position:absolute; right:30px; top:50%; transform:translateY(-50%); font-size:var(--text-xs); color:var(--text-faint); }
    .ref-pills { display:flex; gap:6px; padding:0 18px 12px; flex-shrink:0; flex-wrap:wrap; }
    .ref-pill { padding:4px 12px; border-radius:var(--radius-pill); font-size:var(--text-sm); font-weight:600; border:1px solid var(--border-default); background:var(--bg-surface); color:var(--text-muted); cursor:pointer; transition:all 0.15s; white-space:nowrap; }
    .ref-pill:hover { border-color:var(--accent-blue); color:var(--accent-blue); }
    .ref-pill.active { background:var(--accent-blue); color:#0d1117; border-color:var(--accent-blue); }
    .ref-body { flex:1; overflow-y:auto; padding:0 18px 14px; }
    .ref-cat-header { font-size:var(--text-sm); font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em; margin:16px 0 8px; padding-bottom:4px; border-bottom:1px solid var(--border-subtle); }
    .ref-cat-header:first-child { margin-top:4px; }
    .ref-term { padding:8px 12px; margin-bottom:6px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); background:var(--bg-page); transition:border-color 0.15s; }
    .ref-term:hover { border-color:var(--border-default); }
    .ref-term-name { font-weight:700; font-size:var(--text-base); color:var(--text-primary); }
    .ref-term-aka { font-size:var(--text-sm); color:var(--text-faint); margin-left:6px; font-weight:500; }
    .ref-term-def { font-size:var(--text-sm); color:var(--text-secondary); line-height:1.55; margin-top:3px; }
    .ref-term-sport { display:inline-block; font-size:var(--text-xs); padding:1px 6px; border-radius:var(--radius-pill); background:var(--accent-muted); color:var(--accent-blue); font-weight:600; margin-left:6px; }
    .ref-highlight { background:var(--accent-amber-bg); color:var(--accent-amber); border-radius:2px; padding:0 1px; }
    .ref-empty { text-align:center; padding:40px 20px; color:var(--text-faint); font-size:var(--text-base); }
    .ref-empty span { display:block; font-size:1.5rem; margin-bottom:8px; }
    .ref-more { margin-top:6px; }
    .ref-more summary { cursor:pointer; color:var(--accent-blue); font-size:var(--text-sm); font-weight:600; min-height:44px; display:flex; align-items:center; }
    .ref-term-math { font-family:ui-monospace,monospace; font-size:var(--text-sm); color:var(--text-secondary); line-height:1.55; margin-top:4px; white-space:pre-wrap; }
    .pill { border-radius:var(--radius-pill); padding:2px 8px; font-size:var(--text-base); font-weight:600; }
    .badge { display:inline-block; font-size:var(--text-base); padding:2px 5px; border-radius:var(--radius-pill); border:1px solid var(--border-default); margin-left:4px; color:var(--text-muted); white-space:nowrap; }
    .badge-ok { border-color:var(--accent-green); color:var(--accent-green); }
    .badge-warn { border-color:var(--accent-amber); color:var(--accent-amber); }
    .badge-bad { border-color:var(--accent-red); color:var(--accent-red); }
    .badges { display:inline-flex; gap:6px; flex-wrap:wrap; align-items:center; }
    table { width:100%; border-collapse:collapse; font-size:var(--text-base); table-layout:auto; }
    th,td { padding:8px 10px; border-bottom:1px solid var(--border-subtle); vertical-align:top; }
    th { text-align:left; background:var(--bg-surface); font-size:var(--text-base); font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em; white-space:nowrap; }
    td { color:var(--text-secondary); }
    tbody tr:hover { background:var(--bg-hover); }
    .sub { font-size:var(--text-base); color:var(--text-muted); }
    .ev-pos { color:var(--accent-green); font-weight:600; }
    .ev-neg { color:var(--accent-amber); font-weight:500; }
    details { margin-top:4px; }
    details summary { cursor:pointer; font-size:var(--text-base); color:var(--accent-blue); }
    pre { font-size:var(--text-sm); background:var(--bg-page); border-radius:var(--radius-sm); padding:6px; border:1px solid var(--border-default); overflow-x:auto; white-space:pre-wrap; word-break:break-word; }
    .q { display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; border-radius:var(--radius-pill); border:1px solid var(--border-default); color:var(--text-muted); font-size:16px; margin-left:6px; cursor:help; }
    .tip { position:relative; display:inline-block; vertical-align:middle; }
    .tip:hover::after { content:attr(data-tip); position:absolute; left:0; top:22px; min-width:220px; max-width:360px; background:var(--bg-surface); border:1px solid var(--border-default); padding:10px; border-radius:var(--radius-md); color:var(--text-secondary); font-size:var(--text-base); line-height:1.25; z-index:50; box-shadow:0 10px 30px rgba(0,0,0,0.12); white-space:normal; }
    .muted { color:var(--text-muted); }
    .card { border-radius:var(--radius-lg); border:1px solid var(--border-default); padding:12px 14px; background:var(--bg-card); margin-bottom:12px; }
    .card h3 { margin:0 0 4px; font-size:var(--text-lg); color:var(--text-primary); }
    .card p { margin:6px 0; font-size:var(--text-base); color:var(--text-muted); line-height:1.35; }
    .tab-btn { background:var(--bg-page); border:1px solid var(--border-default); color:var(--text-secondary); padding:6px 16px; border-radius:var(--radius-sm); cursor:pointer; font-size:var(--text-base); font-weight:600; }
    .tab-btn:hover { background:var(--bg-hover); color:var(--text-primary); }
    .props-sport-pill { background:none; border:none; border-bottom:2px solid transparent; color:var(--text-muted); padding:var(--space-xs) var(--space-md); cursor:pointer; font-size:var(--text-base); font-weight:600; transition:color var(--transition-fast),border-color var(--transition-fast); }
    .props-sport-pill:hover { color:var(--text-secondary); }
    .props-sport-pill.active { color:var(--accent); border-bottom-color:var(--accent); }
    /* .hidden keeps !important deliberately: it is the global hide utility
       toggled by 15+ JS sites (modals, backdrops, overlays, view switching)
       and must beat any later display rule. Competing view containers use
       :not(.hidden) on their display rule instead of counter-!importants
       (FIX 2026-07-12, Batch 2 — ended the cc-view-container arms race). */
    .hidden { display:none !important; }
    /* FIX 2026-04-06: scoped flex layout for #view-props that only applies
       when the element is visible (2026-07-12: now via :not(.hidden);
       flex-direction moved here from an inline style on the element). */
    .view-props-flex:not(.hidden) { display:flex; flex-direction:column; }

    /* Game Strip — compact scrollable game list */
    .league-logo { height:28px; }
    .game-strip-badge { display:inline-flex; padding:1px 6px; border-radius:var(--radius-pill); font-size:var(--text-sm); font-weight:700; text-transform:uppercase; letter-spacing:0.03em; }
    .game-strip-badge-bet { background:var(--accent-green-bg); color:var(--accent-green); }
    .game-strip-badge-lock { background:rgba(59,130,246,0.12); color:#3b82f6; }
    .game-strip-badge-lean { background:var(--accent-amber-bg); color:var(--accent-amber); }
    .game-strip-badge-pass { background:var(--bg-surface); color:var(--text-faint); }

    /* Props game cards */
    .props-game-card { background:var(--bg-card); border:1px solid var(--border-default); border-radius:var(--radius-lg); overflow:hidden; }
    .props-game-header { display:flex; align-items:center; justify-content:space-between; padding:10px 16px; background:var(--bg-surface); border-bottom:1px solid var(--border-subtle); cursor:pointer; user-select:none; }
    .props-game-header:hover { background:var(--bg-hover); }
    .props-game-matchup { font-size:var(--text-base); font-weight:700; color:var(--text-primary); }
    .props-game-count { font-size:var(--text-sm); color:var(--text-muted); }
    .props-game-body { padding:0; }
    /* min-height 2026-08-12: every row carries an odds cell, and the odds cell
       is a control (app.js attaches toggleParlayLeg to it on unlocked rows).
       Its 44px target needs a 44px row or it spills ~3px into the neighbour
       above and below and two adjacent prices overlap — the same arithmetic
       recorded for the SGP checkbox below. Rows were 40.9px. */
    /* The LINE track (the second) is 110px: the side is spelled out since 2026-09-24,
       and "Under 29.5" in the cell's mono 600 at --text-base is ~108px. At 78px, sized
       for "O 1.5", every line wrapped to two rows (tests/test_props_row_mobile_scroll.py). */
    .props-player-row { display:grid; grid-template-columns:1fr 110px 76px 62px 76px 56px; gap:0; align-items:center; padding:6px 16px; min-height:44px; font-size:var(--text-base); border-bottom:1px solid var(--border-subtle); transition:background 0.12s; }
    .props-player-row:last-child { border-bottom:none; }
    .props-player-row:hover { background:rgba(255,255,255,0.02); }
    .props-player-name { font-weight:700; font-size:var(--text-base); color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    .props-player-factors { font-size:var(--text-base); color:var(--text-muted); margin-left:8px; }
    .props-line { font-family:var(--font-mono); font-weight:600; font-size:var(--text-base); color:var(--text-primary); text-align:right; }
    .props-odds-cell { text-align:right; font-family:var(--font-mono); font-size:var(--text-lg); color:var(--text-primary); position:relative; }
    /* FIX 2026-08-12 — WCAG 2.5.5. The price is a CONTROL: app.js:8214 gives
       every unlocked row's odds cell `onclick=... toggleParlayLeg(...)`, so a
       tap here commits a leg. Measured 76 x 27.9 at 390px in both normal and
       SGP mode, with adjacent odds cells 13px apart — a mis-tap adds the wrong
       player's leg, and nothing tells the user.

       ::after rather than sizing the cell, for the same reason as
       .props-sgp-checkbox: `.props-player-row` is `align-items:center`, so the
       cell does not stretch to the row, and growing the cell to 44px would
       push every row 40.9 -> 56px on a board that shows dozens at a time.
       A click on a pseudo-element is attributed to its originating element,
       so the inline onclick needs no change.

       Full cell width (76px, already over the minimum) and 44px tall, which
       exactly fills the row's new 44px min-height — so vertically adjacent
       targets touch without overlapping. Locked rows are unaffected: they set
       pointer-events:none below.
       Guarded by tests/test_props_row_mobile_scroll.py. */
    .props-odds-cell::after {
      content:''; position:absolute; left:0; right:0; top:50%;
      height:44px; transform:translateY(-50%);
    }
    /* Phase 9 (C2) 2026-04-08: line shopping badge */
    .props-shop-badge { display:inline-block; margin-left:6px; padding:1px 5px; border-radius:6px; background:rgba(59,130,246,0.10); color:var(--accent-blue, #3b82f6); font-family:var(--font-sans); font-size:var(--text-xs); font-weight:600; vertical-align:middle; cursor:help; white-space:nowrap; }
    .props-shop-badge:hover { background:rgba(59,130,246,0.18); }
    /* Phase 9 (B2) 2026-04-08: injury status badge */
    .props-injury-badge { display:inline-block; margin-right:6px; padding:1px 5px; border-radius:5px; font-family:var(--font-sans); font-size:var(--text-xs); font-weight:700; vertical-align:middle; cursor:help; }
    .props-injury-badge-quest { background:rgba(245,158,11,0.15); color:#f59e0b; border:1px solid rgba(245,158,11,0.30); }
    .props-injury-badge-doubt { background:rgba(239,68,68,0.15); color:#f87171; border:1px solid rgba(239,68,68,0.30); }
    /* Phase 9 (C3) 2026-04-08: live-lock pill and row state */
    .props-row-locked { opacity:0.68; }
    .props-row-locked .props-odds-cell { pointer-events:none; }
    .props-row-locked .props-shop-badge { display:none; }
    .props-lock-badge { display:inline-block; margin-left:6px; padding:1px 6px; border-radius:5px; font-family:var(--font-sans); font-size:var(--text-xs); font-weight:700; letter-spacing:0.03em; vertical-align:middle; }
    .props-lock-live { background:rgba(239,68,68,0.20); color:#f87171; border:1px solid rgba(239,68,68,0.35); }
    .props-lock-final { background:rgba(107,114,128,0.25); color:var(--text-faint); border:1px solid rgba(107,114,128,0.40); }
    .props-proj { text-align:right; font-family:var(--font-mono); color:var(--text-primary); font-size:var(--text-lg); }
    .props-ev { text-align:right; font-family:var(--font-mono); font-weight:700; font-size:var(--text-lg); }
    /* Your-book EV (2026-09-13): the chip / link every props row carries. Never
       green: green is a recommendation elsewhere; this is arithmetic at the
       user's price. Loaded here (not slate.css) because the Props tab and the
       drawer both render it and main.css reaches both. */
    .ce-yp-chip { display:inline-block; font-family:var(--font-mono); font-size:16px; font-weight:600; color:var(--text-secondary); white-space:nowrap; }
    .ce-yp-chip.neg { color:var(--accent-red); }
    .ce-yp-link { display:inline-flex; align-items:center; min-height:24px; font-size:16px; font-weight:500; color:var(--text-secondary); text-decoration:underline; white-space:nowrap; }
    .ce-yp-under { display:block; margin-top:2px; font-weight:500; text-align:right; }
    /* FIX 2026-09-13: the props-tab EV cell is only 76px and the chip's text
       ("you -12.3%") is 16px IBM Plex Mono with no wrap room — nowrap (the
       drawer card's own rule, correct there) spilled it into the Sizing
       column's badge next door. Let it wrap at its own width inside the cell:
       "you" over "-12.3%" is 2 short lines, each comfortably under 76px. */
    .props-ev .ce-yp-chip, .props-ev .ce-yp-link { font-size:16px; white-space:normal; }
    .props-ev .ce-yp-under { white-space:normal; }
    .props-sizing-cell { text-align:center; }
    .props-col-header { display:grid; grid-template-columns:1fr 110px 76px 62px 76px 56px; gap:0; padding:5px 16px; font-size:var(--text-sm); font-weight:700; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em; border-bottom:1px solid var(--border-default); }
    .props-col-header > span { text-align:right; }
    .props-col-header > span:first-child { text-align:left; }
    .props-type-accordion { border-bottom:1px solid var(--border-subtle); }
    .props-type-accordion:last-child { border-bottom:none; }
    .props-type-toggle { display:flex; align-items:center; justify-content:space-between; padding:10px 16px; cursor:pointer; user-select:none; background:var(--bg-surface); transition:background 0.15s; }
    .props-type-toggle:hover { background:var(--bg-hover); }
    .props-type-toggle-label { font-size:1.125rem; font-weight:700; color:var(--text-primary); letter-spacing:0.02em; }
    .props-type-toggle-chevron { font-size:var(--text-base); color:var(--text-muted); transition:transform 0.2s; }
    .props-type-toggle.open .props-type-toggle-chevron { transform:rotate(90deg); }
    .props-type-body { }
    .props-team-accordion { border-bottom:1px solid var(--border-subtle); }
    .props-team-accordion:last-child { border-bottom:none; }
    .props-team-toggle { display:flex; align-items:center; justify-content:space-between; padding:7px 16px 7px 28px; cursor:pointer; user-select:none; background:var(--bg-card); transition:background 0.15s; }
    .props-team-toggle:hover { background:var(--bg-hover); }
    .props-team-toggle-label { font-size:var(--text-base); font-weight:600; color:var(--text-secondary); }
    .props-team-toggle-chevron { font-size:var(--text-sm); color:var(--text-muted); transition:transform 0.2s; }
    .props-team-toggle.open .props-team-toggle-chevron { transform:rotate(90deg); }

    /* ============================================================
       Props category navigation — Phase 1 restructure 2026-04-07
       Left rail on desktop, horizontal pills on mobile, matches
       DraftKings / FanDuel props taxonomy (Scoring / Batter / etc).
       ============================================================ */
    .props-main-layout { display:flex; gap:var(--space-md); align-items:flex-start; }
    .props-content { flex:1; min-width:0; display:flex; flex-direction:column; gap:var(--space-sm); }

    /* Left rail (desktop) */
    .props-category-rail { flex:0 0 180px; position:sticky; top:var(--space-md); background:var(--bg-card); border:1px solid var(--border-default); border-radius:var(--radius-lg); overflow:hidden; }
    .props-category-rail-header { padding:10px 14px; font-size:var(--text-sm); font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; border-bottom:1px solid var(--border-subtle); background:var(--bg-surface); }
    .props-category-rail-list { display:flex; flex-direction:column; padding:4px 0; }
    .props-category-item { display:flex; align-items:center; justify-content:space-between; padding:9px 14px; cursor:pointer; border-left:3px solid transparent; color:var(--text-secondary); font-size:var(--text-base); font-weight:600; transition:background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); user-select:none; }
    .props-category-item:hover { background:var(--bg-hover); color:var(--text-primary); }
    .props-category-item.active { background:var(--bg-raised); color:var(--accent); border-left-color:var(--accent); }
    .props-category-item.is-empty { opacity:0.45; cursor:default; }
    .props-category-item.is-empty:hover { background:transparent; color:var(--text-secondary); }
    .props-category-item-count { font-size:var(--text-sm); color:var(--text-muted); font-weight:500; font-family:var(--font-mono); }
    .props-category-item.active .props-category-item-count { color:var(--accent); }

    /* Horizontal pills (mobile) */
    .props-category-pills { display:none; gap:6px; overflow-x:auto; padding:6px 0 8px; scrollbar-width:none; -ms-overflow-style:none; }
    .props-category-pills::-webkit-scrollbar { display:none; }
    .props-category-pill { flex:0 0 auto; padding:6px 14px; border:1px solid var(--border-default); border-radius:var(--radius-pill); background:var(--bg-surface); color:var(--text-secondary); font-size:var(--text-sm); font-weight:600; cursor:pointer; white-space:nowrap; transition:background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); }
    .props-category-pill:hover { background:var(--bg-hover); color:var(--text-primary); }
    .props-category-pill.active { background:var(--accent); color:var(--bg-base); border-color:var(--accent); }
    .props-category-pill.is-empty { opacity:0.45; cursor:default; }
    .props-category-pill.is-empty:hover { background:var(--bg-surface); color:var(--text-secondary); }
    .props-category-pill-count { font-size:var(--text-xs); opacity:0.8; margin-left:4px; font-family:var(--font-mono); }

    /* Responsive breakpoint — flip rail to pills */
    @media (max-width: 768px) {
      /* FIX 2026-07-25: align-items:flex-start (line ~643) SURVIVED this
         direction flip. In a column flex the cross axis is horizontal, so
         flex-start stopped .props-content stretching and sized it to
         MAX-CONTENT — the sum of every category pill. Measured on prod at
         390px: .props-main-layout 295px but .props-content 911px, giving
         576px of horizontal PAGE scroll, and .props-category-pills'
         overflow-x:auto (line ~659) was inert, because a scroll container
         cannot scroll while its parent is sized to its own max-content.
         With stretch: overflow 576 -> 0, content 911 -> 310, and the pill
         strip's scrollWidth > clientWidth flips to TRUE (it finally
         scrolls). Guarded by tests/test_props_mobile_layout.py. */
      .props-main-layout { flex-direction:column; align-items:stretch; }
      .props-category-rail { display:none; }
      .props-category-pills { display:flex; }
    }

    /* ============================================================
       Phase 9 (B1) 2026-04-08: Same-Game Parlay (SGP) builder
       ============================================================ */
    .props-sgp-toggle { margin-left:auto; display:inline-flex; align-items:center; padding:6px 14px; border:1px solid var(--accent); border-radius:var(--radius-pill); background:transparent; color:var(--accent); font-size:var(--text-sm); font-weight:600; cursor:pointer; transition:background var(--transition-fast), color var(--transition-fast); }
    .props-sgp-toggle:hover { background:var(--accent); color:var(--bg-base); }

    /* Selection mode: each prop row gets a checkbox in the leftmost cell */
    /* Header takes the same 7-track template as the rows in SGP mode — see the
       FIX note at the colHeader in app.js. */
    .props-player-row.props-sgp-mode,
    .props-col-header.props-sgp-mode { grid-template-columns:44px 1fr 110px 76px 62px 76px 56px; }
    .props-sgp-checkbox { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border:1.5px solid var(--border-default); border-radius:4px; cursor:pointer; transition:background var(--transition-fast), border-color var(--transition-fast); color:transparent; position:relative; }
    .props-sgp-checkbox:hover { border-color:var(--accent); }
    .props-sgp-checkbox.checked { background:var(--accent); border-color:var(--accent); color:var(--bg-base); }
    /* FIX 2026-07-25 — WCAG 2.5.5 Target Size (44x44). The visible box stays
       20x20 by design; ::after carries the real pointer target. A click on a
       pseudo-element is attributed to its originating element, so the inline
       onclick in app.js needs no change.

       The two sizes that keep this target from OVERLAPPING a neighbour (an
       overlap would trade one 2.5.x problem for another): the SGP track went
       28px -> 44px so the target stays inside its own column, and the row
       gets min-height:44px so vertically adjacent checkboxes can't collide —
       rows were ~40px, i.e. 4px short, which is exactly how much a 44px
       target would have spilled into the row above and below.

       Applies at every width, not just phones: 2.5.5 covers mouse pointers
       too, and precision pointing is the exact thing this product must not
       assume. Guarded by tests/test_props_row_mobile_scroll.py. */
    .props-sgp-checkbox::after {
      content:''; position:absolute; left:50%; top:50%;
      width:44px; height:44px; transform:translate(-50%,-50%);
    }
    .props-player-row.props-sgp-mode { min-height:44px; }

    /* FIX 2026-07-25: the three LATENT props grids. `.props-col-header` and
       `.props-player-row` shared `1fr 78px 76px 62px 76px 56px` (LINE 110px since 2026-09-25), and
       `.props-player-row.props-sgp-mode` prepends a 44px checkbox column.
       The fixed tracks alone are 348px (392px in SGP mode) plus 32px of
       padding — inside a ~295px .props-content at a 390px viewport. Same bug
       class as the analytics grids and .slate-prop-row, but it renders ZERO
       rows out of NBA season, so it was invisible to the live sweep and would
       have bitten in ~Oct.

       Scroll rather than collapse to a card (Bill's analytics call, and the
       only option that survives this markup): `.props-col-header` is a real
       header whose spans align to the row tracks, and a player's extra lines
       render with an EMPTY name cell (app.js ~8651) so they read as a group.
       A card stack destroys both. Making the team body the scroller keeps
       header and rows in one aligned, swipeable table.

       Scoped to 639px to match .slate-row / .slate-prop-row. At 640-768px
       .props-content is ~600px+, which clears the 412px the desktop grid
       needs (380px until the LINE track went 78 -> 110px on 2026-09-25), so
       a wider scope would add a scrollbar to rows that already fit.

       THIS BLOCK MUST STAY BELOW the .props-sgp-mode rules above. A media
       query adds NO specificity, and `.props-player-row.props-sgp-mode` here
       ties the desktop rule at two classes — so whichever comes LAST wins.
       It originally sat ~30 lines higher and the desktop `44px 1fr ...` quietly
       beat it, leaving the phone SGP name track as a 1fr that resolved to the
       player name's max-content (97px for "Jayson Tatum") instead of the fixed
       120px. Guarded by tests/test_props_row_mobile_scroll.py, which asserts
       source ORDER — a declaration-level test cannot see the cascade. */
    @media (max-width: 639px) {
      .props-team-body { overflow-x:auto; -webkit-overflow-scrolling:touch; }
      /* Fixed first track, not 1fr: inside a scroller a 1fr name column would
         collapse to 0 and the row's max-content would then swing with the
         longest player name. 120px ellipsizes predictably (the cell already
         sets overflow:hidden + text-overflow) and pins the row at a known
         width, so the swipe is bounded. */
      .props-col-header,
      .props-player-row { grid-template-columns:140px 110px 120px 62px 76px 100px; } /* 2026-09-12, at the 16px floor: 120/76/56 clipped the name at 11 chars, the "8 books (Δ35)" shop badge under the price (117px), and wrapped NO EDGE; measured on the deployed bundle with a Pro payload. LINE 78 -> 110px 2026-09-25, the spelled-out side */
      .props-player-row.props-sgp-mode,
      .props-col-header.props-sgp-mode { grid-template-columns:44px 140px 110px 120px 62px 76px 100px; }
      /* Without this the row BOX is only as wide as the 295px container while
         its tracks overflow, so the border, hover fill and locked-row tint
         stop mid-row once you scroll. min-width:100% keeps short rows full
         width. */
      .props-col-header,
      .props-player-row { width:max-content; min-width:100%; }
    }

    /* Floating slip panel — bottom-right of the props view */
    .props-sgp-panel { position:fixed; bottom:20px; right:20px; width:340px; max-height:70vh; overflow-y:auto; background:var(--bg-card); border:1px solid var(--border-default); border-radius:var(--radius-lg); box-shadow:0 10px 30px rgba(0,0,0,0.4); z-index:50; display:flex; flex-direction:column; }
    .props-sgp-panel-header { display:flex; align-items:center; gap:8px; padding:12px 16px; background:var(--bg-surface); border-bottom:1px solid var(--border-subtle); }
    .props-sgp-panel-title { font-weight:700; color:var(--text-primary); font-size:var(--text-base); }
    .props-sgp-leg-count-wrap { font-size:var(--text-sm); color:var(--text-muted); font-family:var(--font-mono); }
    .props-sgp-panel-close { margin-left:auto; background:none; border:none; color:var(--text-muted); font-size:20px; cursor:pointer; line-height:1; padding:0 4px; }
    .props-sgp-panel-close:hover { color:var(--text-primary); }
    .props-sgp-legs { padding:8px 12px; flex:1; min-height:60px; }
    .props-sgp-empty { font-size:var(--text-sm); color:var(--text-muted); text-align:center; padding:16px 8px; line-height:1.4; }
    /* A leg is the name and its x on one line, the detail ("Hits Over 1.5 @ +130")
       whole on the next. Beside the name in this 340px panel the detail had ~150px
       and an ellipsis, so the price was cut off every leg (2026-09-25;
       tests/test_props_sgp_leg_prints_whole.py). */
    .props-sgp-leg { display:flex; flex-wrap:wrap; align-items:center; gap:2px 8px; padding:8px 10px; margin-bottom:6px; background:var(--bg-surface); border-radius:6px; font-size:var(--text-sm); }
    .props-sgp-leg-name { font-weight:700; color:var(--text-primary); flex-shrink:0; }
    .props-sgp-leg-detail { order:3; flex-basis:100%; min-width:0; color:var(--text-muted); font-family:var(--font-mono); font-size:var(--text-xs); }
    .props-sgp-leg-remove { margin-left:auto; background:none; border:none; color:var(--text-faint); font-size:18px; cursor:pointer; line-height:1; padding:0 4px; flex-shrink:0; }
    .props-sgp-leg-remove:hover { color:var(--ev-neg); }
    .props-sgp-actions { display:flex; gap:8px; padding:8px 12px; border-top:1px solid var(--border-subtle); }
    .props-sgp-btn { flex:1; padding:8px 14px; border:none; border-radius:6px; font-size:var(--text-sm); font-weight:600; cursor:pointer; transition:opacity var(--transition-fast); }
    .props-sgp-btn-primary { background:var(--accent); color:var(--bg-base); }
    .props-sgp-btn-primary:hover { opacity:0.9; }
    .props-sgp-btn-secondary { background:var(--bg-surface); color:var(--text-secondary); border:1px solid var(--border-default); }
    .props-sgp-btn-secondary:hover { color:var(--text-primary); }
    .props-sgp-status { padding:0 16px; font-size:var(--text-sm); color:var(--text-muted); min-height:18px; }
    .props-sgp-result { padding:0 16px 12px; }
    .props-sgp-result-row { display:flex; align-items:center; justify-content:space-between; padding:6px 0; font-size:var(--text-sm); border-top:1px solid var(--border-subtle); color:var(--text-secondary); }
    .props-sgp-result-row:first-child { border-top:none; padding-top:10px; }
    .props-sgp-result-val { font-family:var(--font-mono); font-weight:700; color:var(--text-primary); }
    .props-sgp-result-corr { color:var(--accent-green); font-style:italic; font-size:var(--text-xs); padding:2px 0; border-top:none; }
    /* Grade-gate note: shown when a leg is not model-recommended so the joint
       EV above is not read as a green light. */
    .props-sgp-result-note { display:block; color:var(--accent-amber,#e8a40f); font-size:var(--text-xs); line-height:1.35; padding:4px 0; border-top:none; }

    /* ── Parlay Slip ─────────────────────────────────── */
    .parlay-slip{position:fixed;bottom:20px;right:20px;z-index:9000;width:380px;max-height:80vh;border-radius:var(--radius-lg);border:1px solid var(--border-default);background:var(--bg-surface);box-shadow:0 8px 32px rgba(0,0,0,0.4);overflow:hidden;transition:all 0.3s ease;font-family:'IBM Plex Sans',system-ui;}
    .parlay-slip.collapsed .parlay-expanded{display:none;}
    .parlay-slip.expanded .parlay-collapsed-body{display:none;}
    .parlay-slip .parlay-header{background:linear-gradient(135deg,var(--accent),var(--accent-hover));padding:10px 14px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;}
    .parlay-slip .parlay-header .parlay-title{display:flex;align-items:center;gap:8px;font-weight:700;font-size:var(--text-base);color:#fff;}
    .parlay-slip .parlay-header .parlay-badge{background:rgba(0,0,0,0.3);border-radius:var(--radius-pill);padding:2px 8px;font-size:var(--text-xs);color:#fff;}
    .parlay-slip .parlay-header .parlay-toggle{color:rgba(255,255,255,0.8);font-size:var(--text-xs);}
    .parlay-collapsed-body{padding:12px 14px;display:flex;justify-content:space-between;font-size:var(--text-base);}
    .parlay-collapsed-body .parlay-stat{text-align:center;}
    .parlay-collapsed-body .parlay-stat-label{color:var(--text-muted);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:0.04em;}
    .parlay-collapsed-body .parlay-stat-value{font-weight:700;font-size:var(--text-lg);font-family:var(--font-mono);color:var(--text-primary);}
    .parlay-collapsed-body .parlay-stat-value.pos{color:var(--ev-pos);}
    .parlay-collapsed-body .parlay-stat-value.neg{color:var(--ev-neg);}
    .parlay-expanded{max-height:60vh;overflow-y:auto;}
    /* FIX 2026-08-11: these are the SLIP's legs (app.js renderParlaySlip,
       always inside #parlay-slip.parlay-slip). They were unscoped, so they
       also hit the Slate CART's legs — a different component, in
       .parlay-rail, styled from slate.css:555 under the same class name.
       slate.css loads second and won every property both declared, but
       `display` was declared ONLY here, so this flex row silently laid out
       the cart and put .parlay-leg-meta under its corner button.
       Scoped to .parlay-slip, matching this file's own convention for the
       rest of the component (see .parlay-slip .parlay-header above).
       Guarded by tests/test_slate_touch_targets.py. */
    .parlay-slip .parlay-leg{padding:10px 14px;border-bottom:1px solid var(--border-subtle);display:flex;justify-content:space-between;align-items:start;}
    .parlay-slip .parlay-leg-info{flex:1;}
    .parlay-slip .parlay-leg-badges{display:flex;gap:4px;align-items:center;margin-bottom:4px;}
    .parlay-slip .parlay-leg-badges .sport-badge{background:var(--bg-hover);color:var(--text-muted);font-size:var(--text-xs);padding:2px 5px;border-radius:var(--radius-sm);text-transform:uppercase;}
    .parlay-slip .parlay-leg-badges .sizing-badge-mini{font-size:var(--text-xs);padding:2px 5px;border-radius:var(--radius-sm);font-weight:700;}
    .parlay-slip .parlay-leg-badges .sizing-badge-mini.bet{background:var(--accent-green-bg);color:var(--ev-pos);}
    .parlay-slip .parlay-leg-badges .sizing-badge-mini.lean{background:var(--accent-amber-bg);color:var(--accent-amber);}
    .parlay-slip .parlay-leg-badges .sizing-badge-mini.pass{background:var(--bg-hover);color:var(--text-faint);font-weight:600;}
    .parlay-slip .parlay-leg-badges .sizing-badge-mini.defer{background:rgba(255,80,80,0.1);color:var(--ev-neg);font-weight:600;}
    .parlay-slip .parlay-leg-desc{color:var(--text-primary);font-size:var(--text-base);font-weight:600;}
    .parlay-slip .parlay-leg-context{color:var(--text-muted);font-size:var(--text-sm);}
    .parlay-slip .parlay-leg-odds{text-align:right;margin-right:8px;}
    .parlay-slip .parlay-leg-odds-val{font-weight:700;font-size:var(--text-base);font-family:var(--font-mono);}
    .parlay-slip .parlay-leg-odds-prob{color:var(--text-muted);font-size:var(--text-xs);font-family:var(--font-mono);}
    .parlay-slip .parlay-leg-remove{color:var(--text-faint);font-size:var(--text-base);cursor:pointer;padding:2px 4px;transition:color var(--transition-fast);}
    .parlay-slip .parlay-leg-remove:hover{color:var(--accent-red);}
    .parlay-warning{padding:8px 14px;background:var(--accent-amber-bg);border-bottom:1px solid var(--border-subtle);display:flex;align-items:center;gap:6px;font-size:16px;color:var(--accent-amber);}
    .parlay-summary{padding:12px 14px;background:var(--bg-elevated);}
    .parlay-metrics{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:var(--space-sm);margin-bottom:12px;}
    .parlay-metric-label{color:var(--text-muted);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:0.04em;}
    .parlay-metric-value{font-weight:700;font-size:18px;font-family:var(--font-mono);color:var(--text-primary);}
    .parlay-stake-row{display:flex;gap:8px;align-items:center;}
    .parlay-stake-input{flex:1;background:var(--bg-elevated);border:1px solid var(--border-default);border-radius:var(--radius-sm);padding:8px 10px 8px 24px;color:var(--text-primary);font-size:var(--text-base);font-weight:600;font-family:var(--font-mono);}
    .parlay-stake-box{background:var(--bg-elevated);border:1px solid var(--border-default);border-radius:var(--radius-sm);padding:8px 12px;text-align:center;}
    .parlay-actions{display:flex;justify-content:flex-end;padding:8px 14px;gap:8px;border-top:1px solid var(--border-subtle);}
    .parlay-actions button{background:none;border:1px solid var(--border-default);border-radius:var(--radius-sm);padding:4px 12px;color:var(--text-muted);font-size:var(--text-sm);cursor:pointer;transition:all var(--transition-fast);}
    .parlay-actions button:hover{border-color:var(--accent);color:var(--text-primary);}

    @keyframes steamPulse { 0%,100%{box-shadow:0 0 4px rgba(245,158,11,0.4);} 50%{box-shadow:0 0 12px rgba(245,158,11,0.8), 0 0 20px rgba(245,158,11,0.3);} } /* amber, the key-finding fill (Bill 2026-10-15); colours: slate.css KEY FINDING */
    .steam-books-tbl { width:100%; border-collapse:collapse; font-size:var(--text-base); margin:10px 0; }
    .steam-books-tbl th { text-align:left; padding:4px 8px; border-bottom:2px solid var(--border-default); color:var(--text-muted); font-weight:600; font-size:var(--text-sm); text-transform:uppercase; }
    .steam-books-tbl td { padding:4px 8px; border-bottom:1px solid var(--border-subtle); }
    /* Neutral (Bill 2026-10-21): a shift toward the flagged team was red, which read as bad news beside
       "moved toward <Team>". Direction is the sign; colour no longer judges the move. */
    .steam-books-tbl .shift-pos, .steam-books-tbl .shift-neg { color:var(--text-primary); font-weight:700; }
    /* FIX 2026-10-05: five columns at the 16px floor were 430px in a 341px pop-up on a
       375px phone (it scrolled sideways, cutting Shift). Under 481px the odds move
       leaves its column for a full-width row under its book. steam_books_table.js. */
    .steam-books-tbl .steam-book { font-weight:600; }
    .steam-books-tbl .steam-odds-col { font-size:var(--text-xs); color:var(--text-muted); }
    .steam-books-tbl .steam-odds-row { display:none; }
    .steam-books-tbl .steam-odds-row td { padding-top:0; font-size:var(--text-xs); color:var(--text-muted); }
    @media (max-width:480px) {
      .steam-books-tbl th, .steam-books-tbl td { padding:4px; }
      .steam-books-tbl .steam-odds-col { display:none; }
      .steam-books-tbl .steam-odds-row { display:table-row; }
      .steam-books-tbl .steam-row td { border-bottom:0; }
    }
    /* At 320px in IBM Plex the four columns were 291px in a 286px pop-up: 3px side padding fits them. */
    @media (max-width:340px) { .steam-books-tbl th, .steam-books-tbl td { padding:4px 3px; } }
    .steam-interp { border:1.5px solid var(--accent-amber); background:var(--accent-amber-bg); border-radius:8px; padding:12px 16px; margin-top:12px; font-size:var(--text-base); line-height:1.5; color:var(--accent-amber); }
    .steam-interp-rule { margin-top:6px; font-size:var(--text-sm); }
    .steam-interp-action { margin-top:8px; padding:8px 12px; border-radius:6px; background:var(--accent-muted); border:1px solid var(--border-default); color:var(--accent); font-size:var(--text-sm); font-weight:600; }

    /* Top Row — 3 Bet Type Cards */
    .gd-pick-line { font-size:var(--text-base); font-weight:700; color:var(--text-primary); }

    /* AI panel extras */
    .gd-chalk-row { padding:4px 0; }
    .gd-chalk-row + .gd-chalk-row { border-top:1px solid var(--border); margin-top:4px; padding-top:6px; }
    .gd-chalk-team { font-size:var(--text-lg); font-weight:700; color:var(--text-primary); }
    .gd-chalk-sources { font-size:var(--text-xs); color:var(--text-dim); margin-top:2px; }
    .gd-chalk-odds { font-size:var(--text-base); color:var(--text-secondary); margin-top:3px; }
    .gd-chalk-tag { display:inline-block; font-size:var(--text-xs); font-weight:600; padding:1px 6px; border-radius:9999px; margin-left:6px; vertical-align:middle; }
    .gd-chalk-tag--edge { background:var(--accent-green-bg); color:var(--ev-pos); }
    .gd-chalk-tag--low { background:var(--accent-amber-bg); color:var(--accent-amber); }

    /* Fade transition for game switching */
    @keyframes gdFadeIn { from{opacity:0;} to{opacity:1;} }

    /* Tablet: collapse sidebar */
    @media (max-width:1024px) {
      /* FIX 2026-07-12 (Batch 2): the sidebar used to display:none here with
         NO replacement — sport switching on phones/tablets depended entirely
         on the hamburger. It now becomes a horizontal sport-chip strip under
         the top nav (gameview keeps it hidden — that layout has its own
         strip). initSportTabs() force-opens the <details> categories at this
         width because closed-details content cannot be revealed by CSS. */
      .app-layout > .sidebar {
        position:static; width:auto; height:auto;
        /* min-width:0 is load-bearing: as a grid item the sidebar's default
           min-width:auto would size the track to the chip strip's min-content
           (~1300px of non-wrapping chips), blowing the mobile layout viewport
           out to ~1338px. Found live 2026-07-12. */
        min-width:0;
        padding:0; overflow:visible;
        border-right:none; border-bottom:1px solid var(--border-subtle);
      }
      .app-layout > .sidebar .sidebar-section { padding:0; }
      .app-layout > .sidebar .sidebar-section-title { display:none; }
      /* Footer utilities (Tutorial / Reference / Terms) stay hamburger-only */
      .app-layout > .sidebar > div:not(.sidebar-section) { display:none; }
      .app-layout > .sidebar #sport-tabs-container {
        display:flex; align-items:center; gap:6px;
        overflow-x:auto; -webkit-overflow-scrolling:touch;
        padding:8px 12px;
        scrollbar-width:none;
      }
      .app-layout > .sidebar #sport-tabs-container::-webkit-scrollbar { display:none; }
      .app-layout > .sidebar .sidebar-category { display:contents; }
      .app-layout > .sidebar .sidebar-category > summary { display:none; }
      /* FIX 2026-07-25: was display:contents, intending (with the same on
         .sidebar-category above) to flatten every league chip into ONE flex
         row. `<details>` has a UA shadow DOM and does NOT honour that
         flattening, so each CATEGORY stayed a single flex item with its
         leagues stacked VERTICALLY — making the strip as tall as the tallest
         category. Measured on prod at 390px, chips shared an x-slot per
         category: soccer dx=569 at dy 8/56/103/151/198/246 (6 leagues) =
         6 x 48px = 288 + padding = the 302px strip, i.e. 36% of the phone
         screen and almost all of it empty (align-items:center centred each
         shorter stack, hence the staggered look).
         An explicit horizontal row doesn't depend on that flattening:
         302px -> 66px, all 17 chips on one row, horizontal scroll intact.
         Do NOT revert to display:contents — it computes as "contents" with a
         0x0 rect, so it LOOKS applied while laying out as a block.
         Guarded by tests/test_mobile_chip_strip.py. */
      .app-layout > .sidebar .sidebar-league-list {
        display:flex; flex-direction:row; align-items:center; gap:6px;
      }
      .app-layout > .sidebar .sidebar-league-item {
        flex:0 0 auto; white-space:nowrap;
        padding:8px 14px; margin:0;
        min-height:44px;
        border:1px solid var(--border-default); border-left:1px solid var(--border-default);
        border-radius:var(--radius-pill);
      }
      /* Drill-down chips (2026-09-06). The strip has two levels: the top
         level shows single-league sports plus one chip per multi-league
         category, and tapping a category replaces the row with its leagues
         behind a back chip. app.js drives it; these rules only dress it.

         ONE ROW AT EVERY LEVEL, deliberately. The first attempt (c34cf377,
         reverted in f3032e53) disclosed the leagues in an absolutely
         positioned SECOND row. The sidebar and main content overlap in this
         layout, so z-index only picks which one is hidden: the leagues
         rendered under the game board and could not be tapped, and lifting
         the sidebar instead painted chips over the 'Game Board' heading.
         Nothing here is positioned, so neither can happen.

         .drill-chip reuses .sidebar-league-item for its box so a category
         chip is exactly a league chip's size -- styling it independently gave
         44px against the league chips' 45.25px, and align-items:center turned
         that into a visible 1.63px stagger.
         Guarded by tests/test_sidebar_drilldown.py. */
      .app-layout > .sidebar .sidebar-league-item[data-drill-hidden] { display:none; }
      .app-layout > .sidebar .drill-chip {
        background:none; font-family:inherit; cursor:pointer;
        justify-content:center; }
      .app-layout > .sidebar .drill-chip .league-icon { font-size:18px; line-height:1; }
      /* Dashed so the back chip reads as navigation, not as a sport. The
         accent fill stays reserved for .active -- the reverted attempt used
         the same accent treatment for 'open' and 'selected', so two chips
         looked chosen at once. */
      .app-layout > .sidebar .drill-back { border-style:dashed; color:var(--text-secondary); }
      /* 2026-09-28: the category chip holding the selected league ("🏈 NCAAF")
         is the ONE lit chip at the top level -- the league's own chip is
         drill-hidden there. Same accent as .active below; still one lit chip
         per level, because the league and its category never show together. */
      .app-layout > .sidebar .drill-chip.drill-current {
        border-color:var(--accent); color:var(--accent);
        background:var(--accent-muted); gap:6px;
      }
      .app-layout > .sidebar .sidebar-league-item.active {
        border-color:var(--accent); color:var(--accent);
        background:var(--accent-muted);
      }
      /* FIX 2026-07-12: sidebar hides at <=1024px but .app-layout kept its
         180px first track until <=639px, so across 640-1023px (iPad portrait,
         large phones landscape) main-content auto-placed into the dead 180px
         column and the whole Slate rendered in a ~180px ribbon. Collapse to a
         single column wherever the sidebar rail is gone (the chip strip spans
         the single column as a grid row). minmax(0,1fr), not bare 1fr — bare
         1fr's auto minimum lets wide row content (chip strip, tables) expand
         the track past the viewport. */
      /* FIX 2026-09-06: the sidebar row was allocated 133px for 63px of
         content — 70px of empty space sitting in the chrome above the board,
         measured on prod at a real 375x812.

         Cause: `.app-layout` has `min-height:100vh` and declares NO
         grid-template-rows (main.css:297), so rows are implicit and auto-sized
         — and a grid's default `align-content` is `normal`, which behaves as
         STRETCH. Whenever the content is shorter than 100vh, the leftover is
         split EQUALLY between the auto rows. Above 1024px this is invisible
         because the sidebar and main content share ONE row; the single column
         below is what turns the sidebar into a row of its own, so it starts
         collecting half the slack. The arithmetic: 63 + 70 = 133, and the main
         row showed the same +70.

         `auto` sizes the strip to its content; the second track takes the rest,
         so the layout still fills the viewport. `align-content:start` also
         removes the 70px but leaves it dead at the BOTTOM of the page — the
         content row loses it (measured: main 692px vs 554px).

         minmax(0,1fr) rather than bare 1fr for the same reason the column above
         gives: bare 1fr's auto minimum lets wide row content expand the track.
         Measured identical here today, and kept for consistency and safety.

         Verified 375/540/768/1024: sidebar 64px at every width, no horizontal
         overflow; and at 1200px the layout is still a single 756px row across
         two columns, which this rule cannot reach. */
      .app-layout { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
    }

    @keyframes analyticsSpinner { to{transform:rotate(360deg)} }
    .analytics-loading{display:flex;align-items:center;gap:8px;color:var(--text-dim);font-size:var(--text-base);}
    .analytics-loading::before{content:'';width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:analyticsSpinner 0.6s linear infinite;}
    /* Analytics Dashboard Grid */
    .analytics-grid { display:flex; flex-direction:column; gap:var(--space-md); }
    .analytics-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:var(--space-sm); margin-bottom:var(--space-md); }
    .analytics-metric { background:var(--bg-surface); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:var(--space-md); }
    /* The lead tile: profit per $100 staked. It answers the question the
       yardstick panel just posed, so it sits first and reads a size up. */
    .analytics-metric--lead { border-color:var(--accent); border-left-width:3px; }
    .analytics-metric--lead .analytics-metric-value { font-size:30px; }
    .analytics-metric-value { font-size:24px; font-weight:700; font-family:var(--font-mono); color:var(--text-primary); }
    .analytics-metric-label { font-size:var(--text-xs); color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em; margin-top:var(--space-xs); }
    .analytics-metric-trend { font-size:var(--text-sm); font-family:var(--font-mono); margin-top:var(--space-xs); }
    .analytics-metric-trend.up { color:var(--ev-pos); }
    .analytics-metric-trend.down { color:var(--ev-neg); }
    .analytics-charts { display:grid; grid-template-columns:1fr 1fr; gap:var(--space-md); }
    .analytics-chart-card { background:var(--bg-surface); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:var(--space-md); }
    /* card-info: shared style for the "?" info blurb on analytics/PM cards.
       Extracted 2026-07-12 from 20 identical inline style="" blocks in
       index.html. The inline style="display:none;" is intentionally kept on
       each element because toggleInfo() reads/writes info.style.display. */
    .card-info { color:var(--text-muted); font-size:var(--text-base); line-height:1.6; margin-bottom:12px; padding:10px 12px; background:var(--bg-elevated); border-radius:6px; }
    .analytics-chart-title { font-size:var(--text-sm); font-weight:600; color:var(--text-secondary); margin-bottom:var(--space-sm); display:flex; align-items:center; gap:8px; }
    .analytics-full-width { grid-column:1/-1; }
    /* The scope strip: which sport every panel below it describes. It sits
       between the all-sports Consensus Performance widget and the first
       sidebar-scoped panel (2026-09-19, "it says all sports but it's just
       baseball"). The accent left rule matches the lead tile and the
       yardstick, the two other "read this first" surfaces on the tab. */
    .analytics-scope { display:flex; align-items:baseline; gap:var(--space-sm) var(--space-md); flex-wrap:wrap; padding:var(--space-sm) var(--space-md); background:var(--bg-surface); border:1px solid var(--border-subtle); border-left:3px solid var(--accent); border-radius:var(--radius-md); }
    .analytics-scope:empty { display:none; }
    .analytics-scope-sport { font-size:var(--text-lg); font-weight:800; color:var(--text-primary); letter-spacing:0.02em; }
    .analytics-scope-note { font-size:var(--text-sm); color:var(--text-muted); line-height:1.45; }
    .analytics-section-header { font-size:var(--text-base); font-weight:800; color:var(--accent); margin:var(--space-xl) 0 var(--space-md) 0; text-transform:uppercase; letter-spacing:0.04em; }
    .analytics-filter-pill { font-size:var(--text-sm); padding:var(--space-xs) var(--space-md); border-radius:var(--radius-pill); border:1px solid var(--border-default); color:var(--text-muted); cursor:pointer; background:none; transition:all var(--transition-fast); }
    .analytics-filter-pill.active { background:var(--accent-muted); color:var(--accent); border-color:var(--accent); }
    .analytics-filter-pill:hover { border-color:var(--text-muted); color:var(--text-secondary); }

    /* FIX 2026-07-25: the Analytics tab pushed the PAGE sideways 467px at a
       390px viewport (scrollWidth 857 vs clientWidth 390).

       Root cause is grid blowout, not the tables on their own: a `1fr` track's
       automatic minimum is min-content, so every analytics grid sized itself to
       its widest descendant and spilled out of its parent —

         .analytics-metrics  repeat(4,1fr)  -> tracks 98/98/98/88 in a 310px row,
                                               last KPI card reached x=446
         .analytics-charts   1fr 1fr        -> e.g. 326px + 397px in a 310px row
         #analytics-daily-perf's inline split (app.js) 1fr 1fr
                                            -> 354px + 434px in a 284px card;
                                               the Signal Accuracy table started
                                               at x=423 and ran to x=857

       Stacking the grids is necessary but NOT sufficient: at one column the
       Signal Accuracy table's own min-content (434px) still exceeds the 284px
       card interior. So each wide table also gets its own scroll box —
       wide content scrolls in its own container, never the page body
       (same contract as .slate-ss-scroll and #analytics-props-history). */
    .analytics-tbl-scroll { overflow-x:auto; -webkit-overflow-scrolling:touch; }
    /* Two-up split inside an analytics card (was an inline style in app.js;
       promoted to a class so the phone breakpoint below can reach it). */
    .analytics-split-2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
    /* Six-up stat strip inside the Daily Performance card (also de-inlined). */
    .analytics-tiles-6 { display:grid; grid-template-columns:repeat(6,1fr); gap:8px; margin-bottom:16px; }

    @media (max-width: 639px) {
      .analytics-metrics { grid-template-columns:repeat(2,1fr); }
      .analytics-tiles-6 { grid-template-columns:repeat(2,1fr); }
      .analytics-charts { grid-template-columns:1fr; }
      .analytics-split-2 { grid-template-columns:1fr; }
      /* min-width:0 is the declaration that actually defeats the blowout — a
         single-column track still refuses to shrink below its item's
         min-content without it, so dropping this re-breaks the page. */
      .analytics-metrics > *,
      .analytics-tiles-6 > *,
      .analytics-charts > *,
      .analytics-split-2 > * { min-width:0; }
      /* 2026-09-12, phone review at the 16px floor. Measured on the live
         Analytics tab at 375px: the page itself scrolled sideways (465px), a
         chart title kept its four filter pills on one line (348px in a 269px
         card), the lead metric's 30px "-$13.42" wrapped at the sign, and
         "distinguishable" in a metric trend was wider than its 116px tile. */
      .analytics-chart-title { flex-wrap:wrap; }
      .analytics-metric-value { white-space:nowrap; }
      .analytics-metric--lead .analytics-metric-value { font-size:24px; }
      .analytics-metric--lead { grid-column:1 / -1; } /* the lead tile carries the 95% interval sentence; half a phone column broke it mid-word */
      .analytics-metric-label, .analytics-metric-trend { overflow-wrap:anywhere; }
      /* Pick History is an eleven-column fr grid rendered inline (app.js
         ~7458); its min-content is 412px against a 269px card. Scroll rather
         than collapse, the same call as every other analytics table
         (.analytics-tbl-scroll): the card scrolls and rows keep a readable
         width. Model vs Market's three summary boxes wrap instead. */
      #analytics-pick-history { overflow-x:auto; -webkit-overflow-scrolling:touch; }
      #analytics-pick-history .ph-row { min-width:560px; }
      /* The record line wraps as whole items: "102W - 103L - 2T" and "Since
         Feb 21, 2026" each stayed on one line instead of one word per line. */
      #analytics-pick-history .ph-record { flex-wrap:wrap; }
      #analytics-pick-history .ph-record > span { white-space:nowrap; }
      #analytics-model-vs-market .mvm-summary { flex-wrap:wrap; }
      /* Self-learning timeline and model-version rows: five fixed-min-width
         spans on one flex line measured 399px in a 269px card. They wrap. */
      .sl-row { flex-wrap:wrap; }
    }

    .info-btn {
      width:17px; height:17px; border-radius:50%;
      border:1.5px solid var(--accent); background:var(--accent-muted);
      color:var(--accent); font-size:var(--text-sm); font-weight:700;
      cursor:pointer; display:inline-flex; align-items:center;
      justify-content:center; padding:0; transition:all 0.15s;
    }
    .info-btn:hover { background:var(--accent-muted); color:#fff; }
    /* Pick-history pager. Was inline padding+font-size in app.js, which no
       media query could override; promoted to a class so the phone
       breakpoint can reach it (same reason as .analytics-split-2).
       Colour and cursor stay inline — they depend on hasPrev/hasNext. */
    /* 2026-09-10 pick ledger: the analytics Pick History labels a
       reconstructed row (R, title carries the evidence note) and shows the
       number a withdrawn call was pulled at. 12px floor respected. */
    .ph-recon {
      display:inline-block; margin-left:6px; padding:0 6px; border-radius:8px;
      font-size:16px; font-weight:700; line-height:16px;
      background:var(--accent-muted); color:var(--accent); cursor:help;
    }
    .ph-withdrawn { color:var(--text-dim); font-size:16px; white-space:nowrap; }
    /* 2026-10-01: an over/under graded at the first book's line and not
       re-checked (utils/ou_line_audit). Shown, still counted. */
    .ph-unverified { color:var(--text-dim); font-size:16px; white-space:nowrap; cursor:help; }
    .ph-unverified-note { color:var(--text-dim); font-size:16px; padding:6px 0; }
    .pick-history-page-btn {
      padding:3px 10px; font-size:var(--text-sm);
      border-radius:4px; border:1px solid var(--border);
      background:var(--bg-card);
    }

/* ── Auth Modal ── */
.auth-input {
  width:100%; padding:10px 12px;
  background:var(--bg-elevated); border:1px solid var(--border-default);
  border-radius:var(--radius-sm); color:var(--text-primary);
  font-size:17px; font-family:var(--font-sans);
}
.auth-input:focus { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-muted); outline:none; }
.auth-btn {
  margin-top:12px; padding:10px 16px;
  background:var(--accent); color:#0d1117; border:none;
  border-radius:var(--radius-sm); font-size:17px; font-weight:600;
  cursor:pointer; transition:background var(--transition-fast);
}
.auth-btn:hover { background:var(--accent-hover); }
.auth-btn:disabled { opacity:0.5; cursor:not-allowed; }
/* ── User Pill + Menu ── */
.user-pill { position:relative; cursor:pointer; display:flex; align-items:center; gap:6px; }
.tier-badge {
  font-size:16px; font-weight:700; text-transform:uppercase; letter-spacing:0.05em;
  padding:2px 6px; border-radius:var(--radius-pill);
}
.tier-badge.free { background:var(--bg-hover); color:var(--text-muted); }
.tier-badge.pro { background:rgba(16,185,129,0.15); color:var(--accent); }
.tier-badge.enterprise { background:rgba(234,179,8,0.15); color:#eab308; }
.user-menu {
  display:none; position:absolute; top:calc(100% + 4px); right:0;
  min-width:140px; background:var(--bg-surface); border:1px solid var(--border-default);
  border-radius:var(--radius-md); box-shadow:0 8px 24px rgba(0,0,0,0.4); z-index:200;
}
.user-menu.open { display:block; }
.user-menu-item {
  padding:8px 14px; font-size:16px; color:var(--text-secondary); cursor:pointer;
  /* display/text-decoration added 2026-08-22: the menu gained <a> items
     (My Account, Alerts, Watchlist). Anchors are inline and underlined by
     default, which broke the row rhythm. No-ops for the <div> items. */
  display:block; text-decoration:none;
}
.user-menu-item:hover { background:var(--bg-hover); color:var(--text-primary); }

/* ── Feedback FAB ── */
.feedback-fab {
  position:fixed; bottom:20px; left:20px; z-index:8000;
  width:44px; height:44px; border-radius:50%;
  /* FIX 2026-07-23: #fff on --accent was 2.54:1 — WCAG 1.4.3 AA fail. Same
     treatment the tutorial play button already uses: dark ink on the green (7.5:1). */
  background:var(--accent); color:#0d1117; border:none;
  font-size:20px; cursor:pointer; box-shadow:0 4px 16px rgba(0,0,0,0.4);
  transition:background var(--transition-fast), transform var(--transition-fast);
}
.feedback-fab:hover { background:var(--accent-hover); transform:scale(1.1); }
/* Bill, 2026-10-13: bottom-left it covered the sidebar column. Above 1024px the
   sidebar footer's "Send Feedback" row (#sidebar-feedback) replaces it; the circle
   now shows only at 800-1024px, where the sidebar is the chip strip up top. */
@media (min-width:1025px) { .feedback-fab { display:none; } }

/* =========================================
   COMMAND CENTER (2026-04-15)
   ========================================= */

#view-commandcenter {
  color: var(--text-primary);
  font-family: 'IBM Plex Sans', sans-serif;
}
.cc-view-container {
  padding: 0;
  width: 100%;
  box-sizing: border-box;
  flex-direction: column;
  flex: 1 1 0;
  min-height: 0;
  overflow: hidden;
}
/* FIX 2026-07-12 (Batch 2): display lives in a :not(.hidden) rule so the
   .hidden utility hides this container without an !important arms race
   (was display:flex !important + .cc-view-container.hidden counter-rule). */
.cc-view-container:not(.hidden) {
  display: flex;
}

.cc-summary-strip {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 6px 12px;
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: 6px;
  margin-bottom: 8px;
  flex-shrink: 0;
}

.cc-summary-count {
  font-size: 17px;
  font-weight: 600;
  color: var(--text-primary);
}

.cc-summary-metrics {
  font-size: 16px;
  color: var(--text-muted);
  font-family: 'IBM Plex Mono', monospace;
}

.cc-refresh-btn {
  background: transparent;
  border: 1px solid var(--border-default);
  color: var(--text-muted);
  padding: 4px 10px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 18px;
}
.cc-refresh-btn:hover { background: var(--bg-hover); color: var(--text-primary); }

.cc-filter-bar {
  background: var(--bg-base);
  padding: 6px 12px;
  border: 1px solid var(--border-default);
  border-radius: 6px;
  margin-bottom: 8px;
  z-index: 10;
  flex-shrink: 0;
}

.cc-filter-row {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  align-items: center;
  padding: 2px 0;
}

.cc-filter-label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 17px;
  color: var(--text-muted);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.cc-filter-label select,
.cc-filter-label input[type="number"],
.cc-filter-label input[type="text"] {
  background: var(--bg-surface);
  color: var(--text-primary);
  border: 1px solid var(--border-default);
  border-radius: 4px;
  padding: 4px 8px;
  font-size: 16px;
  font-family: inherit;
}

.cc-checkgroup {
  display: inline-flex;
  gap: 8px;
}
.cc-checkgroup label {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  font-size: 17px;
  text-transform: none;
  letter-spacing: 0;
  color: var(--text-primary);
  font-weight: 500;
  cursor: pointer;
}

.cc-table-wrap {
  overflow: auto;
  flex: 1 1 0;
  min-height: 0;
  border: 1px solid var(--border-default);
  border-radius: 6px;
  background: var(--bg-surface);
}

.cc-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 16px;
  font-family: 'IBM Plex Mono', monospace;
}

.cc-th {
  text-align: left;
  padding: 10px 12px;
  font-weight: 600;
  color: var(--text-muted);
  text-transform: uppercase;
  font-size: 16px;
  letter-spacing: 0.05em;
  border-bottom: 1px solid var(--border-default);
  cursor: pointer;
  user-select: none;
  background: var(--bg-base);
  position: sticky;
  top: 0;
  z-index: 5;
}
.cc-th:hover { color: var(--text-primary); }
.cc-th-active { color: var(--text-primary); }

.cc-row {
  border-left: 3px solid transparent;
  border-bottom: 1px solid var(--border-default);
  cursor: pointer;
  transition: background 0.1s ease;
}
.cc-row:hover { background: var(--bg-hover); }
.cc-row td { padding: 8px 12px; vertical-align: middle; }

.cc-row-bet  { border-left-color: var(--accent-green, #10b981); }
.cc-row-lock { border-left-color: #3b82f6; }
.cc-row-lean { border-left-color: var(--accent-amber, #f59e0b); }
.cc-row-noedge { border-left-color: var(--text-faint, #b0b0ba); }

.cc-tier-pill {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 3px;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: 0.05em;
}
.cc-tier-bet  { background: rgba(16,185,129,0.15); color: #10b981; }
.cc-tier-lock { background: rgba(59,130,246,0.15); color: #3b82f6; }
.cc-tier-lean { background: rgba(245,158,11,0.15); color: #f59e0b; }
.cc-tier-noedge { background: var(--bg-base); color: var(--text-faint); }

.cc-ev-positive { color: var(--accent-green, #10b981); font-weight: 600; }
.cc-ev-negative { color: var(--text-muted); }
/* FIX 2026-07-16 same-hole check: phantom (>30pp) EV never renders green */
.cc-ev-suspect { color: var(--accent-amber, #f59e0b); font-weight: 600; }
.cc-row-started { opacity: 0.5; }
.cc-started-chip {
  font-size: 1rem; font-weight: 700; letter-spacing: 0.04em;
  color: var(--text-muted); border: 1px solid var(--border-color, #374151);
  border-radius: 3px; padding: 0 3px; margin-left: 4px; vertical-align: 1px;
}

/* Prob colored relative to breakeven — green = edge exists */
.cc-prob-strong { color: var(--accent-green, #10b981); font-weight: 600; }
.cc-prob-thin { color: var(--accent-amber, #f59e0b); }
.cc-prob-negative { color: var(--text-muted); }

.cc-grade-high   { color: var(--accent-green, #10b981); font-weight: 600; }
.cc-grade-mod    { color: var(--accent-amber, #f59e0b); }
.cc-grade-low    { color: var(--text-muted); }

.cc-empty {
  text-align: center;
  padding: 40px 12px;
  color: var(--text-faint);
  font-style: italic;
  font-family: 'IBM Plex Sans', sans-serif;
}

/* FIX 2026-07-22: the empty state now explains WHY the table is empty
   (see static/js/cc_empty_state.js). The headline is the verdict, the detail
   carries the counts, and the action is the escape hatch. Not italic — this
   is a statement of fact about the board, not a placeholder. */
.cc-empty-headline {
  font-style: normal;
  font-size: var(--text-base, 15px);
  color: var(--text-secondary);
  margin-bottom: 6px;
}

.cc-empty-detail {
  font-style: normal;
  font-size: var(--text-sm, 13px);
  color: var(--text-muted);
  max-width: 62ch;
  margin: 0 auto;
  line-height: 1.5;
}

.cc-empty-action {
  font-style: normal;
  font-family: inherit;
  font-size: var(--text-sm, 13px);
  margin-top: 14px;
  /* 44px min touch target — enforced by tests/test_mobile_touch_targets.py.
     Assert the DECLARATION, not the rendered rect. */
  min-height: 44px;
  padding: 10px 18px;
  cursor: pointer;
  color: var(--text-primary);
  background: transparent;
  border: 1px solid var(--border-default, #333);
  border-radius: 6px;
}

.cc-empty-action:hover {
  border-color: var(--accent-green, #10b981);
  color: var(--accent-green, #10b981);
}

/* The escape hatch is the one control on an empty All Bets table, so a
   fat-finger miss there leaves the user with nothing to do but leave.
   Enumerated in tests/test_slate_touch_targets.py::GUARDED_CONTROLS — the
   sentinel only reads declarations inside a mobile media query. */
@media (max-width: 768px) {
  .cc-empty-action {
    min-height: 44px;
  }
}

.cc-detail-row td {
  padding: 16px 24px;
  background: var(--bg-base);
  border-left: 3px solid var(--accent-muted, #b0b0ba);
}

/* Sports custom dropdown */
.cc-sports-wrap { position: relative; }
.cc-sports-dropdown { position: relative; display: inline-block; }
.cc-sports-btn {
  background: var(--bg-surface);
  color: var(--text-primary);
  border: 1px solid var(--border-default);
  border-radius: 4px;
  padding: 4px 12px 4px 8px;
  font-size: 16px;
  font-family: inherit;
  cursor: pointer;
  min-width: 120px;
  text-align: left;
}
.cc-sports-btn:hover { border-color: var(--text-muted); }
.cc-sports-menu {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 100;
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: 6px;
  padding: 6px 0;
  margin-top: 4px;
  min-width: 160px;
  max-height: 300px;
  overflow-y: auto;
  box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.cc-sports-item {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 5px 12px;
  font-size: 16px;
  color: var(--text-primary);
  cursor: pointer;
  font-weight: 500;
}
.cc-sports-item:hover { background: var(--bg-hover); }

@media (max-width: 768px) {
  .cc-filter-row { gap: 8px; }
  .cc-checkgroup { flex-wrap: wrap; }
  .cc-th, .cc-row td { padding: 6px 8px; font-size: 17px; }
}

/* FIX 2026-07-12 (Batch 2): phone collapse for the 12-col All Bets table.
   At 390px the full grid needed ~700-800px of two-finger horizontal scroll.
   Keep the 5 decision-driving columns — Matchup(3), Side/Line(5), Odds(7),
   EV(8), Tier(12) — and hide Time(1), Sport(2), Bet(4), Book(6), Prob(9),
   B/E(10), Grade(11). Everything hidden is still shown in the tap-to-expand
   detail row (renderCCRow order pins the indices; detail/empty rows use
   colspan and are excluded via :not([colspan])). */
@media (max-width: 639px) {
  .cc-table thead th:nth-child(1),
  .cc-table thead th:nth-child(2),
  .cc-table thead th:nth-child(4),
  .cc-table thead th:nth-child(6),
  .cc-table thead th:nth-child(9),
  .cc-table thead th:nth-child(10),
  .cc-table thead th:nth-child(11),
  .cc-table tbody td:nth-child(1):not([colspan]),
  .cc-table tbody td:nth-child(2):not([colspan]),
  .cc-table tbody td:nth-child(4):not([colspan]),
  .cc-table tbody td:nth-child(6):not([colspan]),
  .cc-table tbody td:nth-child(9):not([colspan]),
  .cc-table tbody td:nth-child(10):not([colspan]),
  .cc-table tbody td:nth-child(11):not([colspan]) {
    display: none;
  }
}

/* ============================================================
   Cricket (IPL) card elements — Phase 1 Task 16
   ============================================================ */

/* ============================================================
   Tennis card elements — Phase 1 Task 13
   ============================================================ */

/* ==========================================================================
   UFC / MMA card badges — Phase 1 Task 13
   ========================================================================== */

/* ── Mobile input zoom-prevention (iOS Safari zooms on focus when input <16px) ──
   NOTE (2026-09-02): despite the name, this block is NOT reliably last in
   the file. A plain `.br-input {` rule added after it once silently beat
   its entry for .br-input (both specificity (0,1,0); a media query adds
   none); .br-input went with the bet-routing modal on 2026-10-02. Before
   adding a selector here, grep the file for a later plain re-declaration
   of it -- if one exists, this block cannot win and the override must go
   in a block genuinely last in the file. Targets each app input class
   explicitly to keep specificity equal-or-greater. */
/* ---- The settings panel as a BOTTOM SHEET below 800px (2026-09-22) ----
   WHY a sheet and not the anchored dropdown: the gear is hidden at these
   widths, and this panel is a FORM the reader adjusts (a select plus six
   inputs), not a menu they tap once. A form belongs in the thumb zone --
   skills/mobile-responsive-web: anything tapped more than once per session
   sits in the bottom 60%, the top corners are for back/menu only. Measured
   before: at 375/639/640/799 the select computed display:none; at 800 it
   appeared. One element serves both layouts, so there is one set of ids. */
@media (max-width: 799px) {
  #settings-dropdown {
    position:fixed; left:0; right:0; bottom:0; top:auto; width:auto;
    /* dvh, not vh: the iOS URL bar collapsing must not move the sheet. */
    max-height:min(80dvh, 620px); overflow-y:auto; overscroll-behavior:contain;
    border-radius:16px 16px 0 0; border-bottom:0;
    /* The bottom padding clears the STICKY Done button (44px + its margin),
       or the last control sits under it when the sheet opens -- found by
       scripts/browser_checks/mobile_reach_check.py, which reported
       #force-refresh covered_by button.settings-done at 375/639/799. */
    padding:var(--space-sm) var(--space-md) calc(44px + var(--space-md) * 2 + env(safe-area-inset-bottom));
    box-shadow:0 -8px 32px rgba(0,0,0,0.5); z-index:9500;
  }
  /* Over .top-nav and .bottom-nav (z-index 100), under the sheet itself. */
  /* .top-nav is z-index:100 and thus a stacking context: the sheet's own
     9500 only ranks it inside the nav, so .bottom-nav (100, later in the
     DOM) painted over the Done button and ate the tap. app.js adds this
     class for exactly as long as the sheet is open. */
  .top-nav.settings-open { z-index:9500; }
  .settings-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.55); z-index:9400; }
  .settings-backdrop.open { display:block; }
  /* The grab handle is the affordance that says 'sheet'; decorative only. */
  .settings-sheet-handle { display:block; width:36px; height:4px; margin:4px auto var(--space-sm); border-radius:999px; background:var(--border-strong); }
  /* 44px targets, 16px type: under 16px iOS Safari zooms the page on focus. */
  #settings-dropdown select,
  #settings-dropdown input[type="number"] { min-height:44px; font-size:16px; width:100%; box-sizing:border-box; }
  #settings-dropdown label { font-size:16px; margin-top:var(--space-sm); }
  /* The checkbox row is a 44px strip, not a 16px box to hit. */
  #settings-dropdown #force-refresh { min-width:24px; min-height:24px; }
  #settings-dropdown #force-refresh + label { min-height:44px; display:flex; align-items:center; margin-top:0; }
  /* Done closes the sheet and is the last thing in it, nearest the thumb. */
  /* Sticky: the sheet scrolls when the controls are taller than it, and a
     Done button that has to be scrolled to is not in the thumb zone. */
  .settings-done { display:block; position:sticky; bottom:0; width:100%; min-height:44px;
    margin-top:var(--space-md);
    background:var(--accent); color:#0d1117; font:600 16px 'IBM Plex Sans',sans-serif;
    border:0; border-radius:var(--radius-sm); cursor:pointer; }
  .settings-done:focus-visible { outline:2px solid var(--text-primary); outline-offset:2px; }
}

@media (max-width: 639px) {
  .bankroll-pill .bankroll-input,
  .top-nav-dropdown input[type="number"],
  .ref-search-input,
  .auth-input,
  .cc-filter-label select,
  .cc-filter-label input[type="number"],
  .cc-filter-label input[type="text"],
  /* FIX 2026-08-11: the Analytics page-size select renders at --text-sm
     (13px) via .analytics-filter-pill and zoomed the viewport on focus.
     Qualified with `select` so it beats the .analytics-filter-pill rule
     (0,1,1 vs 0,1,0) — an unqualified .analytics-filter-pill here would tie
     and lose nothing, but would also restyle the pill BUTTONS, which are not
     focusable text controls and must keep their compact size. */
  select.analytics-filter-pill {
    font-size: 18px;
  }
}

/* WCAG 2.5.5 touch targets — 2026-08-11 picks-page sweep (All Bets,
   Analytics, Props). Measured at 390x844: every failure was HEIGHT, never
   width, because these controls were sized for a mouse. End of file so they
   win on source order. Guarded by tests/test_slate_touch_targets.py. */
@media (max-width: 639px) {
  /* Buttons and label rows: centre the existing content in a 44px box. */
  .cc-checkgroup label,
  .cc-sports-btn,
  .cc-refresh-btn,
  .analytics-filter-pill,
  .pick-history-page-btn,
  .props-sport-pill,
  .props-category-pill,
  .props-sgp-toggle {
    min-height: 44px;
    /* WCAG 2.5.5 is 44 in BOTH axes. Height was the common failure, but the
       short labels are narrow too: measured after the height fix,
       .cc-refresh-btn was 35 wide, the "ML" bet-type label 40.3, and the
       "All" clv/cal tabs 40.5. */
    min-width: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  /* Form controls get height only. `display:inline-flex` on a <select>
     fights the native control's own rendering, so it is not set here. */
  .cc-filter-label select,
  .cc-filter-label input[type="number"],
  .cc-filter-label input[type="text"] {
    min-height: 44px;
  }

  /* .info-btn is a 17x17 dot INLINE inside chart headings — 22 of them. A
     44px box would reflow every heading, so grow the TARGET, not the
     control: an absolutely-positioned ::after contributes no layout.
     17 + 2*14 = 45px. (accessibility-audit skill: expand the hit area with
     padding or a pseudo-element when the visual design needs to stay small.) */
  .info-btn { position: relative; }
  .info-btn::after {
    content: "";
    position: absolute;
    inset: -14px;
  }
}

/* ── Mobile layout (<640px) ──
   .sidebar is already display:none at <1024px (line ~837), but .app-layout's grid template
   still allocates 180px to the empty column — collapse the sidebar column at mobile so .main-content
   actually gets the full viewport width. */
@media (max-width: 639px) {
  .app-layout { grid-template-columns: 1fr; }
}

/* ── 100vh → 100dvh upgrade (iOS Safari URL-bar-collapse fix) ──
   100vh on iOS includes the URL bar's space. When the bar collapses on scroll, content
   jumps. 100dvh (dynamic viewport height) tracks the actual visible area. Wrapped in
   @supports so older browsers fall through to the original 100vh rules unchanged. */
@supports (height: 100dvh) {
  .app-layout { min-height: 100dvh; }
  .sidebar { height: calc(100dvh - 56px); }
  .main-content { height: calc(100dvh - 56px); }
  @media (max-width: 639px) {
    .main-content { height: calc(100dvh - 56px - 64px - env(safe-area-inset-bottom)); }
  }
}

/* ============================================================
   UI cleanup Batch 2 (2026-07-12) — component classes extracted
   from inline style="" scaffolding in index.html. Values are
   verbatim from the inline originals unless commented.
   ============================================================ */

/* Modal size modifiers (were per-modal inline max-width overrides) */
.modal--sm { max-width: 420px; }
.modal--lg { max-width: 720px; }
/* Modal header row: title left, close button right */
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.modal-head h2 { margin: 0; font-size: 1.125rem; }

/* Props Scanner header + scroll container (were inline on #view-props kids) */
.props-header { z-index: 20; background: var(--bg-base); padding: var(--space-sm) var(--space-lg); display: flex; align-items: center; gap: var(--space-lg); flex-wrap: wrap; border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; }
.props-title { margin: 0; font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
.props-sport-pills { display: flex; gap: 2px; }
.props-scroll { padding: var(--space-sm) var(--space-lg) var(--space-lg); flex: 1 1 0; min-height: 0; overflow-y: auto; }
.props-status-line { margin-bottom: var(--space-sm); color: var(--text-muted); font-size: var(--text-sm); }
.props-games { display: flex; flex-direction: column; gap: var(--space-sm); }

/* Steam modal chart box */
.steam-chart-box { height: 240px; position: relative; }

/* ===== Reduced motion — WCAG 2.3.3 / motion sensitivity (FIX 2026-07-18) =====
   Nothing respected the OS "reduce motion" setting, so the infinite loops
   (taglineGlow, livePulse on live games, steamPulse, spinners) ran non-stop for
   everyone. Neutralize CSS animations + transitions for users who opt out;
   unchanged for everyone else. Canonical reset (accessibility-audit skill). */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Slate drilldown Context panel (2026-07-22) — injuries, weather, officials,
   fatigue, prediction markets. This data shipped to the browser for months
   with no screen rendering it: the display lived on the legacy Game Board,
   hidden 2026-07-18, and the parity check never listed context.
   See static/js/slate_context.js. */

.slate-ss-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-sm);
}
/* FIX 2026-07-24: a global th nowrap kept team-name headers on one line, so
   the table's min-content width (442px) overflowed the 312px takeover body
   at 390px viewport and clipped the Weight column off-screen. Headers wrap;
   the scroll wrapper below is the safety net for extreme names. */
.slate-ss-scroll { overflow-x: auto; }
.slate-ss-table th {
  text-align: right;
  font-size: var(--text-xs);
  font-weight: 700;
  color: var(--text-muted);
  padding: 3px 6px;
  border-bottom: 1px solid var(--border-subtle);
  white-space: normal;
  overflow-wrap: break-word;
}
.slate-ss-table th:first-child { text-align: left; }
.slate-ss-table td {
  padding: 3px 6px;
  text-align: right;
  color: var(--text-secondary);
  font-family: var(--font-mono);
  border-bottom: 1px solid var(--border-subtle);
}
.slate-ss-table td:first-child {
  text-align: left;
  font-family: inherit;
}
.slate-ss-pick { font-weight: 700; color: var(--text-primary); }
.slate-ss-wt { color: var(--text-faint); }
.slate-ss-consensus td {
  font-weight: 700;
  color: var(--text-primary);
  border-top: 2px solid var(--border-default);
}

/* FIX 2026-07-24 (desktop review): unconstrained, the panel stretched to the
   full drilldown width — 1149px at a 1280 viewport — leaving ~280px of dead
   space between a signal's name and its number, and shoving the advanced
   rows' team/prob pair against the far right edge. Sharps scan DOWN a number
   column; a stretched data table is the classic mobile-styles-let-loose tell
   (OddsJam and Unabated both cap theirs). 720px also holds the explanation
   prose to a readable measure. No effect on mobile — the container is
   already narrower. */
.slate-ss-scroll,
.slate-ss-footnote,
.slate-ss-advanced,
.slate-ss-ai { max-width: 720px; }

.slate-ss-footnote {
  margin-top: 4px;
  font-size: var(--text-xs);
  color: var(--text-faint);
  line-height: 1.4;
}

.slate-ss-advanced, .slate-ss-ai { margin-top: 10px; }
.slate-ss-count {
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
  color: var(--text-muted);
}

.slate-ss-sig {
  display: grid;
  grid-template-columns: 1fr auto 52px;
  gap: 4px 8px;
  padding: 3px 0;
  border-bottom: 1px solid var(--border-subtle);
  font-size: var(--text-sm);
  align-items: baseline;
}
.slate-ss-sig--unavail { opacity: 0.55; }
.slate-ss-sig-name { color: var(--text-secondary); }
.slate-ss-sig-wt { color: var(--text-faint); font-size: var(--text-xs); }
.slate-ss-sig-favor { color: var(--text-primary); font-weight: 600; text-align: right; }
/* A neutral signal ("No lean", it prints 50.0%) names no team, so it does
   not wear a team's weight. */
.slate-ss-sig-favor--even { color: var(--text-muted); font-weight: 400; }
.slate-ss-sig-prob {
  color: var(--text-secondary);
  font-family: var(--font-mono);
  text-align: right;
}
.slate-ss-sig-explain {
  grid-column: 1 / -1;
  font-size: var(--text-xs);
  color: var(--text-muted);
  line-height: 1.4;
}
/* Redistribution note for a signal that picked up weight from a dark one —
   Task 8: "+24.2pp absorbed from starting_pitcher". Same row shape as
   slate-ss-sig-explain (full width, own line) but faint: it is provenance
   for the weight badge above it, not the headline explanation. */
.slate-ss-sig-absorbed {
  grid-column: 1 / -1;
  font-size: var(--text-xs);
  color: var(--text-faint);
  line-height: 1.4;
}

/* Confidence conviction list -- Task B4: panel.confidence.conviction
   (game.ai_side_conviction_signals) rendered as its own card. Reuses the
   .slate-ss-sig row shape (name / qualifier / value) unchanged -- a
   conviction source and a signal-stack row are the same visual shape.
   Agree/disagree color the qualifier the same way the rest of the app
   colors edge: var(--ev-pos)/var(--ev-neg), never a new palette. */
.slate-ss-conviction { max-width: 720px; }
.slate-ss-sig-favor--agree { color: var(--ev-pos); }
.slate-ss-sig-favor--disagree { color: var(--ev-neg); }

.slate-ctx-section { margin-bottom: 10px; }
.slate-ctx-section:last-child { margin-bottom: 0; }

/* The priced impact. Amber by default, red past 2pp — a 4pp injury swing is
   the single biggest thing on this panel and must not read as a footnote. */
.slate-ctx-impact {
  margin-left: 8px;
  font-weight: 600;
  text-transform: none;
  letter-spacing: 0;
  color: var(--accent-amber, #f59e0b);
}
.slate-ctx-impact--severe { color: var(--ev-neg, #ef4444); }

.slate-ctx-line,
.slate-ctx-note {
  font-size: var(--text-sm);
  color: var(--text-secondary);
  line-height: 1.5;
}
.slate-ctx-note { color: var(--text-muted); font-style: italic; }

.slate-ctx-player {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: baseline;
  font-size: var(--text-sm);
  padding: 2px 0;
  color: var(--text-secondary);
}
.slate-ctx-player-name { font-weight: 600; color: var(--text-primary); }
.slate-ctx-player-team { color: var(--text-faint); font-size: var(--text-xs); }
.slate-ctx-status { font-weight: 600; color: var(--accent-amber, #f59e0b); }
.slate-ctx-status--out { color: var(--ev-neg, #ef4444); }

/* 50 rows is real evidence, but it cannot bury the edge math above it. */
.slate-ctx-details { margin-top: 6px; }
.slate-ctx-details summary {
  font-size: var(--text-sm);
  color: var(--text-muted);
  cursor: pointer;
  /* 44px touch target — enforced by tests/test_slate_touch_targets.py.
     Assert the CSS DECLARATION, not the rendered rect. */
  min-height: 44px;
  display: flex;
  align-items: center;
}
.slate-ctx-report { max-height: 220px; overflow-y: auto; margin-top: 4px; }

@media (max-width: 768px) {
  .slate-ctx-details summary { min-height: 44px; }
}

/* ── Mobile/tablet shell: ONE scroll region, not two (2026-07-25) ──
   `.app-layout` is a grid. At <=1024px it collapses to a single column
   (main.css:956), so `.sidebar` stops being a column BESIDE `.main-content`
   and becomes a ~302px sport-chip-strip ROW ABOVE it. But `.main-content`
   kept `height: calc(100dvh - 56px [- 64px])` — a formula written for the
   desktop case where it starts directly under the 56px top nav.

   Measured on prod at 390x844 before this fix:
     .main-content top = 358 (56 nav + 302 stacked strip), height = 724
     358 + 724 = 1082 = document.scrollHeight EXACTLY
     1082 - 844 = 238 = exactly what the window scrolled
   That outer scrollbar plus `#view-premium`'s own overflow-y:auto gave two
   nested scroll regions, and letterboxed the Slate drilldown into a 421px
   window on an 844px phone.

   Fix: below the grid-collapse breakpoint, stop deriving the pane height
   from the viewport and let the PAGE scroll. Range is <=1024px to match the
   grid collapse — scoping it to 639px would leave 640-1024px broken
   (verified 768x1024 pre-fix: window scrolled 302px AND pane scrolled
   2379px), the same trap main.css:948 documents them hitting before.

   Verified at 390px that no view container collapses once the pane height
   is released: premium 3894px, pmdebug 4373, analytics 6545, commandcenter
   398, props 383, cricket 217 — and zero nested scrollers remain in the
   shell (component-level table scrollers are intentional and untouched). */
@media (max-width: 1024px) {
  .main-content { height: auto; overflow: visible; }
  #view-premium, #view-exchanges, #view-analytics,
  #view-commandcenter, #cricket-view, #ufc-view,
  #view-props.view-props-flex { flex: 0 1 auto; min-height: 0; overflow: visible; }
  .props-scroll { flex: 0 1 auto; min-height: 0; overflow-y: visible; }
}

/* `.bottom-nav` is position:fixed and display:block ONLY at <=639px
   (main.css:124). The page now scrolls beneath it, so reserve its height
   (64px inner + 1px border) plus the safe-area inset. */
@media (max-width: 639px) {
  .main-content { padding-bottom: calc(65px + env(safe-area-inset-bottom)); }
}

/* ============================================================
   GAMES BOARD — PER-MARKET ODDS BOX (2026-08-05)
   Markup: renderOddsBoxCard() in static/js/app.js.
   Nothing here may drop below 12px (tests/test_type_scale_floor.py), and
   anything carrying meaning uses --text-primary/--text-secondary rather than
   --text-faint — grey text was called out as hard to read.
   ============================================================ */
/* 2026-09-03: the card edge was invisible. --border (0.1) on --bg-surface
   (#111113) against a --bg-page of #09090b put border AND surface within a few
   percent of the page, so 19 games read as one column. Separation now comes
   from depth as well as a line: the elevated surface plus a soft shadow, with
   --border-card carrying more contrast than --border-strong. Guarded by
   tests/test_board_card_separation.py. */
.ob-card { background:var(--bg-elevated); border:1px solid var(--border-card); border-radius:var(--radius-lg); margin-bottom:10px; overflow:hidden; box-shadow:var(--shadow-card-sm); }
.ob-card-live { border-color:rgba(248,113,113,0.35); }
.ob-head { padding:10px 14px; border-bottom:1px solid var(--border); font-size:var(--text-base); font-weight:600; color:var(--text-primary); }
.ob-head .ob-at { color:var(--text-muted); font-weight:400; margin:0 2px; }
.ob-live-chip { font-family:var(--font-mono); font-size:var(--text-xs); font-weight:700; color:var(--ev-neg); text-transform:uppercase; letter-spacing:0.04em; margin-left:8px; }
.ob-live-score { font-family:var(--font-mono); font-size:var(--text-sm); font-weight:600;
                 margin-left:8px; color:var(--text-primary); }
.ob-score-sep  { margin:0 4px; color:var(--text-secondary); font-weight:400; }
.ob-locked { padding:14px; font-size:var(--text-sm); color:var(--text-primary); text-align:center; }
/* A prediction-market fallback card names its venue at full strength (Kalshi licence, 2026-10-18). */
.ob-venue-note { padding:6px 14px 0; font-size:var(--text-sm); color:var(--text-primary); }
/* "Powered by Kalshi Research": the legend Kalshi's data licence requires on every screen showing its data (2026-10-19). */
.kalshi-attrib { display:flex; align-items:center; gap:9px; margin:4px 0; font-size:var(--text-xs); color:var(--text-secondary); }
/* Kalshi's official white wordmark (brand kit: white on dark, never recoloured or stretched;
   clear space = 2x the K's stem, about 9px at this 18px height). */
.kalshi-wordmark { display:inline-block; height:18px; width:auto; margin:9px 9px 9px 0; vertical-align:middle; flex:none; }

/* Header actions (2026-08-29): watch / log / share, migrated from the slate's
   per-bet list — see renderCardActions() in app.js. Right-aligned so they read
   as controls on the header rather than more matchup text. */
.ob-actions { display:flex; justify-content:flex-end; align-items:center; gap:8px;
  padding:0 14px 10px; }
.ob-action-watch, .ob-action-log, .ob-action-share {
  font-size:16px; font-weight:600; line-height:1; padding:5px 9px;
  border-radius:var(--radius-sm); border:1px solid var(--border);
  background:var(--bg-surface); color:var(--text-secondary); text-decoration:none;
  cursor:pointer;
}
.ob-action-watch { padding:5px 8px; }
.ob-action-watch.on { color:var(--accent-amber); border-color:var(--accent-amber); }
.ob-action-log:hover, .ob-action-share:hover, .ob-action-watch:hover {
  color:var(--text-primary); border-color:var(--text-muted);
}

/* 2026-09-02 board-card sweep. .ob-actions is emitted on EVERY card, so a
   15-game board carries 45 of these; measured 28x24 / 39x24 / 52x24 at 375px
   against the 44px floor. This block must stay directly AFTER the shared
   .ob-action-watch, .ob-action-log, .ob-action-share rule above (and its
   :hover rule) -- that desktop rule declares no min-height, min-width, or
   display, so this override wins the cascade only by coming later. Moving
   it earlier, or adding one of those properties to the desktop rule without
   moving this block again, silently drops mobile back below the 44px floor.
   Share already clears 44 wide by its label, and is recorded in
   WIDTH_BY_LAYOUT rather than given a no-op min-width.
   Guarded by tests/test_slate_touch_targets.py. */
@media (max-width: 639px) {
  .ob-action-watch,
  .ob-action-log,
  .ob-action-share {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  .ob-action-watch,
  .ob-action-log {
    min-width: 44px;
  }
}

/* Staleness stamp (2026-08-29), also migrated from the slate list — see
   renderCardFreshness() in app.js. 12px floor, muted unless stale/provisional. */
.ob-freshness { display:flex; align-items:center; gap:8px; flex-wrap:wrap;
  padding:0 14px 8px; font-size:16px; color:var(--text-muted); }
.ob-freshness-text.ob-stale { color:var(--accent-amber); }
.ob-provisional { color:var(--accent-amber); font-weight:600; }

/* overflow-x is a floor, not the plan — the mobile block below makes the grid
   fit 375px outright, because an element that scrolls inside a container still
   reports a right edge past the viewport. */
.ob-box-wrap { overflow-x:auto; }
.ob-box { width:100%; border-collapse:collapse; }
.ob-th-short { display:none; }
.ob-legend-phone { display:none; }
.ob-box thead th { font-family:var(--font-mono); font-size:var(--text-xs); font-weight:700; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-secondary); padding:8px; text-align:center; }
.ob-box tr.ob-subhead th { font-family:var(--font-mono); font-size:var(--text-xs); font-weight:400; letter-spacing:0; text-transform:none; color:var(--text-muted); padding:0 8px 8px; border-bottom:1px solid var(--border); }
/* Each word wears the colour of the value it names — the only in-place
   explanation of what the green number is. Keep these in step with
   .ob-line/.ob-price/.ob-prob below or the legend starts lying. */
.ob-k-line, .ob-k-price, .ob-k-prob { color:var(--text-primary); }
.ob-k-prob { font-weight:700; }
.ob-box td { padding:7px 8px; text-align:center; border-bottom:1px solid var(--border-subtle); }
.ob-box tbody tr:last-child td, .ob-box tbody tr:last-child .ob-team { border-bottom:none; }
.ob-team { text-align:left; padding:7px 8px 7px 14px; font-size:var(--text-base); font-weight:600; color:var(--text-primary); border-bottom:1px solid var(--border-subtle); }
.ob-cell { display:inline-flex; flex-direction:column; align-items:center; gap:1px; min-width:76px; padding:4px 8px; border:1px solid transparent; border-radius:var(--radius-sm); }
.ob-cell-pick { background:var(--accent-green-bg); border-color:var(--accent); }
.ob-line { font-family:var(--font-mono); font-size:var(--text-base); font-weight:700; color:var(--text-primary); line-height:1.25; }
.ob-price { font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-primary); line-height:1.25; }
.ob-prob { font-family:var(--font-mono); font-size:var(--text-sm); font-weight:700; color:var(--text-primary); line-height:1.25; }

.ob-strip { padding:8px 14px; border-top:1px solid var(--border-subtle); font-size:var(--text-base); color:var(--text-primary); }
.ob-strip-k { font-family:var(--font-mono); font-size:var(--text-xs); font-weight:700; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-secondary); margin-right:8px; }
.ob-pct { font-family:var(--font-mono); font-weight:700; color:var(--accent); }
/* Two figures used to share .ob-pct: a probability and the recommendation's
   EV. Green now means the recommendation, so they had to part company.
   .ob-pct stays green -- carried since 2026-09-06 by the bubble note
   ("missed our confidence floor by 0.4pp"), the "Where we see value"
   strip that used to be its example having been removed as a restatement
   of the verdict line; this is the probability ("Most likely to win
   58.0%"). */
.ob-pct-prob { font-family:var(--font-mono); font-weight:700; color:var(--text-primary); }
.ob-strip-bubble .ob-pct { color:var(--accent-amber); }
/* 'info' is the common case (an Over/Under play takes no side on the winner);
   only a genuine conflict earns amber. */
.ob-note { font-size:var(--text-sm); }
/* --text-secondary, not --text-muted: this note is meaning-carrying copy ("takes
   no side on the winner"), and the block header above requires the readable rung
   for anything carrying meaning. It is the COMMON case on MLB, so it must not be
   the least legible text on the card. */
.ob-note-info { color:var(--text-secondary); }
/* Board order banner (NCAAF: FBS games first, then FCS-vs-FCS; Bill 2026-10-08). */
/* Board sections (Bill, 2026-09-27): the first in-progress and first final card
   carry a heading; upcoming games need none. Hidden while a search filters. */
.board-section-h { margin:22px 0 10px; font-family:var(--font-mono); font-size:16px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; color:var(--text-secondary); }
.board-section-h[hidden] { display:none; }
.board-order-note { margin:0 0 10px; padding:10px 14px; border:1px solid var(--border-subtle); border-radius:var(--radius-lg); background:var(--bg-elevated); color:var(--text-secondary); font-size:var(--text-base); line-height:1.4; }
.ob-note-warn { color:var(--accent-amber); }
/* ADDED 2026-09-06 (NCAAF Phase 2a): opener-to-current line move strip. "with" =
   the market moved toward the side we recommend; against reuses ob-note-warn. */
.ob-move-with { color:var(--accent-green, #3fb950); }

/* The card opens the full drilldown takeover (2026-08-07, re-pointed
   2026-08-29 — see Slate.showGameDrilldown in static/js/slate.js). */
.ob-card[role="button"] { cursor:pointer; }
.ob-card[role="button"]:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
/* BLOCKING 1 fix (2026-08-29, unified board review round 2): j/k keyboard
   nav moves across board cards now (see slate.js:_attachKeyboardShortcuts —
   the slate rows this replaced are dead markup, tracked separately). Give
   that a focus ring distinct from mouse hover. */
.ob-card.kbd-focus { outline:2px solid var(--accent); outline-offset:-2px; }

/* The compact phone card (Bill, 2026-09-27) and its sheet (Bill, 2026-10-05;
   app.js _obSheetHandle, static/js/card_sheet.js; spec
   docs/superpowers/specs/2026-10-05-phone-game-card-sheet-design.md). Desktop
   never shows the handle and never folds. At <=639px a board card reads
   matchup, verdict, most likely, the actions row, then the handle at its
   bottom edge; the odds grid, line movement, freshness + provisional note and
   a started game's "Markets locked" line fold away, and the sheet shows them.
   Unknown children (the steam badge and anything added later) sit with the
   strips at order 3, never above the matchup. */
.ob-sheet-handle { display:none; }
@media (max-width: 639px) {
  .ob-card { display:flex; flex-direction:column; }
  .ob-card > * { order:3; }
  .ob-card > .ob-head { order:1; }
  .ob-card > .ob-verdict { order:2; }
  .ob-card > .ob-actions { order:4; }
  .ob-card > .ob-freshness { order:5; }
  .ob-card > .ob-locked { order:6; }
  .ob-card > .ob-box-wrap { order:7; }
  .ob-card > .ob-move { order:8; }
  .ob-card > .ob-sheet-handle { order:9; }
  .ob-card:not(.ob-sheet-card) > :is(.ob-freshness, .ob-locked, .ob-box-wrap, .ob-move) { display:none; }
  /* The handle: a full-width 44px strip to the touch, a 36x4px bar to the
     eye, and the old toggle's word. Only this strip (and the open sheet's
     grab bar) turns off the browser's panning: a swipe that starts anywhere
     else on the card still scrolls the page. */
  .ob-sheet-handle { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; width:100%; min-height:44px; margin:0; padding:6px 0 8px;
    border:0; border-top:1px solid var(--border-subtle); border-radius:0; background:transparent; color:var(--text-secondary);
    font-family:inherit; cursor:grab; touch-action:none; -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
  .ob-sheet-handle:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
  .ob-sheet-word { font-size:16px; font-weight:600; line-height:1.2; }
}
.ob-sheet-bar { display:block; width:36px; height:4px; border-radius:999px; background:var(--border-strong); }
/* The sheet. card_sheet.js mounts it on open and removes it on close, so it
   has no [hidden] state to pair. It runs from just under the 56px top bar to
   the screen bottom, OVER the bottom nav: it is a modal dialog, so a nav tab
   under it would be a dead or trap-breaking target; the content gains the
   nav's 65px; "Open full analysis" lands in the thumb zone; and the
   safe-area padding only means something at the screen edge. The settings
   sheet covers the nav the same way (z-index 9500). The backdrop dims the
   top bar (z-index 100), and a tap on it closes. Motion is transform only. */
.ob-sheet-backdrop { position:fixed; inset:0; z-index:9400; background:rgba(0,0,0,0.55); opacity:0; }
.ob-sheet-backdrop.ob-sheet-anim { transition:opacity 260ms linear; }
.ob-sheet { position:fixed; left:0; right:0; bottom:0; height:calc(100vh - 56px); height:calc(100dvh - 56px); z-index:9500; display:flex; flex-direction:column; box-sizing:border-box; background:var(--bg-surface); border-top:1px solid var(--border-default); border-radius:16px 16px 0 0; box-shadow:0 -8px 32px rgba(0,0,0,0.5); transform:translate3d(0,100%,0); will-change:transform; outline:none; }
.ob-sheet.ob-sheet-anim { transition:transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.ob-sheet-grab { flex:none; position:relative; display:flex; align-items:center; justify-content:center; min-height:44px; padding:0 8px; cursor:grab; touch-action:none; -webkit-user-select:none; user-select:none; }
.ob-sheet-close { position:absolute; top:0; right:4px; min-height:44px; padding:0 12px; border:0; background:transparent; color:var(--text-primary); font-family:inherit; font-size:16px; font-weight:600; cursor:pointer; }
.ob-sheet-body { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; }
.ob-sheet .ob-sheet-card { display:block; margin:0; border:0; border-radius:0; box-shadow:none; background:transparent; overflow:visible; }
.ob-sheet-foot { flex:none; padding:8px 12px calc(8px + env(safe-area-inset-bottom)); border-top:1px solid var(--border-subtle); background:var(--bg-surface); }
.ob-sheet-full { display:block; width:100%; min-height:44px; border:0; border-radius:var(--radius-sm); background:var(--accent); color:#0d1117; font-family:inherit; font-size:16px; font-weight:600; cursor:pointer; }
.ob-sheet-close:focus-visible, .ob-sheet-full:focus-visible { outline:2px solid var(--text-primary); outline-offset:2px; }
@media (prefers-reduced-motion: reduce) {
  .ob-sheet.ob-sheet-anim, .ob-sheet-backdrop.ob-sheet-anim { transition:none; }
}

/* 2026-09-06: the collapsed "no play" disclosure. Same rules at both
   viewports -- no new media block -- because the row is already a single
   truncating line and has nothing left to shed on a narrow screen.
   Font sizes stay at or above the 12px floor (tests/test_juice_font_floor.py,
   var(--text-xs) resolves to 12px). */
/* min-height:44px keeps the row a comfortable touch target -- the repo raised
   board controls to 44px in the 2026-08-11 review; do not regress that. */
/* min-width:0 is load-bearing: a flex item will not shrink below its content
   without it, which is the exact bug fixed in the takeover header on
   2026-09-05 -- without this the state label gets pushed off the row. */

@media (max-width: 768px) {
  .ob-box { table-layout:fixed; }
  .ob-box thead th, .ob-box td { padding:6px 3px; }
  .ob-box thead th:first-child { width:36%; } /* 29% until the 16px floor: 94px broke DIAMONDBACKS mid-word (2026-09-11) */
  .ob-box tr.ob-subhead { display:none; }
  .ob-legend-phone { display:block; font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-muted); text-align:right; padding:6px 10px 2px; line-height:1.3; }
  /* O/U on phones: OVER/UNDER measured 112px in a 77px column and was the ONLY
     cell protruding -- the 35px sideways scroll of the whole table (2026-09-11).
     The long form stays for screen readers via the clip pattern. */
  .ob-th-long { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
  .ob-th-short { display:inline; }
  .ob-box tr.ob-subhead th { padding:0 3px 6px; } /* was 0 8px 8px: 10px more for 'line · price · win prob' to wrap in two lines, not three */
  .ob-head, .ob-strip, .ob-locked { padding-left:10px; padding-right:10px; }
  .ob-team { padding:6px 4px 6px 10px; font-size:var(--text-xs); text-transform:none; white-space:normal; hyphens:auto; overflow-wrap:anywhere; } /* title case + 16px: 'Diamondbacks' is 98px in the 103px cell; caps at 17px was 120px and broke mid-word. hyphens:auto is a bonus where the OS has a dictionary */
  .ob-cell { min-width:0; width:100%; padding:4px 2px; }
  .ob-line, .ob-price, .ob-prob { max-width:100%; overflow:hidden; text-overflow:ellipsis; }
  /* FIX 2026-08-05: the GLOBAL `th { white-space:nowrap }` (main.css:401) also
     catches this sub-header, so "line · price · win prob" was held on one line
     and forced the table to 422px inside a 325px phone column — pushing the
     Over/Under column, MLB's only unsuppressed market, off-screen by default.
     Letting it wrap brings the table to 333px. Same global rule was already
     fixed once for team-name headers (see the note at main.css:1999). The
     sub-header is the only in-place explanation of what the green number
     means, so it wraps rather than being shortened or dropped. */
  .ob-box tr.ob-subhead th { white-space:normal; line-height:1.3; }
}

/* Until 2026-09-12 a (max-width: 375px) rule hid .ob-prob to keep three
   market columns on the narrowest phones. The 2026-09-11 phone odds-table
   pass (O/U header, one-line legend, 36% team column) made the table fit
   with the probability stacked under the price, and for a free visitor the
   market probability is the ONE number the card promises -- so it shows at
   every width now. Measured at 375px: 16px mono "60.4%" is 54px in a 69px
   column, no cell spills. */

/* Verdict strip (2026-08-08) — markup: renderVerdictStrip() in app.js.
   Same 12px floor as everything else here (tests/test_type_scale_floor.py). */
.ob-verdict { display:flex; justify-content:space-between; align-items:center;
  gap:8px; flex-wrap:wrap; padding:7px 10px; border-top:1px solid var(--border);
  font-size:17px; color:var(--text-secondary); }
.ob-verdict-play { border-top-color:var(--accent); color:var(--text-primary); }
.ob-verdict-ev { color:var(--accent); font-weight:600; }
/* Your-book EV (2026-09-13): what the recommendation above is worth at the
   user's own book -- inherits .ob-verdict's 17px, above the 16px floor.
   Deliberately NEUTRAL, never green: .ob-verdict-ev already owns green, for
   the MODEL's own recommendation, not what the user's own book pays. */
.ob-verdict-mine { color: var(--text-secondary); font-weight: 500; }
.ob-verdict-mine.neg { color: var(--accent-red); }
.ob-verdict-none { color:var(--text-secondary); }
/* A visual affordance, not a control: the whole .ob-card is already
   role="button" with its own click and Enter/Space handlers, so this stays a
   <span>. A <button> here would nest an interactive element inside
   role="button".
   INVERTED 2026-09-20, with the takeover's Back-to-slate pill (slate.css
   .slate-takeover-back; Bill: "fix the open analysis pill too"): the entry
   and the exit of the same journey, one look. Until then the fill was
   --bg-elevated with a 16% ring, where the ring carried all the contrast at
   ~1.74:1 against the card -- the 2026-08-31 transparent fill + hairline had
   been invisible, and that 09-01 step was judged barely better. Now a
   --text-primary fill with the dark ink the feedback FAB puts on a bright
   fill (~17:1), bold. Not green: green is the model's pick (.ob-verdict-ev,
   .ob-verdict-play's rule), and this is navigation. The .ob-verdict strip's
   own color (--text-primary on a play) never reaches the pill: it declares
   its own. Pinned by tests/test_signal_stack_css.py. */
/* 2026-09-12 NCAAF derivative markets: the card-level tag for a 1H / team O/U play. */
.ob-deriv-tag { display:inline-block; margin-left:8px; padding:1px 8px; border-radius:999px; background:var(--bg-elevated); border:1px solid var(--border-subtle); color:var(--text-secondary); font-size:16px; white-space:nowrap; }

/* Experts chip (spec 2026-10-08): "N experts" in the verdict row; opens the experts dialog.
   16px is the board's type floor. Guarded by tests/test_slate_touch_targets.py. */
.ob-experts-chip {
  font-size: 16px; font-family: inherit; line-height: 1.2; padding: 2px 10px; border-radius: 999px;
  border: 1px solid var(--accent-blue); background: transparent; color: var(--accent-blue);
  cursor: pointer; white-space: nowrap;
}
.ob-experts-chip:hover { background: var(--bg-elevated); }
@media (max-width: 639px) {
  .ob-experts-chip { min-height: 44px; display: inline-flex; align-items: center; }
}

/* The experts dialog. [hidden] must follow every rule that sets display (hidden-vs-display guard). */
.ce-exp-backdrop {
  position: fixed; inset: 0; z-index: 9999; background: rgba(0, 0, 0, 0.55);
  display: flex; align-items: center; justify-content: center; padding: 16px;
}
.ce-exp-backdrop[hidden] { display: none; }
.ce-exp-dialog {
  background: var(--bg-card); color: var(--text-primary); border-radius: 12px;
  max-width: 480px; width: 100%; max-height: 85vh; overflow: auto; padding: 16px 18px; font-size: 16px;
  line-height: 1.5; outline: none;
}
.ce-exp-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.ce-exp-title { font-size: 18px; margin: 0; }
.ce-exp-close {
  min-width: 44px; min-height: 44px; font-size: 24px; background: transparent; border: 0; color: inherit;
  cursor: pointer;
}
.ce-exp-desc, .ce-exp-free, .ce-exp-framing { color: var(--text-secondary); margin: 8px 0; }
.ce-exp-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
.ce-exp-btn, .ce-exp-link { font-size: 16px; min-height: 44px; display: inline-flex; align-items: center; }
.ce-exp-btn {
  padding: 0 14px; border-radius: 8px; border: 1px solid var(--accent-blue); background: transparent;
  color: var(--accent-blue); cursor: pointer; font-family: inherit;
}
.ce-exp-link { color: var(--accent-blue); }
.ce-exp-rank {
  flex-basis: 100%; justify-content: center; font-size: 16px; min-height: 44px; display: inline-flex;
  align-items: center; padding: 0 14px; border-radius: 8px; background: var(--accent-blue); color: #0d1117;
  font-weight: 600; text-decoration: none;
}
.ob-verdict-go { font-size:16px; color:#0d1117; font-weight:700; white-space:nowrap;
  display:inline-flex; align-items:center; padding:3px 10px;
  background:var(--text-primary);
  border:1px solid var(--text-primary); border-radius:999px; }
/* The phone sheet (card_sheet.js): the card's "open analysis" words promise a
   tap the sheet's card does not answer; its "Open full analysis" button is that
   tap. After the pill rule above, which tests/test_signal_stack_css.py finds first. */
.ob-sheet .ob-verdict-go { display:none; }

/* ── Analytics yardstick ─────────────────────────────────────────────────
   Shown above the Analytics figures, not below them: after the numbers it
   reads as an excuse, before them it is a legend for the chart. */
.analytics-yardstick {
  position: relative;
  grid-column: 1 / -1;
  border: 1px solid var(--border-default);
  border-left: 3px solid var(--accent);
  background: var(--bg-card);
  border-radius: 6px;
  padding: 1.1rem 2.4rem 1rem 1.2rem;
  margin: 0 0 1rem;
  max-width: 46rem;
}
.analytics-yardstick h3 { margin: 0 0 0.5rem; font-size: 1.125rem; }
.analytics-yardstick p { margin: 0 0 0.6rem; line-height: 1.55; }
.analytics-yardstick .ay-lead { font-weight: 700; font-size: 1.125rem; }
.analytics-yardstick .ay-punch { font-weight: 700; margin-top: 0.8rem; }
.analytics-yardstick .ay-meta { color: var(--text-secondary); font-size: 1rem; margin-bottom: 0; }
/* The way from a board fact to the tool it argues for (board_facts.js
   panel.link, 2026-09-26). Inline text link, padded to a 44px tap row. */
.analytics-yardstick .ay-link { display: inline-block; padding: 10px 0; color: var(--accent); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
.analytics-yardstick .ay-link:hover { color: var(--text-primary); }
.analytics-yardstick .ay-ledger {
  border-collapse: collapse;
  margin: 0.6rem 0 0.9rem;
  font-variant-numeric: tabular-nums;
  width: 100%;
  max-width: 26rem;
}
.analytics-yardstick .ay-ledger td {
  padding: 0.32rem 0.8rem 0.32rem 0;
  border-bottom: 1px solid var(--border-default);
}
/* A label with no working spans the calc column (app.js _showPanelFact), so
   the label column is only as wide as the labels beside working. Those keep
   to one line and the working wraps instead: without this, "At -110" split
   into "At" / "-110" at 375px (exchanges launch Plan B, Task 9). */
.analytics-yardstick .ay-ledger td:first-child:not([colspan]) { white-space: nowrap; }
.analytics-yardstick .ay-ledger .num {
  text-align: right;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.analytics-yardstick .ay-ledger .pos { color: var(--ev-pos); }
.analytics-yardstick .ay-ledger .neg { color: var(--ev-neg); }
.analytics-yardstick .ay-ledger tr.tot td {
  font-weight: 700;
  border-bottom: none;
  border-top: 2px solid var(--border-default);
}
.analytics-yardstick .ay-close {
  position: absolute; top: 0.5rem; right: 0.6rem;
  background: none; border: none; cursor: pointer;
  color: var(--text-secondary); font-size: 1.4rem; line-height: 1;
  padding: 0.2rem 0.4rem;
}
.analytics-yardstick .ay-close:hover { color: var(--text-primary); }
@media (max-width: 640px) {
  .analytics-yardstick { padding: 1rem 2.2rem 0.9rem 1rem; }
  .analytics-yardstick .ay-ledger { font-size: 1.0625rem; }
}

/* ── Yardstick callout (board) ────────────────────────────────────────────
   Was a slim one-line strip and it disappeared -- understated to the point of
   invisible on the surface where it has the most work to do. It now reads as
   a card with a number in it, and the whole card is the tap target. */
.yardstick-strip {
  display: flex;
  align-items: stretch;
  gap: 0;
  border: 1px solid var(--accent);
  border-left: 4px solid var(--accent);
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--accent) 10%, var(--bg-card)) 0%,
    var(--bg-card) 55%
  );
  border-radius: 8px;
  margin: 0 0 12px;
  overflow: hidden;
}
/* The display:flex above outranks the UA [hidden] rule, so without this the
   X saved the dismissal and the strip stayed on screen (fixed 2026-09-28). */
.yardstick-strip[hidden] { display: none; }
.yardstick-strip .ys-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 3px;
  align-items: flex-start;
  text-align: left;
  background: none;
  border: none;
  cursor: pointer;
  font-family: inherit;
  color: var(--text-primary);
  /* Right side 5px, not 14: it pays for the 44px X below, so the text
     column is as wide as before (tests/test_yardstick_mobile_fold.py). */
  padding: 12px 5px 12px 14px;
}
.yardstick-strip .ys-body:hover { background: rgba(255, 255, 255, 0.03); }
.yardstick-strip .ys-eyebrow {
  /* 12px is the project's accessibility floor (tests/test_type_scale_floor.py).
     Shrinking an eyebrow to fit is the wrong trade on a product whose users
     include people with vision and motor impairments. */
  font-size: 16px;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--accent);
  font-weight: 700;
}
.yardstick-strip .ys-hook {
  font-size: 1.125rem;
  font-weight: 700;
  line-height: 1.35;
}
.yardstick-strip .ys-loss { color: var(--ev-neg); }
.yardstick-strip .ys-gain { color: var(--ev-pos); }
.yardstick-strip .ys-cta {
  font-size: 1rem;
  color: var(--accent);
  font-weight: 600;
}
.yardstick-strip .ys-dismiss {
  align-self: flex-start;
  background: none;
  border: none;
  cursor: pointer;
  color: var(--text-muted);
  font-size: 1.25rem;
  line-height: 1;
  padding: 10px 12px;
  /* Was 36x40. A tap that missed it to the left hit the card, which OPENS
     the panel -- the opposite of what the reader meant (2026-09-28). */
  min-width: 44px;
  min-height: 44px;
}
.yardstick-strip .ys-dismiss:hover { color: var(--text-primary); }
@media (max-width: 640px) {
  /* The strip costs the board its fold on a phone, so on mobile it keeps the
     argument and drops the packaging.

     Measured on prod 2026-09-09 at 375x812: the board starts at 221px -- the
     figure the mobile-fold work bought -- and this strip added 106px on top,
     putting the first card at 339px. With a 541px card that needs 880px
     against an 812px viewport, so NO game fitted on one screen: the exact
     condition the 2026-09-06 board spec called a failure.

     Of those 106px, the hook is 42 and carries the whole point. The eyebrow is
     a label for an argument that states itself, and the CTA is a cue rather
     than the control -- the whole card is the control (see the markup comment
     in templates/index.html), so hiding the CTA text costs an affordance, not
     an action. A chevron keeps the affordance at no extra height.

     Type is NOT shrunk to buy this: 12px is the project's accessibility floor
     and the hook stays at its readable size. Height comes from removing
     elements, not from making the remaining ones harder to read. */
  .yardstick-strip { margin-bottom: 8px; }
  .yardstick-strip .ys-body { padding: 9px 3px 9px 12px; gap: 0; }
  .yardstick-strip .ys-hook { font-size: 1.0625rem; }
  .yardstick-strip .ys-eyebrow { display: none; }
  .yardstick-strip .ys-cta { display: none; }
  /* The affordance the hidden CTA carried, folded into the hook's own line. */
  .yardstick-strip .ys-hook::after {
    content: ' \2192';
    color: var(--accent);
    font-weight: 700;
    white-space: nowrap;
  }
}

/* Sport chip above a card on the All Sports board (2026-09-09). Only that
   mixed board renders it; on a single-sport board the tab already says which
   sport it is. 12px is the floor the push gate enforces -- anything smaller
   fails tests/test_css_type_floor.py, so shorten the text, never the type. */
.ob-sport-chip {
  font-size: 16px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  opacity: 0.72;
  margin: 18px 0 4px;
}

/* Kickoff on a board card (2026-09-09): date AND start time, in ET, always
   both. Asked for after a card gave no way to tell tonight.s game from
   Sunday.s without opening it. 12px is the floor the push gate enforces --
   shorten the text, never the type. */
.ob-when {
  font-size: 16px;
  font-weight: 500;
  opacity: 0.68;
  margin-left: 10px;
  white-space: nowrap;
}
/* FIX 2026-10-06: at 320px in IBM Plex a long kickoff ("Tomorrow 8:26 PM ET") ran past the card's
   edge beside the matchup; the system fallback the tests used to measure in hid it. Under 341px the
   time takes its own line. tests/test_rendered_text_clipping.py, now measured in Plex. */
@media (max-width: 340px) { .ob-when { display:block; margin-left:0; } }

/* Tier locks (2026-09-11). The blurred card holds fixed dummy text; nothing to scrape. */
.ce-locked-card { position: relative; display: inline-flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%; padding: 4px 8px; border: 1px dashed var(--border-default); border-radius: 6px; background: var(--bg-elevated); color: var(--text-primary); cursor: pointer; font: inherit; }
/* M4: hover and focus-visible are SPLIT. Sharing one rule with `outline:none`
   meant keyboard focus on a locked card showed only a border tint -- the same
   thing a mouse hover does -- so a keyboard user could not tell where they
   were. Focus keeps a real 2px outline. */
.ce-locked-card:hover { border-color: var(--accent); }
.ce-locked-card:focus-visible { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 2px; }
/* The inline variant (renderVerdictStrip) is inert decoration inside a
   role="button" ancestor: no pointer affordance of its own. */
.ce-locked-card.ce-locked-inline { cursor: inherit; }
.ce-locked-blur { display: flex; flex-direction: column; gap: 2px; filter: blur(4px); user-select: none; pointer-events: none; font-size: var(--text-xs); line-height: 1.2; color: var(--text-muted); min-width: 0; overflow: hidden; }
.ce-locked-badge { font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--accent); white-space: nowrap; }
.tier-upgrade-link { margin-left: 6px; font-size: var(--text-xs); color: var(--accent); text-decoration: none; }
.nav-locked { opacity: 0.65; }
.nav-locked:hover { opacity: 0.85; }
.ce-locked-note { font-size: var(--text-xs); color: var(--text-muted); padding: 8px 0; }
.ce-locked-panel { padding: 16px; border: 1px dashed var(--border-default); border-radius: 6px; color: var(--text-muted); font-size: var(--text-xs); display: flex; gap: 12px; align-items: center; justify-content: space-between; }
.ce-locked-panel button { font: inherit; }
/* I10: 10001, ABOVE the tour offer card (.tour-offer, 10000). At z-index 1000
   the panel rendered behind the tutorial's welcome card, so a lock clicked with
   the card open opened a modal the user could not see. */
.ce-upgrade-panel { position: fixed; inset: 0; z-index: 10001; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.55); padding: 16px; }
.ce-upgrade-panel[hidden] { display: none; }
.ce-upgrade-card { position: relative; width: min(440px, 100%); background: var(--bg-surface); border: 1px solid var(--border-default); border-radius: 8px; padding: 24px; color: var(--text-primary); }
.ce-upgrade-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted); font-size: 22px; cursor: pointer; }
/* M4: the close button is the panel's first focus stop, so it needs a
   visible ring -- without one, opening the panel moved focus somewhere the
   keyboard user could not see. */
.ce-upgrade-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.ce-upgrade-eyebrow { font-size: var(--text-xs); letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); }
.ce-upgrade-title { font-size: 20px; margin: 6px 0 12px; line-height: 1.25; }
.ce-upgrade-lines { margin: 0 0 16px; padding-left: 18px; color: var(--text-primary); font-size: var(--text-sm); line-height: 1.5; }
.ce-upgrade-cta { display: inline-block; background: var(--accent); color: #0d1117; padding: 10px 16px; border-radius: 6px; font-weight: 600; text-decoration: none; }
.ce-upgrade-fine { margin: 12px 0 0; font-size: var(--text-xs); color: var(--text-muted); }
body.ce-panel-open { overflow: hidden; }
/* M4: these two wrappers were emitted by slate.js / app.js with no rules at
   all, so the locked card sat flush against its neighbours. */
.slate-drilldown-locked { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 0; }
.ob-verdict-locked { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 4px 0; }

/* Board-quiet notice (2026-09-25). Sits above Best Bets, reads as an
   explanation rather than a promo: no CTA, no colour that competes with a
   pick. Type stays at the 16px floor -- the copy was shortened instead. */
.quiet-notice {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  margin: 0 0 16px;
  padding: 14px 16px;
  border: 1px solid var(--border, rgba(255, 255, 255, 0.12));
  border-left: 3px solid var(--accent, #4a9eff);
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.03);
}
/* Same trap as .yardstick-strip[hidden]: display:flex beats the UA rule. */
.quiet-notice[hidden] { display: none; }
.quiet-notice .qn-body { flex: 1 1 auto; min-width: 0; }
.quiet-notice .qn-eyebrow {
  display: block;
  font-size: 16px;
  font-weight: 700;
  margin-bottom: 6px;
  color: var(--text, #e8e8e8);
}
.quiet-notice .qn-text {
  margin: 0 0 6px;
  font-size: 16px;
  line-height: 1.45;
  color: var(--text-muted, #b8b8b8);
}
.quiet-notice .qn-text:last-child { margin-bottom: 0; }
.quiet-notice .qn-muted { color: var(--text-dim, #8a8a8a); }
.quiet-notice .qn-dismiss {
  flex: 0 0 auto;
  width: 44px;
  height: 44px;
  font-size: 22px;
  line-height: 1;
  background: none;
  border: 0;
  border-radius: 6px;
  color: var(--text-dim, #8a8a8a);
  cursor: pointer;
}
.quiet-notice .qn-dismiss:hover { background: rgba(255, 255, 255, 0.06); }
@media (max-width: 639px) {
  .quiet-notice { padding: 12px 12px; gap: 8px; }
}

/* ── Exchanges launch (Plan B, spec 2026-09-27 section 4) ───────────────────
   B3: the Exchanges tab's "New" pill, its mirror in the phone menu, and the
   dot on the menu icon while it shows. 16px like everything else: the type
   floor holds even for a ten-day badge. Neutral ink, never green: green marks
   a graded call (a BUY), and a launch badge must not borrow it. Light on
   dark also holds under .nav-locked's 0.65 opacity, which dims this pill on a
   free visitor's locked tab (tests/test_exchanges_badge.py checks the
   contrast). */
.nav-new-pill {
  display: inline-block;
  margin-left: 6px;
  padding: 0 7px;
  border-radius: var(--radius-pill);
  background: var(--text-primary);
  color: var(--bg-page);
  font-size: 16px;
  font-weight: 700;
  line-height: 22px;
  vertical-align: middle;
}
.nav-new-pill[hidden] { display: none; }
.top-nav-hamburger.has-new { position: relative; }
.top-nav-hamburger.has-new::after {
  content: '';
  position: absolute;
  top: 1px;
  right: 1px;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--text-primary);
  /* A ring in the bar's own colour cuts the dot free of the top bar of the icon. */
  box-shadow: 0 0 0 2px var(--bg-surface);
}

/* Plan D: the Exchanges tour's layer over the board. static/js/exchanges_tour_launcher.js makes it on launch
   and removes it on close; the deck is its own page (static/tutorial/tours/exchanges/index.html) in the frame.
   Level with the tutorial's fullscreen player (10001), above the board's fixed buttons. Kept before the B2
   block, which holds no fixed layer (tests/test_exchanges_first_open.py). */
.ex-tour { position: fixed; inset: 0; z-index: 10001; display: flex; background: #06080c; }
.ex-tour-frame { flex: 1 1 auto; width: 100%; height: 100%; border: 0; }
body.ex-tour-open { overflow: hidden; }
/* Tutorial revamp (spec 2026-10-08 sections 3, 7.1): the same layer for any chassis deck (the core, What's New),
   made and removed by static/js/tour_layer.js (CETourLayer). The Exchanges tour keeps its own above. */
.tour-layer { position: fixed; inset: 0; z-index: 10001; display: flex; background: #06080c; }
.tour-layer-frame { flex: 1 1 auto; width: 100%; height: 100%; border: 0; }
body.tour-open { overflow: hidden; }

/* B2: the first-open panel and its "How this works" link, at the top of the
   Exchanges board (templates/partials/_exchanges_first_open.html). Controls
   are 44px tall; nothing is under the 16px floor. Neutral ink throughout:
   green marks a graded call (a BUY), and this panel is not one. It sits in
   the board's flow (no overlay, no fixed layer), so a phone scrolls past it. */
.exl-howto-row { display: flex; justify-content: flex-end; margin-bottom: var(--space-sm); }
.exl-howto-link {
  background: none;
  border: none;
  color: var(--text-primary);
  font: inherit;
  font-size: var(--text-sm);
  min-height: 44px;
  padding: 0 var(--space-xs);
  cursor: pointer;
  text-decoration: underline;
}
.exl-panel {
  background: var(--bg-card);
  border: 1px solid var(--border-default);
  border-left: 3px solid var(--text-muted);
  border-radius: var(--radius-md);
  padding: var(--space-lg);
  margin: 0 0 var(--space-md);
  color: var(--text-secondary);
  font-size: var(--text-sm);
  line-height: 1.5;
  overflow-wrap: break-word;
}
.exl-panel[hidden], .exl-ask[hidden] { display: none; }
.exl-q { margin: 0 0 var(--space-sm); font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
.exl-answers { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: 0 0 var(--space-md); }
.exl-answer, .exl-gotit, .exl-tour-play {
  min-height: 44px;
  padding: 0 var(--space-lg);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--bg-detail);
  color: var(--text-primary);
  font: inherit;
  font-size: var(--text-sm);
  cursor: pointer;
}
.exl-notnow {
  min-height: 44px;
  padding: 0 var(--space-sm);
  background: none;
  border: none;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--text-sm);
  cursor: pointer;
  text-decoration: underline;
}
.exl-howto-title { margin: 0 0 var(--space-xs); font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
.exl-howto-text { margin: 0 0 var(--space-md); }
.exl-actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); }
.exl-howto-link:focus-visible, .exl-answer:focus-visible, .exl-notnow:focus-visible, .exl-gotit:focus-visible,
.exl-tour-play:focus-visible,
.exl-q:focus-visible, .exl-howto-title:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }
@media (max-width: 640px) {
  .exl-panel { padding: var(--space-md); }
  /* Follow-up (Bill 2026-09-29): a phone asks the question alone (486px -> 240px at 375px);
     How to read this shows once it is answered, or with an offer of the tour. The fold ruling (same
     day) also takes the "How this works" row and Got it away while an OPEN panel asks: an
     answer or Not now brings both back. Plan D's tour button waits with them. */
  .exl-panel:has(.exl-ask:not([hidden])) .exl-howto-title,
  .exl-panel:has(.exl-ask:not([hidden])) .exl-howto-text,
  .exl-panel:has(.exl-ask:not([hidden])) .exl-gotit,
  .exl-panel:has(.exl-ask:not([hidden])) .exl-tour-play,
  .exl-howto-row:has(+ .exl-panel:not([hidden]) .exl-ask:not([hidden])) { display: none; }
}
/* An open card hides the board (exchanges_app.js); the launch chrome goes with it. */
#view-exchanges:has(#ex-card:not([hidden])) .exl-howto-row,
#view-exchanges:has(#ex-card:not([hidden])) .exl-panel { display: none; }

/* Exchanges launch: Phone filters (Bill 2026-09-29, the fold ruling). At <=640px, the first-open
   panel's phone breakpoint, the sport, venue and market selects fold behind one "Filters" button
   (static/js/exchanges_phone_filters.js), so the board climbs toward the first screen. The order
   toggle and the team search stay outside. On a desktop the wrapper is display: contents, so the
   three labels stay flex items of .ex-filters exactly as before, and the button never shows. */
.ex-filter-set { display: contents; }
@media (max-width: 640px) {
  .ex-filter-set { display: flex; flex-direction: column; gap: var(--space-sm); }
  .ex-filters-toggle {
    display: inline-flex;
    align-items: center;
    align-self: flex-start;
    max-width: 100%;
    min-height: 44px;
    padding: var(--space-xs) var(--space-md);
    background: var(--bg-card);
    color: var(--text-primary);
    border: 1px solid var(--border-subtle);
    border-radius: 6px;
    font: inherit;
    font-size: var(--text-sm);
    text-align: left;
    cursor: pointer;
  }
  /* A chevron for the state; `/ ''` gives it no spoken name (aria-expanded already says it). */
  .ex-filters-toggle::after { content: '\25BE' / ''; padding-left: var(--space-xs); }
  .ex-filters-toggle[aria-expanded="true"]::after { content: '\25B4' / ''; }
  .ex-filters-toggle:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }
  /* Part 2: the board's own How to read this line folds behind a one-line button (the module
     re-applies the fold after every board paint). The copy is Plan A's, untouched. */
  .ex-howto-toggle {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    margin: 0 0 var(--space-sm);
    padding: 0 var(--space-xs) 0 0;
    background: none;
    border: none;
    color: var(--text-primary);
    font: inherit;
    font-size: var(--text-sm);
    font-weight: 600;
    text-decoration: underline;
    text-underline-offset: 3px;
    cursor: pointer;
  }
  .ex-howto-toggle::after { content: '\25BE' / ''; padding-left: var(--space-xs); }
  .ex-howto-toggle[aria-expanded="true"]::after { content: '\25B4' / ''; }
  .ex-howto-toggle:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }
  /* Unfolded, the line opens with its own bold "How to read this." directly under the button of
     the same name: the button says it, so the label goes on a phone (the copy after it stays).
     No `hidden` toggles the label, so there is no [hidden] pairing to owe. */
  .ex-howto-label { display: none; }
  /* An open card hides the board (exchanges_app.js); its button goes with it. */
  #view-exchanges:has(#ex-card:not([hidden])) .ex-howto-toggle { display: none; }
}
/* After every display rule above: `hidden` must win (tests/test_hidden_needs_display_override.py). */
.ex-filter-set[hidden], .ex-filters-toggle[hidden] { display: none; }
.ex-howto-toggle[hidden], .ex-howto[hidden] { display: none; }
