/* Type pairing: IBM Plex Sans (UI/headings) + IBM Plex Mono (tabular data) --
   built by IBM specifically for dense enterprise data surfaces, real tabular-
   figure support, and a distinct personality instead of the generic Inter/
   system-ui default most dashboards reach for. Loaded via the Google Fonts
   <link> in base.html's <head>, not @font-face here -- both families ship
   through Google's own CDN with proper per-browser subsetting, so there's no
   benefit to hand-picking individual woff2 files ourselves.

   Palette from the dataviz skill's validated reference (references/palette.md)
   -- chart-facing tokens (--series-*, --seq-*, --status-*) are left exactly as
   validated (CVD-safe adjacent pairs); only the UI-chrome tokens below them
   (surfaces, accent, shadows, radii, type scale) are new.
   Light/dark both selected, swapped via prefers-color-scheme (no viewer toggle
   in this standalone app, so OS setting is the only signal). */
:root {
    color-scheme: light;
    --surface-1:      #fcfcfb;   /* chart / card surface */
    --surface-2:      #f2f1ec;   /* recessed surface -- inputs, track backgrounds */
    --page-plane:     #f9f9f7;
    --text-primary:   #0b0b0b;
    --text-secondary: #52514e;
    --text-muted:     #898781;
    --gridline:       #e1e0d9;
    --baseline:       #c3c2b7;
    --border:         rgba(11,11,11,0.10);
    --success-text:   #006300;

    /* categorical, fixed order -- validated adjacent-pair safe */
    --series-1: #2a78d6; /* blue    -> HR */
    --series-2: #eb6834; /* orange  -> Hit */
    --series-3: #1baf7a; /* aqua    -> 2+ TB */
    --series-4: #eda100; /* yellow  -> BB */

    /* sequential (blue) ramp, light->dark, for meter tracks */
    --seq-100: #cde2fb;
    --seq-200: #9ec5f4;
    --seq-400: #3987e5;
    --seq-500: #256abf;

    /* Hit value is ORDINAL (1B < 2B < 3B < HR), so it gets a sequential ramp, not four
       unrelated categorical hues. The old palette reused --series-* and failed on its own
       terms: 1B/3B measured 13.7 OKLab apart and 1B/HR 10.8, both under the 15 floor, which
       is why singles, triples and homers all read as "orange-ish". Spray dots interleave, so
       ALL pairs have to separate, not just adjacent ones.
       The ramp traverses hue as it darkens (amber -> orange -> crimson -> plum) because four
       steps of a single hue cannot clear the floor inside a usable lightness range -- the
       gaps land at ~13. Worst pair here is 15.2 normal / 8.6 simulated-CVD.
       Light mode runs light -> dark with value, which also puts the emphasis right: singles
       are ~65% of hits and stay recessive, homers are rare and read darkest. */
    --hit-1b: #f5b32b;
    --hit-2b: #e35c28;
    --hit-3b: #a82a52;
    --hit-hr: #4f1a3d;

    --status-good: #0ca30c;
    --status-warning: #fab219;
    --status-serious: #ec835a;
    --status-critical: #d03b3b;

    /* accent -- a deeper, warmer navy than the chart blue, so UI chrome (nav,
       buttons, focus rings) reads as a deliberate brand color rather than
       reusing the data-series blue for two different jobs. */
    --accent:       #1d3f6e;
    --accent-hover: #163055;
    --accent-tint:  #e7edf5;

    --radius-sm: 6px;
    --radius-md: 10px;
    --radius-lg: 16px;
    --shadow-sm: 0 1px 2px rgba(11,11,11,0.05);
    --shadow-md: 0 3px 10px rgba(11,11,11,0.07);
    --shadow-lg: 0 10px 28px rgba(11,11,11,0.10);

    --font-ui:   'IBM Plex Sans', system-ui, -apple-system, "Segoe UI", sans-serif;
    --font-mono: 'IBM Plex Mono', ui-monospace, "SF Mono", Menlo, monospace;
}

@media (prefers-color-scheme: dark) {
    :root {
        color-scheme: dark;
        --surface-1:      #1a1a19;
        --surface-2:      #242422;
        --page-plane:     #0d0d0d;
        --text-primary:   #ffffff;
        --text-secondary: #c3c2b7;
        --text-muted:     #898781;
        --gridline:       #2c2c2a;
        --baseline:       #383835;
        --border:         rgba(255,255,255,0.10);
        --success-text:   #0ca30c;

        --series-1: #3987e5;
        --series-2: #d95926;
        --series-3: #199e70;
        --series-4: #c98500;

        /* Dark keeps each category's HUE so a theme toggle never repaints a category into a
           different colour -- only the steps move. They have to move: the light ramp's plum
           terminal is near-black and would vanish against the field. Re-stepped and
           re-validated against the dark field below, not flipped. */
        --hit-1b: #f0b83c;
        --hit-2b: #e2703a;
        --hit-3b: #c03a5c;
        --hit-hr: #9648b0;

        --accent:       #6fa1e0;
        --accent-hover: #8bb3e8;
        --accent-tint:  rgba(111,161,224,0.16);

        --shadow-sm: 0 1px 2px rgba(0,0,0,0.24);
        --shadow-md: 0 3px 10px rgba(0,0,0,0.32);
        --shadow-lg: 0 10px 28px rgba(0,0,0,0.42);
    }
}

* { box-sizing: border-box; }

body {
    font-family: var(--font-ui);
    margin: 0;
    background: var(--page-plane);
    color: var(--text-primary);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

html { max-width: 100%; }

nav {
    background: var(--surface-1);
    padding: 0 24px;
    border-bottom: 1px solid var(--border);
    display: flex;
    gap: 4px;
    align-items: center;
    height: 58px;
    position: sticky;
    top: 0;
    z-index: 10;
}
/* Wordmark uses the mono face (already loaded for tabular data) rather than the
   UI sans -- reads as a terminal/CLI mark, which suits a stats tool, and reuses
   an existing font rather than pulling a third family. Slight positive tracking
   because mono at small sizes sets tight. */
nav .brand {
    /* Anchor, not a span, since 2026-08-04 -- the wordmark links home. Typeface and
       letter-spacing now come from the shared .wordmark class so the nav and the landing-page
       h1 cannot drift apart; this rule keeps only what is specific to the nav bar. */
    text-decoration: none;
    font-size: 15px;
    font-weight: 600;
    margin-right: 22px;
    color: var(--text-primary);
    white-space: nowrap;
}
/* The prompt caret is decorative -- aria-hidden in the markup so screen readers
   read the name, not "greater-than sign". */
nav .brand .caret { color: var(--accent); margin-right: 2px; }
nav .brand:hover { color: var(--accent); }
nav .brand:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 3px; }
.nav-links { display: flex; gap: 4px; align-items: center; }
nav a {
    color: var(--text-secondary);
    text-decoration: none;
    font-weight: 500;
    font-size: 14px;
    padding: 8px 14px;
    border-radius: var(--radius-sm);
    white-space: nowrap;
    transition: background 0.15s ease, color 0.15s ease;
}
nav a:hover { color: var(--text-primary); background: var(--gridline); }
nav a.active { color: var(--accent); font-weight: 600; background: var(--accent-tint); }

/* Hamburger -- hidden on desktop, shown below the breakpoint where the full
   link row no longer fits (7 labels including "Pitch Mix Analysis" and
   "Model Performance" -- confirmed live these overflow well before typical
   phone widths, taking the whole page's horizontal layout down with them
   since nothing constrained the nav's own width). */
.nav-toggle {
    display: none;
    flex-direction: column;
    justify-content: center;
    gap: 4px;
    width: 34px; height: 34px;
    margin-left: auto;
    background: transparent; border: none; box-shadow: none;
    padding: 0; cursor: pointer;
}
.nav-toggle span {
    display: block; height: 2px; width: 20px; margin: 0 auto;
    background: var(--text-primary); border-radius: 2px;
    transition: transform 0.15s ease, opacity 0.15s ease;
}
.nav-toggle.open span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.nav-toggle.open span:nth-child(2) { opacity: 0; }
.nav-toggle.open span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }

main { padding: 32px 24px 64px; max-width: 1400px; margin: 0 auto; }

/* The hamburger has to appear before the horizontal bar runs out of room, not at some
   round number. The links need ~1024px alongside the wordmark, but this was set to 860,
   so every width from 860 to 1024 rendered a bar that ran off the right edge and silently
   ate the last tab. Measured, not guessed -- and it must be re-measured if a tab is added
   or renamed, which is what exposed it. */
@media (max-width: 1040px) {
    nav { position: relative; padding: 0 16px; }
    .nav-toggle { display: flex; }
    .nav-links {
        display: none;
        position: absolute;
        top: 58px; left: 0; right: 0;
        flex-direction: column; align-items: stretch;
        gap: 2px;
        background: var(--surface-1);
        border-bottom: 1px solid var(--border);
        box-shadow: var(--shadow-lg);
        padding: 8px 12px 14px;
    }
    .nav-links.open { display: flex; }
    nav a { padding: 12px 14px; font-size: 15px; }
}

@media (max-width: 860px) {
    main { padding: 20px 16px 48px; }
}

h1 { font-size: 26px; font-weight: 700; margin: 0 0 3px; letter-spacing: -0.015em; }
h2.subtitle { font-size: 14px; color: var(--text-secondary); font-weight: 400; margin: 0 0 22px; }

.card {
    background: var(--surface-1);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-sm);
    padding: 20px 22px;
    margin-bottom: 18px;
}

.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }

/* ---- form controls ---- */
input[type=text] {
    background: var(--surface-2);
    border: 1px solid var(--border);
    color: var(--text-primary);
    padding: 10px 14px;
    border-radius: var(--radius-sm);
    font-size: 14px;
    font-family: var(--font-ui);
    width: 340px;
    transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input[type=text]:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--accent-tint);
}
button, .btn {
    background: var(--accent);
    color: white;
    border: none;
    padding: 10px 18px;
    border-radius: var(--radius-sm);
    font-size: 14px;
    font-weight: 600;
    font-family: var(--font-ui);
    cursor: pointer;
    box-shadow: var(--shadow-sm);
    transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
button:hover, .btn:hover { background: var(--accent-hover); box-shadow: var(--shadow-md); }
button:active, .btn:active { transform: translateY(1px); }
button.ghost, .btn.ghost {
    background: transparent;
    border: 1px solid var(--border);
    color: var(--text-secondary);
    box-shadow: none;
}
button.ghost:hover, .btn.ghost:hover { background: var(--surface-2); color: var(--text-primary); box-shadow: none; }
button.ghost.active, .btn.ghost.active { background: var(--accent-tint); color: var(--accent); border-color: transparent; }
select {
    background: var(--surface-2); color: var(--text-primary); border: 1px solid var(--border);
    padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; font-family: var(--font-ui);
}

/* ---- tables ---- */
table { border-collapse: collapse; width: 100%; font-size: 13px; font-family: var(--font-ui); }
th, td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--gridline); white-space: nowrap; }
th {
    color: var(--text-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
    font-family: var(--font-ui);
    position: sticky; top: 0; background: var(--surface-1); cursor: pointer; user-select: none;
}
th:hover { color: var(--accent); }
th .sort-arrow { opacity: 0.4; margin-left: 3px; }
/* Only appears once a 2nd+ sort key is active (shift+click) -- shows priority order. */
th .sort-priority {
    display: inline-block; font-size: 9px; font-weight: 700; opacity: 0.6;
    margin-left: 2px; vertical-align: super;
}
tbody tr:hover td { background: var(--surface-2); }
.table-scroll { overflow-x: auto; max-height: 75vh; overflow-y: auto; }
.tabular { font-variant-numeric: tabular-nums; font-family: var(--font-mono); }

/* ---- matchup card grid (Today page) ---- */
.matchup-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
    gap: 14px;
}

/* ---- landing page ---- */
.home-hero { margin-bottom: 32px; }
.home-hero p { font-size: 14px; color: var(--text-secondary); max-width: 640px; margin: 10px 0 0; line-height: 1.5; }
.home-section { margin-bottom: 30px; }
.home-section:last-child { margin-bottom: 0; }
.home-section-label {
    font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
    color: var(--text-muted); margin: 0 0 12px;
}
.nav-card {
    background: var(--surface-1);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 18px 20px;
    display: block;
    text-decoration: none;
    transition: transform 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease;
}
.nav-card:hover {
    transform: translateY(-2px);
    border-color: var(--accent);
    box-shadow: 0 6px 16px rgba(0,0,0,0.08);
}
.nav-card-title {
    font-size: 15px; font-weight: 700; color: var(--text-primary);
    display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;
}
.nav-card-arrow { color: var(--accent); font-weight: 700; opacity: 0; transition: opacity 0.12s ease, transform 0.12s ease; }
.nav-card:hover .nav-card-arrow { opacity: 1; transform: translateX(2px); }
.nav-card-desc { font-size: 13px; color: var(--text-secondary); line-height: 1.4; margin: 0; }
.matchup-card {
    background: var(--surface-1);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 16px;
}
.matchup-vs { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; }
.matchup-batter { font-size: 15px; font-weight: 700; }
.matchup-hand { font-size: 11px; color: var(--text-muted); font-weight: 600; }
.matchup-pitcher { font-size: 13px; color: var(--text-secondary); margin-bottom: 10px; }
.matchup-context { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.chip {
    font-size: 11px; font-weight: 600; color: var(--text-secondary);
    background: var(--page-plane); border: 1px solid var(--border);
    padding: 3px 9px; border-radius: 999px; white-space: nowrap;
}
/* Open-Meteo forecast weather. Deliberately muted and dashed: it is a forecast, not MLB's
   measurement, and the two must never look interchangeable on the same row. */
.chip.forecast { color: var(--text-muted); border-style: dashed; }
/* Thin air (>8% below sea-level standard) -- Coors, Las Vegas, Mexico City. Flagged because
   it is the single largest physical effect on fly-ball carry in the dataset (~24% spread). */
.chip.thin-air { color: var(--status-warning); border-color: var(--status-warning); }
/* Thick air (>4% above league mean) -- cold coastal parks. The suppressing counterpart to
   thin-air, so the chip reads in both directions rather than only flagging the Coors end. */
.chip.thick-air { color: var(--accent); border-color: var(--accent); }
.chip.wind-out { color: var(--status-good); }
.chip.wind-in { color: var(--status-critical); }
.chip.source-real { color: var(--status-good); border-color: var(--status-good); }
.chip.source-illustrative { color: var(--text-muted); }
/* Middle tier: a real market price, just not from today's capture. Warning-
   colored rather than good/muted so it reads as "trust, but it's stale". */
.chip.source-recent { color: var(--status-warning); border-color: var(--status-warning); }

/* ---- stat tile / meter (outcome probabilities) ---- */
.meter-row { margin-bottom: 9px; }
.meter-row:last-child { margin-bottom: 0; }
.meter-label-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
.meter-label { color: var(--text-secondary); font-weight: 600; }
.meter-value { color: var(--text-primary); font-weight: 700; font-variant-numeric: tabular-nums; }
.meter-track { height: 7px; border-radius: 4px; overflow: hidden; }
.meter-fill { height: 100%; border-radius: 4px; }

/* ---- search / name list ---- */
.name-list { list-style: none; padding: 0; margin: 0; }
.name-list li { border-bottom: 1px solid var(--gridline); }
.name-list li:last-child { border-bottom: none; }
.name-list a {
    display: flex; justify-content: space-between; align-items: center;
    color: var(--text-primary); text-decoration: none; padding: 12px 4px; font-size: 14px; font-weight: 500;
}
.name-list a:hover { color: var(--accent); }
.name-list a .chevron { color: var(--text-muted); }

.context-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 4px; }
.not-found { color: var(--status-serious); }
.empty-state { color: var(--text-muted); font-size: 14px; padding: 8px 0; }
canvas { max-width: 100%; }
.chart-caption { font-size: 12px; color: var(--text-muted); margin: 8px 0 0; }

/* ---- performance / calibration page ---- */
.outcome-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
    gap: 14px;
}
.outcome-card-head {
    display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap;
    gap: 4px 12px;
    margin-bottom: 14px;
}
.outcome-card-head .name { font-size: 15px; font-weight: 700; }
.outcome-card-head .n { font-size: 12px; color: var(--text-muted); }
@media (max-width: 860px) {
    /* Side-by-side name + caption crowds and can visually overlap once the
       caption is more than a few words (e.g. Hot Zones' methodology note) --
       confirmed live on a real 390px viewport. Stack instead of squeezing. */
    .outcome-card-head { flex-direction: column; align-items: flex-start; gap: 2px; }
}
/* Must wrap: four tiles at min-width 70px plus three 22px gaps needs 346px, but a
   card at phone width only offers ~312px of inner space -- without wrapping the
   last tile's VALUE pushed past the card and off the page entirely (confirmed on
   Picks / Betting / Model Performance at 390px). Row-gap is smaller than
   column-gap so wrapped rows don't look disconnected. */
.stat-tiles { display: flex; flex-wrap: wrap; gap: 14px 22px; margin-bottom: 16px; }
.stat-tile { min-width: 70px; }
@media (max-width: 860px) {
    .stat-tiles { gap: 12px 18px; }
}
/* Fixed 2-line height regardless of actual wrap -- "PERFECT-DAY RATE" wraps, "HIT RATE"
   doesn't, so without a reserved height each tile's .value sits at a different vertical
   position within the same card. */
.stat-tile .label {
    font-size: 11px; line-height: 1.35; min-height: 30px;
    color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em;
}
.stat-tile .value { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 2px; }

.bucket-row { margin-bottom: 10px; }
.bucket-row:last-child { margin-bottom: 0; }
.bucket-label-row { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 3px; }
.bucket-label { color: var(--text-secondary); font-weight: 600; }
.bucket-count { color: var(--text-muted); }
.bucket-track {
    position: relative; height: 14px; border-radius: 4px;
    background: var(--gridline); overflow: hidden;
}
.bucket-actual { position: absolute; top: 0; left: 0; bottom: 0; border-radius: 4px; opacity: 0.35; }
.bucket-predicted-tick {
    position: absolute; top: -2px; bottom: -2px; width: 2px;
    background: var(--text-primary);
}
.bucket-legend { display: flex; gap: 14px; font-size: 11px; color: var(--text-muted); margin-top: 10px; }
.bucket-legend .swatch { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }

/* Outcome pill (Yes/No) is deliberately NEUTRAL and fixed regardless of whether the
   model called it correctly -- it's reporting a fact (did this happen), not a
   verdict. Coloring it green/red by correctness (the old behavior) meant "No" could
   show up green whenever the model correctly predicted "unlikely" -- confusing,
   since green/red read as yes/no to most people before they read the text. The
   separate .correct-mark carries the correctness signal instead. */
.correct-badge {
    font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 999px;
    white-space: nowrap; color: var(--text-secondary); background: var(--gridline);
}
.correct-mark { margin-left: 5px; font-weight: 700; }
.correct-mark.aligned { color: var(--status-good); }
.correct-mark.missed { color: var(--status-critical); }

/* ---- hot zones (Matchup page) ---- */
.zone-grid-row { display: flex; gap: 24px; flex-wrap: wrap; }
.zone-panel { min-width: 200px; }
.zone-grid {
    display: grid; grid-template-columns: repeat(3, 64px); grid-template-rows: repeat(3, 64px);
    gap: 3px;
}
.zone-cell {
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    border-radius: 6px; background: var(--gridline);
    color: #fff; text-shadow: 0 0 3px rgba(0,0,0,0.6), 0 0 1px rgba(0,0,0,0.8);
}
.zone-cell-empty { background: var(--gridline) !important; color: var(--text-muted); text-shadow: none; }
.zone-value { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
.zone-n { font-size: 10px; opacity: 0.85; }


/* Weather not yet reported by MLB: the model fell back to league-average conditions, so the
   prediction is working from filler. Amber rather than red -- it is a caveat on freshness,
   not an error, and the afternoon rescores resolve it. */
.chip.weather-pending {
    background: rgba(191, 132, 0, 0.12);
    color: #8a6100;
    border-color: rgba(191, 132, 0, 0.35);
}
@media (prefers-color-scheme: dark) {
    .chip.weather-pending { background: rgba(240, 180, 60, 0.16); color: #f0c96b; border-color: rgba(240, 180, 60, 0.35); }
}
:root[data-theme="dark"] .chip.weather-pending { background: rgba(240, 180, 60, 0.16); color: #f0c96b; border-color: rgba(240, 180, 60, 0.35); }
:root[data-theme="light"] .spray-field { fill: var(--seq-100); opacity: 0.45; }
:root[data-theme="light"] .spray-infield { fill: var(--surface-2); }
:root[data-theme="light"] .chip.weather-pending { background: rgba(191, 132, 0, 0.12); color: #8a6100; border-color: rgba(191, 132, 0, 0.35); }


/* The product name is set in the mono face EVERYWHERE it appears, not just in the nav bar.
   It reads as a wordmark rather than as body copy, and a name that changes typeface between
   the header and the page below it looks like two different products. Shared class so the
   nav brand and the landing-page h1 cannot drift apart. */
.wordmark {
    font-family: var(--font-mono);
    letter-spacing: 0.01em;
}
.wordmark .caret { color: var(--accent); margin-right: 2px; }


/* Profit/loss coloring for the real-odds backtest. Semantic status colors, deliberately not
   the categorical series palette -- money up/down is a state, not a series identity. */
.stat-tile .value.pos, .pos { color: var(--status-good); }
.stat-tile .value.neg, .neg { color: var(--status-critical); }
.muted { color: var(--text-muted); }


/* Calibration buckets holding under 30 predictions. Dimmed rather than hidden: the model DID
   make those predictions and that is worth seeing, but an n=1 bucket reads 0% or 100% actual
   and would otherwise look like a catastrophic miss. */
.bucket-row.thin-bucket { opacity: 0.42; }
.bucket-row.thin-bucket .bucket-count { font-style: italic; }


/* Same-game correlation. Amber rather than red: correlated legs are not an error, they are
   mispriced -- fine as singles, misleading inside a parlay. */
/* The deepest-run line under the sweep grid. Compact and inline: it is one fact per market,
   not a table, and it sits directly under the grid it is drawn from. */
/* Best day states a fact in words rather than a single figure, so it cannot carry the 22px
   used for the numeric tiles beside it -- "Top 22 all correct" would wrap mid-phrase in a
   flex row. Sized to sit on one line while staying visibly a tile value. */
.stat-tile .value--phrase { font-size: 14px; line-height: 1.3; white-space: nowrap; }
/* The date under it. Secondary to the claim, which is the headline. */
.best-run-date { display: block; font-size: 10.5px; font-weight: 400; color: var(--text-muted); white-space: nowrap; }

/* Hot-form marker. Plain text, not a filled pill: it sits beside a player's name in a dense
   table and a solid green capsule pulled the eye harder than the pick itself. A muted green
   that reads as an annotation, and phrased so it needs no legend. */
.form-hot {
    display: block; margin-top: 2px;
    font-size: 10.5px; font-weight: 600; white-space: nowrap;
    color: #2f7d32; font-variant-numeric: tabular-nums;
}
@media (prefers-color-scheme: dark) { .form-hot { color: #6bbf6e; } }
:root[data-theme="dark"] .form-hot { color: #6bbf6e; }
:root[data-theme="light"] .form-hot { color: #2f7d32; }

/* Tonight strip on the batter page, and the last-slate line on Today's Picks. Both connect
   the two halves of the site that previously did not refer to each other. */
.tonight-strip {
    display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px;
    padding: 9px 12px; margin-bottom: 14px;
    background: var(--accent-tint); border-radius: var(--radius-md);
    font-size: 12.5px;
}
.tonight-head {
    text-transform: uppercase; letter-spacing: 0.04em; font-size: 10.5px;
    font-weight: 700; color: var(--accent);
}
.tonight-probs { display: flex; flex-wrap: wrap; gap: 10px; margin-left: auto; }
.tonight-prob { display: inline-flex; align-items: baseline; gap: 4px; white-space: nowrap; }
.tp-label { font-size: 10.5px; color: var(--text-muted); }
.tp-value { font-weight: 700; font-variant-numeric: tabular-nums; }
.tonight-more { font-size: 11.5px; white-space: nowrap; }
@media (max-width: 620px) { .tonight-probs { margin-left: 0; } }

.last-slate { margin: -4px 0 16px; }
.last-slate-head {
    margin: 0 0 4px; font-size: 12.5px; color: var(--text-secondary);
}
.last-slate-head .muted { font-size: 11.5px; margin-left: 2px; }
.last-slate-head a { margin-left: 8px; font-size: 11.5px; }
.last-slate-list { list-style: none; margin: 0; padding: 0; }
.last-slate-list li {
    display: flex; gap: 8px; align-items: baseline;
    font-size: 12.5px; line-height: 1.7;
}
.last-slate-list li::before { content: '\2022'; color: var(--text-muted); }
.ls-market { color: var(--text-secondary); }
.ls-score { font-weight: 700; font-variant-numeric: tabular-nums; }

/* League Leaders: a grid of small top-five cards. Auto-fit rather than a fixed count, so
   fourteen lists reflow from four columns down to one without a breakpoint per layout. */
/* Hot-streaks counts. The denominator is constant down the column, so it recedes and the
   count carries the weight. */
.form-count { font-weight: 700; }
.form-count.hot { color: var(--status-good); }
.form-of { color: var(--text-muted); font-size: 11px; }

.leader-grid {
    display: grid; gap: 12px;
    grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.leader-card { margin: 0; }
.leader-list { list-style: none; margin: 0; padding: 0; }
.leader-list li {
    display: grid; grid-template-columns: 16px 1fr auto; gap: 8px;
    align-items: baseline; padding: 3px 0; font-size: 12.5px;
}
.leader-list li + li { border-top: 1px solid var(--border); }
.lb-rank { color: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.lb-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lb-value { font-weight: 700; font-variant-numeric: tabular-nums; }

.chip.corr { color: var(--status-warning); border-color: var(--status-warning); font-size: 0.72rem; }
.callout-warn { border-left: 3px solid var(--status-warning); padding-left: 10px; }

/* ---------------------------------------------------------------------------
   Info popovers -- the "i" button next to a heading or stat.

   Replaces title="" tooltips, which do not exist on touch devices, cannot be
   focused by keyboard, and disappear mid-sentence. Long explanatory paragraphs
   moved in here so each page leads with a plain one-line answer and keeps the
   detail one click away.
   --------------------------------------------------------------------------- */
.info-wrap { position: relative; display: inline-block; vertical-align: middle; }

.info-btn {
    display: inline-flex; align-items: center; justify-content: center;
    width: 16px; height: 16px; margin-left: 5px; padding: 0;
    border: 1px solid var(--border); border-radius: 50%;
    background: var(--surface-2); color: var(--text-muted);
    font-family: var(--font-ui); font-size: 10px; font-weight: 700;
    font-style: italic; line-height: 1; cursor: pointer;
    transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.info-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.info-btn[aria-expanded="true"] { background: var(--accent); color: #fff; border-color: var(--accent); }

.info-pop {
    position: absolute; top: calc(100% + 7px); z-index: 40;
    display: block; width: max-content; max-width: min(330px, 78vw);
    padding: 11px 13px;
    background: var(--surface-1); color: var(--text-secondary);
    border: 1px solid var(--border); border-radius: var(--radius-md);
    box-shadow: 0 6px 22px rgba(0,0,0,0.13);
    font-family: var(--font-ui); font-size: 12.5px; font-weight: 400;
    line-height: 1.55; text-align: left; white-space: normal;
    /* A popover opened from inside a table heading inherits its centering and
       uppercase tracking without these. */
    text-transform: none; letter-spacing: normal;
}
.info-pop[hidden] { display: none; }
.info-pop--left  { left: 0; }
.info-pop--right { right: 0; }

.info-pop-title {
    display: block; margin-bottom: 5px;
    color: var(--text-primary); font-size: 12.5px; font-weight: 600;
}
.info-pop-body { display: block; }
.info-pop-body p { margin: 0 0 7px; }
.info-pop-body p:last-child { margin-bottom: 0; }
.info-pop-body strong { color: var(--text-primary); font-weight: 600; }

/* On a narrow screen an absolutely-positioned panel anchored to a button near the
   edge still overflows, so pin it to the viewport instead.

   `top` MUST be reset here. The base rule sets top: calc(100% + 7px), which is measured
   against the offset parent while the panel is absolute -- but this switches it to
   position: fixed, where percentages resolve against the VIEWPORT. The panel was
   therefore being placed at 100vh + 7px, i.e. just past the bottom edge, and because it
   is fixed no amount of scrolling brought it back. Every popover on the site opened
   correctly on a phone and rendered off-screen; it read as "the info buttons do nothing".
   Pinned to the bottom instead, which also keeps it clear of the finger that tapped it.
   Capped and scrollable because some of these run several paragraphs. */
@media (max-width: 560px) {
    .info-pop {
        position: fixed;
        left: 12px; right: 12px;
        top: auto; bottom: 12px;
        width: auto; max-width: none;
        max-height: 60vh; overflow-y: auto;
        -webkit-overflow-scrolling: touch;
    }
}

@media (prefers-color-scheme: dark) {
    .info-pop { box-shadow: 0 6px 22px rgba(0,0,0,0.5); }
}

/* ---------------------------------------------------------------------------
   Per-pitch performance bars on the Matchup page.

   Replaced a trellis of nine-cell hot-zone grids, one per pitch type. Those were
   sparse by construction -- a batter x hand x zone x pitch_type cell rarely has
   enough contact to mean anything -- so the grids read mostly empty while taking
   the whole width. One bar per pitch answers the same question with the data
   that actually exists.
   --------------------------------------------------------------------------- */
.pbar-row {
    display: grid;
    grid-template-columns: 74px 1fr 62px 62px;
    align-items: center; gap: 10px;
    padding: 5px 0;
    border-bottom: 1px dashed var(--gridline);
}
.pbar-row:last-child { border-bottom: 0; }
.pbar-thin { opacity: 0.45; }

.pbar-label {
    font-family: var(--font-ui); font-size: 12.5px; font-weight: 600;
    color: var(--text-primary); text-align: right;
}
.pbar-track {
    position: relative; display: block; height: 15px;
    background: var(--surface-2); border-radius: 3px; overflow: visible;
}
.pbar-fill {
    display: block; height: 100%; border-radius: 3px;
    /* 4px rounded data-end anchored to the baseline, per the chart conventions
       used elsewhere in this app. The fill color is set inline: it is a continuous
       ramp keyed to how far the value sits from the batter's own average, so a fixed
       set of classes cannot express it (and bucketing into four shades put a better
       pitch in a darker blue than a worse one). */
    transition: width 0.18s ease, background 0.18s ease;
}

/* His own average across every pitch from this hand -- the line each bar is read
   against, so "is this pitch better or worse than usual for him" is visual. */
.pbar-base {
    position: absolute; top: -3px; bottom: -3px; width: 0;
    border-left: 2px dotted var(--text-secondary);
}
.pbar-value {
    font-family: var(--font-mono); font-size: 12.5px; font-weight: 600;
    color: var(--text-primary); text-align: right;
    font-variant-numeric: tabular-nums;
}
.pbar-n {
    font-family: var(--font-mono); font-size: 11px;
    color: var(--text-muted); text-align: right;
    font-variant-numeric: tabular-nums;
}

@media (max-width: 560px) {
    .pbar-row { grid-template-columns: 54px 1fr 54px; }
    .pbar-n { display: none; }
}


/* A window this stat was never computed at -- distinct from a value that is merely thin. */
.na-cell { color: var(--text-muted); opacity: 0.35; }

/* ---------------------------------------------------------------------------
   Percentile ranking card (batter / pitcher detail).
   --------------------------------------------------------------------------- */
.pctile-card { margin-bottom: 18px; }

.pctile-scale {
    position: relative; height: 14px; margin: 0 0 6px;
    margin-left: 122px; margin-right: 66px;
}
.pctile-scale-label {
    position: absolute; transform: translateX(-50%);
    font-family: var(--font-ui); font-size: 10px; font-weight: 600;
    letter-spacing: 0.06em; color: var(--text-muted);
}
.pctile-scale-label:first-child { transform: none; }
.pctile-scale-label:last-child { transform: translateX(-100%); }

.pctile-row {
    display: grid; grid-template-columns: 110px 1fr 56px;
    align-items: center; gap: 12px;
    padding: 4px 0;
    border-bottom: 1px dashed var(--gridline);
}
.pctile-row:last-child { border-bottom: 0; }
.pctile-empty { opacity: 0.4; }

.pctile-label {
    font-family: var(--font-ui); font-size: 12.5px; font-weight: 600;
    color: var(--text-primary); text-align: right;
}
.pctile-track {
    position: relative; display: block; height: 13px;
    background: var(--surface-2); border-radius: 7px;
}
.pctile-fill {
    display: block; height: 100%; border-radius: 7px;
    transition: width 0.18s ease, background 0.18s ease;
}
/* The number rides on the end of its own bar, as on the Savant card -- reading the rank off
   a separate column means pairing bar to number by eye on every row. */
.pctile-dot {
    position: absolute; top: 50%; transform: translate(-50%, -50%);
    min-width: 21px; height: 21px; padding: 0 3px;
    display: flex; align-items: center; justify-content: center;
    border-radius: 11px; border: 2px solid var(--surface-1);
    font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
    color: #fff; font-variant-numeric: tabular-nums;
}
.pctile-value {
    font-family: var(--font-mono); font-size: 12.5px; font-weight: 600;
    color: var(--text-primary); text-align: right;
    font-variant-numeric: tabular-nums;
}

@media (max-width: 560px) {
    .pctile-row { grid-template-columns: 84px 1fr 48px; gap: 8px; }
    .pctile-scale { margin-left: 92px; margin-right: 56px; }
    .pctile-label { font-size: 11.5px; }
}

/* ---------------------------------------------------------------------------
   Spray chart.
   --------------------------------------------------------------------------- */
.spray-card { margin-bottom: 18px; }
.spray-wrap { max-width: 620px; margin: 0 auto; }
.spray-svg { width: 100%; height: auto; display: block; }

.spray-field   { fill: var(--seq-100); opacity: 0.45; stroke: none; }
.spray-infield { fill: var(--surface-2); stroke: var(--baseline); stroke-width: 1.5; }
.spray-line    { stroke: var(--baseline); stroke-width: 2; fill: none; }
.spray-arc     { stroke: var(--gridline); stroke-width: 1.5; fill: none; stroke-dasharray: 5 5; }
.spray-arc-label {
    fill: var(--text-muted); font-family: var(--font-mono); font-size: 15px;
}

/* Hit types use the validated categorical order, so they stay distinguishable under the
   common color-vision deficiencies rather than only in full color. */
.spray-pt { stroke: var(--surface-1); stroke-width: 1.5; }
.spray-single   { fill: var(--hit-1b); }
.spray-double   { fill: var(--hit-2b); }
.spray-triple   { fill: var(--hit-3b); }
.spray-home_run { fill: var(--hit-hr); }

.spray-legend {
    display: flex; flex-wrap: wrap; gap: 16px;
    margin: 0 0 10px; padding-bottom: 10px;
    border-bottom: 1px dashed var(--gridline);
}
.spray-legend-item {
    display: inline-flex; align-items: center; gap: 6px;
    font-family: var(--font-ui); font-size: 12px; color: var(--text-secondary);
}
.spray-dot { width: 11px; height: 11px; border-radius: 50%; display: inline-block; }
.spray-dot.spray-single   { background: var(--hit-1b); }
.spray-dot.spray-double   { background: var(--hit-2b); }
.spray-dot.spray-triple   { background: var(--hit-3b); }
.spray-dot.spray-home_run { background: var(--hit-hr); }
.spray-count {
    font-family: var(--font-mono); font-size: 11.5px; font-weight: 600;
    color: var(--text-primary); font-variant-numeric: tabular-nums;
}

@media (prefers-color-scheme: dark) {
    /* Not the light field dimmed by opacity -- that lands at L*38, mid-range, and squeezes the
       hit ramp from both ends (3B and HR came out 1.0 apart). An explicitly dark field gives
       the ramp somewhere to go. */
    .spray-field { fill: #1f262b; opacity: 1; }
    .spray-infield { fill: #2e373d; }
}

.toolbar-note { font-size: 11.5px; color: var(--text-muted); font-family: var(--font-ui); }

/* Parlay figures sit inside the same market card as the singles, divided rather than
   separated -- the comparison between them is the point. */
.parlay-strip {
    margin-top: 10px; padding-top: 10px;
    border-top: 1px dashed var(--gridline);
}
.parlay-strip-label {
    display: block; margin-bottom: 6px;
    font-family: var(--font-ui); font-size: 11px; font-weight: 600;
    letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted);
}

/* Player names are navigation. Underlined only on hover so a dense table does not turn
   into a wall of blue -- the cursor and color shift carry it. */
.player-link { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--border); }
.player-link:hover { color: var(--accent); border-bottom-color: var(--accent); }
.player-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.player-nav { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 14px; }
.player-nav a {
    display: inline-flex; align-items: center; gap: 6px;
    padding: 6px 12px; border-radius: var(--radius-sm);
    background: var(--surface-2); border: 1px solid var(--border);
    color: var(--text-primary); text-decoration: none;
    font-family: var(--font-ui); font-size: 12.5px; font-weight: 600;
}
.player-nav a:hover { background: var(--accent); color: #fff; border-color: var(--accent); }

/* Download control on the stat tables. */
.table-actions { display: flex; justify-content: flex-end; margin-bottom: 8px; }
.csv-button {
    display: inline-flex; align-items: center; gap: 6px;
    padding: 5px 11px; border-radius: var(--radius-sm);
    background: var(--surface-2); border: 1px solid var(--border);
    color: var(--text-secondary); text-decoration: none;
    font-family: var(--font-ui); font-size: 12px; font-weight: 600;
}
.csv-button:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.csv-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* "What period is this?" banner on a player page. Neutral when the player is active,
   flagged when his numbers stopped moving weeks ago. */
.activity-note {
    margin: 0 0 14px; padding: 9px 13px;
    border-left: 3px solid var(--border); border-radius: var(--radius-sm);
    background: var(--surface-2);
    font-family: var(--font-ui); font-size: 12.5px; color: var(--text-secondary);
}
.activity-note strong { color: var(--text-primary); }
.activity-stale {
    border-left-color: var(--status-warning);
    background: rgba(250, 178, 25, 0.10);
}

/* Marks a percentile row whose ranking is flipped because a low value is the good one. */
.pctile-inv {
    margin-left: 3px; color: var(--text-muted);
    font-size: 11px; font-weight: 700; cursor: help;
}

/* Context notes under a pick. Deliberately quiet -- they qualify the number beside them
   rather than competing with it. */
.pick-drivers { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
.pick-driver {
    font-family: var(--font-ui); font-size: 11px; line-height: 1.35;
    color: var(--text-muted);
}
.pick-driver::before { content: "· "; }

/* About page body copy -- wider measure than the dense chart captions, since this is the
   one page on the site meant to be read straight through. */
.about-body {
    max-width: 68ch;
    margin: 0 0 12px;
    font-family: var(--font-ui); font-size: 14px; line-height: 1.65;
    color: var(--text-secondary);
}
.about-body:last-child { margin-bottom: 0; }
.about-body strong { color: var(--text-primary); }
.about-body a { color: var(--accent); }

.ribbon {
    display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
    font-family: var(--font-ui); font-size: 12.5px; color: var(--text-muted);
}
.ribbon a { color: var(--text-secondary); text-decoration: none; }
.ribbon a:hover { color: var(--accent); text-decoration: underline; }
.ribbon .sep { opacity: 0.5; }

/* Above the nav: a thin utility strip, quieter than the nav so it does not compete with it. */
.ribbon--top {
    padding: 6px 20px; font-size: 11.5px;
    background: var(--surface-2);
    border-bottom: 1px solid var(--border);
}

.site-footer {
    max-width: var(--page-max, 1200px);
    margin: 36px auto 24px; padding: 16px 20px 0;
    border-top: 1px solid var(--border);
}

/* On a phone the strip would wrap to two lines and push the nav down; the legal line is the
   part that can wait until the footer. */
.ribbon-help { color: var(--text-secondary); }
.ribbon-help a { color: var(--text-secondary); text-decoration: underline; }
.ribbon-help a:hover { color: var(--accent); }

/* On a phone the top strip has room for about two items before it wraps and shoves the nav
   down. The helpline keeps its place and the contact/legal bits drop -- both still sit in
   the footer, and of the four this is the one that should never be the thing that falls off. */
@media (max-width: 560px) {
    .ribbon--top .ribbon-contact, .ribbon--top .ribbon-legal { display: none; }
}

@media (prefers-color-scheme: dark) {
    .about-body a { color: var(--seq-200); }
}

/* About heading: the letters that spell mlbaseballytics stay full strength, everything
   around them recedes. An h1 is already bold, so emphasis has to come from dimming the
   rest rather than bolding the parts. */
/* .dim is used for "About" itself as well as the filler letters, so it is scoped to the
   heading rather than left to the .name-expand span alone. */
/* Everything in the heading sits at one weight and one color now -- the letters that spell
   mlbaseballytics are marked by an underline instead. text-underline-offset keeps the rule
   off the descenders in "ytics" and "Baseball". */
h1 > .dim { font-weight: 400; }
.name-expand { font-weight: 400; }
.name-expand .dim { font-weight: 400; }
.name-expand b {
    font-weight: 400;
    /* A hairline, and dimmed against the text so it reads as a quiet mark rather than a
       link. 1px sits below the browser default, which scales with the heading's font size
       and comes out heavy at h1. */
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-decoration-color: var(--text-muted);
    text-underline-offset: 4px;
}

@media (max-width: 560px) {
    /* Long for a phone heading -- let it wrap and drop a size rather than overflow. */
    .name-expand { display: block; font-size: 0.72em; margin-top: 2px; }
}


/* ---------------------------------------------------------------------------
   Explicit theme choice.

   prefers-color-scheme still decides for anyone who has not picked, so the OS
   setting remains the default. These attribute rules only exist to let a reader
   override it, which a media query alone cannot do -- hence every dark rule is
   restated here rather than shared. Duplication is the price of overridability;
   if a token changes above, change it here too.
   --------------------------------------------------------------------------- */
:root[data-theme="light"] {
    color-scheme: light;
    --surface-1:      #fcfcfb;   /* chart / card surface */
    --surface-2:      #f2f1ec;   /* recessed surface -- inputs, track backgrounds */
    --page-plane:     #f9f9f7;
    --text-primary:   #0b0b0b;
    --text-secondary: #52514e;
    --text-muted:     #898781;
    --gridline:       #e1e0d9;
    --baseline:       #c3c2b7;
    --border:         rgba(11,11,11,0.10);
    --success-text:   #006300;

    /* categorical, fixed order -- validated adjacent-pair safe */
    --series-1: #2a78d6; /* blue    -> HR */
    --series-2: #eb6834; /* orange  -> Hit */
    --series-3: #1baf7a; /* aqua    -> 2+ TB */
    --series-4: #eda100; /* yellow  -> BB */

    /* Mirrors :root -- see the ramp note there. */
    --hit-1b: #f5b32b;
    --hit-2b: #e35c28;
    --hit-3b: #a82a52;
    --hit-hr: #4f1a3d;

    /* sequential (blue) ramp, light->dark, for meter tracks */
    --seq-100: #cde2fb;
    --seq-200: #9ec5f4;
    --seq-400: #3987e5;
    --seq-500: #256abf;

    --status-good: #0ca30c;
    --status-warning: #fab219;
    --status-serious: #ec835a;
    --status-critical: #d03b3b;

    /* accent -- a deeper, warmer navy than the chart blue, so UI chrome (nav,
       buttons, focus rings) reads as a deliberate brand color rather than
       reusing the data-series blue for two different jobs. */
    --accent:       #1d3f6e;
    --accent-hover: #163055;
    --accent-tint:  #e7edf5;

    --radius-sm: 6px;
    --radius-md: 10px;
    --radius-lg: 16px;
    --shadow-sm: 0 1px 2px rgba(11,11,11,0.05);
    --shadow-md: 0 3px 10px rgba(11,11,11,0.07);
    --shadow-lg: 0 10px 28px rgba(11,11,11,0.10);

    --font-ui:   'IBM Plex Sans', system-ui, -apple-system, "Segoe UI", sans-serif;
    --font-mono: 'IBM Plex Mono', ui-monospace, "SF Mono", Menlo, monospace;
}

:root[data-theme="dark"] {
        color-scheme: dark;
        --surface-1:      #1a1a19;
        --surface-2:      #242422;
        --page-plane:     #0d0d0d;
        --text-primary:   #ffffff;
        --text-secondary: #c3c2b7;
        --text-muted:     #898781;
        --gridline:       #2c2c2a;
        --baseline:       #383835;
        --border:         rgba(255,255,255,0.10);
        --success-text:   #0ca30c;

        --series-1: #3987e5;
        --series-2: #d95926;
        --series-3: #199e70;
        --series-4: #c98500;

        /* Mirrors the dark media query -- see the ramp note in :root. */
        --hit-1b: #f0b83c;
        --hit-2b: #e2703a;
        --hit-3b: #c03a5c;
        --hit-hr: #9648b0;

        --accent:       #6fa1e0;
        --accent-hover: #8bb3e8;
        --accent-tint:  rgba(111,161,224,0.16);

        --shadow-sm: 0 1px 2px rgba(0,0,0,0.24);
        --shadow-md: 0 3px 10px rgba(0,0,0,0.32);
        --shadow-lg: 0 10px 28px rgba(0,0,0,0.42);
    }

:root[data-theme="dark"] .info-pop { box-shadow: 0 6px 22px rgba(0,0,0,0.5); }

:root[data-theme="dark"] .spray-field { fill: #1f262b; opacity: 1; }
:root[data-theme="dark"] .spray-infield { fill: #2e373d; }

:root[data-theme="dark"] .about-body a { color: var(--seq-200); }

/* Theme toggle. Sits in the top ribbon; the icon shows what you will GET, not what you
   are on, which is the convention people expect from these. */
.theme-toggle {
    margin-left: auto;
    display: inline-flex; align-items: center; gap: 5px;
    padding: 2px 9px; border-radius: 999px;
    background: transparent; border: 1px solid var(--border);
    color: var(--text-secondary); cursor: pointer;
    font-family: var(--font-ui); font-size: 11.5px; line-height: 1.7;
}
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
.theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.ribbon-wrap { display: flex; align-items: center; gap: 10px; padding: 6px 20px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.ribbon-wrap .ribbon--top { padding: 0; background: none; border: 0; flex: 1; }

/* Pitch codes carry their full name on hover. Dotted underline is the long-standing
   convention for "there is an explanation here", and abbr gives it to assistive tech too. */
.pitch-code {
    text-decoration: none;
    border-bottom: 1px dotted var(--text-muted);
    cursor: help;
}

/* Section headings on Picks -- one level above the individual cards, so "Top Picks" and
   "Top Value" read as two different questions rather than two more cards. */
.section-head { margin: 26px 0 12px; }
.section-head:first-of-type { margin-top: 8px; }
.section-title {
    margin: 0 0 3px; font-family: var(--font-ui);
    font-size: 17px; font-weight: 700; color: var(--text-primary);
}
.section-note {
    margin: 0; max-width: 70ch;
    font-family: var(--font-ui); font-size: 12.5px; color: var(--text-muted);
}

/* The bubble shown when a pitch code is tapped. Positioned on <body> rather than inside the
   cell so a table with overflow:auto cannot clip it. */
/* Now carries far more than a pitch name: on touch this is how every title="" on the site is
   read, and the longest of those runs 162 characters. nowrap would send that off the side of
   the screen, so it wraps against a cap -- short pitch names still sit on one line, because
   the box shrinks to its content. */
.pitch-pop {
    position: absolute; z-index: 60;
    padding: 6px 10px; border-radius: var(--radius-sm);
    background: var(--surface-1); color: var(--text-primary);
    border: 1px solid var(--border); box-shadow: 0 4px 14px rgba(0,0,0,0.16);
    font-family: var(--font-ui); font-size: 12px; line-height: 1.45;
    white-space: normal; max-width: min(300px, calc(100vw - 24px));
}

/* Live-record badge beside each outcome on Picks. Reads as a claim with evidence behind it,
   so it is a link, not a label -- one click to the record that produced it. */
.lift-badge {
    display: inline-flex; align-items: baseline; gap: 5px;
    margin-left: 10px; padding: 2px 8px; border-radius: 999px;
    background: rgba(12, 163, 12, 0.12); color: var(--status-good);
    border: 1px solid rgba(12, 163, 12, 0.3);
    font-family: var(--font-ui); font-size: 11.5px; font-weight: 600;
    text-decoration: none; white-space: nowrap;
}
.lift-badge:hover { background: rgba(12, 163, 12, 0.2); }
.lift-badge:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Under ~1.1x the ranking is barely beating chance; saying so in green would oversell it. */
.lift-badge--flat {
    background: var(--surface-2); color: var(--text-muted); border-color: var(--border);
}
.lift-badge-n { font-weight: 400; opacity: 0.75; font-size: 10.5px; }

/* The lift badge doubles as a popover trigger, so it needs button resets. */
button.lift-badge { cursor: pointer; font: inherit; font-size: 11.5px; font-weight: 600; }
button.lift-badge[aria-expanded="true"] { background: rgba(12, 163, 12, 0.24); }
button.lift-badge--flat[aria-expanded="true"] { background: var(--border); }

/* ---- Spray chart: per-hit tooltip ------------------------------------------------ */

/* The wall is drawn as its own stroked line on top of the fill so the fence reads as a
   fence. The fill alone left the outfield boundary as a soft colour edge, which is not
   what a wall looks like. */
.spray-wall {
    stroke: var(--baseline); stroke-width: 2; fill: none;
    stroke-linejoin: round;
}
.spray-arc-label { text-anchor: middle; }

/* Dots take the pointer, but hit testing is nearest-point in JS -- see spray-tip.js. */
.spray-svg { touch-action: manipulation; }
.spray-pt { transition: r 90ms ease, stroke-width 90ms ease; }
.spray-pt--on { r: 9; stroke-width: 2.5; }

.spray-hint { margin: 6px 0 0; text-align: center; }

.spray-tip {
    position: absolute; z-index: 60; pointer-events: none;
    max-width: 260px; padding: 9px 11px;
    background: var(--surface-1); color: var(--text-primary);
    border: 1px solid var(--border); border-radius: var(--radius-sm);
    box-shadow: var(--shadow-lg);
    font-family: var(--font-ui); font-size: 12.5px; line-height: 1.5;
    display: flex; flex-direction: column; gap: 3px;
}
.spray-tip-head { display: flex; align-items: baseline; gap: 7px; }
.spray-tip-ev { font-weight: 600; }
.spray-tip-date { color: var(--text-muted); font-size: 11.5px; }
.spray-tip-line { color: var(--text-secondary); }
.spray-tip-who { color: var(--text-muted); font-size: 11.5px; }
/* Expected stats line up in a little grid -- they are read against each other, and
   tabular-nums stops the decimal points from wandering between rows. */
.spray-tip-grid {
    display: grid; grid-template-columns: auto auto; gap: 1px 10px;
    margin-top: 3px; padding-top: 5px; border-top: 1px solid var(--border);
    font-variant-numeric: tabular-nums;
}
.spray-tip-k { color: var(--text-muted); }
.spray-tip-v { text-align: right; font-weight: 600; }

@media (prefers-color-scheme: dark) {
    .spray-tip { box-shadow: 0 10px 28px rgba(0,0,0,0.55); }
}
:root[data-theme="dark"] .spray-tip { box-shadow: 0 10px 28px rgba(0,0,0,0.55); }

/* ---- At-bat log ------------------------------------------------------------------ */

.pa-card { margin-bottom: 18px; }

.pa-summary {
    display: flex; flex-wrap: wrap; gap: 6px 18px;
    margin: 0 0 12px; padding-bottom: 10px;
    border-bottom: 1px dashed var(--gridline);
    font-size: 12.5px; color: var(--text-secondary);
}
.pa-summary b { color: var(--text-primary); font-variant-numeric: tabular-nums; }

/* Outcome carries a colour so a column of results reads at a glance. Hits reuse the spray
   chart's hit ramp -- the same event should not be two different colours on one page. */
.pa-result { font-weight: 600; }
/* Colour here encodes VALENCE -- did the plate appearance go the batter's way -- and the label
   beside it carries the specifics. Hits used to borrow --hit-2b from the spray chart, where
   single/double/triple/home-run are a categorical scale and orange is just one of four steps.
   Read in a log next to strikeouts it was a different message: #e35c28 against a #d03b3b
   strikeout is one hue apart, so the best outcome in the table looked like the worst.
   Hits and walks now share the positive green. They are not equally good, but they are equally
   not-bad, and the label already says which is which. */
.pa-result--hit       { color: var(--status-good); }
.pa-result--walk      { color: var(--status-good); }
.pa-result--strikeout { color: var(--status-critical); }
.pa-result--out       { color: var(--text-secondary); font-weight: 500; }

.pa-detail {
    margin-left: 5px; font-size: 11px; color: var(--text-muted);
    white-space: nowrap;
}
.pa-filter-note { font-size: 12px; color: var(--text-secondary); margin-left: 4px; }
.pa-filter-note .pitch-code { margin-left: 2px; }

/* This table is 13 columns wide; without this the numbers wrap and the rows lose their
   alignment on a laptop. The .table-scroll wrapper handles anything narrower. */
.pa-card table { font-variant-numeric: tabular-nums; white-space: nowrap; }
.pa-den { color: var(--text-muted); font-weight: 400; }

/* Market comparison on Today's Games. Sits between the outcome label and the model's own
   percentage, so the eye reads "what we think / what it costs / the gap" in one line. */
.mkt { margin-left: auto; margin-right: 10px; font-size: 11px; white-space: nowrap; }
.mkt-price { color: var(--text-muted); font-variant-numeric: tabular-nums; }
.mkt-edge { margin-left: 5px; font-weight: 600; font-variant-numeric: tabular-nums; }
.mkt-edge.pos { color: var(--status-good); }
.mkt-edge.neg { color: var(--text-muted); }
.ladder-head { font-size: 13px; font-weight: 600; color: var(--text-primary); margin: 4px 0 8px; }

/* The two value splits sit side by side on a laptop and stack on a phone: they are meant to
   be read against each other, and one under the other loses the comparison. */
.split-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 26px; margin-bottom: 18px; }
.split-pair > div { min-width: 0; overflow-x: auto; }
.split-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
@media (max-width: 860px) { .split-pair { grid-template-columns: 1fr; } }
.colgroup { font-size: 11px; color: var(--text-muted); font-weight: 600; text-align: center; border-bottom: 1px solid var(--gridline); }
tr.edge-row td { border-top: 2px solid var(--gridline); }
/* "edge" spelled out next to the number -- the pair was being read as two prices. */
.mkt-tag { color: var(--text-muted); font-size: 10px; margin-left: 3px; }
#matchup-table .mkt { margin-left: 6px; font-size: 10.5px; }

/* Percentile card: the player's own numbers are out of date. Warning-toned rather than
   critical -- the figures are correct, they just describe a different moment. */
.stale-note {
    margin: 0 0 14px; padding: 9px 12px; border-radius: 8px;
    font-size: 13px; line-height: 1.5;
    color: var(--text-secondary);
    background: var(--gridline);
    border-left: 3px solid var(--status-warning, var(--status-serious));
}
.stale-note strong { color: var(--text-primary); }

/* Today's Games: per-game cards */
.lineup-flag {
    font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
    padding: 2px 7px; border-radius: 999px; margin-left: 8px; vertical-align: middle;
    color: var(--text-secondary); background: var(--gridline);
}
.lineup-flag--posted { color: var(--status-good); background: color-mix(in oklab, var(--status-good) 16%, transparent); }
.bench-more { margin: 6px 0 14px; }
.bench-more summary { cursor: pointer; font-size: 12.5px; color: var(--text-secondary); padding: 4px 0; }
.game-card .split-title { margin-top: 12px; }

/* Grouped header above the game-card tables: a label for a block of columns, not a
   sortable column itself. */
.group-head th {
    font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em;
    color: var(--text-muted); font-weight: 600; padding-bottom: 2px;
    border-bottom: 1px solid var(--gridline); text-align: left;
}
.group-head th:empty { border-bottom: none; }

/* ---- segmented control ----
   An either/or view switch. Built from anchors because each view is a real URL that should
   be linkable and work without JS; the styling is what says "one control, two states"
   rather than "two links". The raised look belongs to the SELECTED segment -- it is the one
   standing proud of the recessed track, which is the affordance people already read on a
   physical toggle. */
.segmented {
    display: inline-flex;
    gap: 2px;
    padding: 3px;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: 999px;
}
.segmented-item {
    padding: 6px 16px;
    border-radius: 999px;
    font-family: var(--font-ui);
    font-size: 13px;
    font-weight: 600;
    line-height: 1.4;
    color: var(--text-secondary);
    text-decoration: none;          /* these are anchors; the underline is what made them read as links */
    white-space: nowrap;
    transition: background 120ms ease, color 120ms ease;
}
.segmented-item:hover { color: var(--text-primary); }
.segmented-item.is-on {
    background: var(--surface-1);   /* --surface does not exist; the card surface is --surface-1 */
    color: var(--accent);
    box-shadow: var(--shadow-sm);
}
/* Keyboard focus has to stay visible -- removing the underline took the other affordance. */
.segmented-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .segmented-item { transition: none; } }

/* ---- data freshness stamp ----
   Sits under the nav on every page the pipeline feeds. Sized and coloured to be read only
   when looked for: muted text at 11.5px, with the dot carrying the state. Green is a
   confirmation rather than an achievement, so it is the dot that is green and not the text. */
.data-stamp {
    /* Matches `main` exactly (1400px / 24px, 16px on narrow) so the stamp lines up with the
       content beneath it rather than floating at its own margin. */
    max-width: 1400px;
    margin: 0 auto -14px;
    padding: 10px 24px 0;
    display: flex; align-items: center; gap: 7px;
    font-family: var(--font-ui); font-size: 11.5px; letter-spacing: .01em;
    color: var(--text-muted);
}
.data-stamp-dot {
    width: 6px; height: 6px; border-radius: 50%;
    background: currentColor; flex: none;
}
.data-stamp--fresh .data-stamp-dot { background: var(--status-good); }
.data-stamp--stale .data-stamp-dot { background: var(--text-muted); }
/* Two days behind is a failed run, not a slow one -- the only state that should catch an eye. */
.data-stamp--old { color: var(--status-critical); }
.data-stamp--old .data-stamp-dot { background: var(--status-critical); }

@media (max-width: 720px) { .data-stamp { padding: 8px 16px 0; margin-bottom: -10px; } }

/* A driver note that argues AGAINST the pick. Kept visible but visibly different, so the
   supporting reasons read as the answer to "why this pick" and this reads as the exception. */
.pick-driver--caveat {
    color: var(--status-serious);
    border-color: color-mix(in oklab, var(--status-serious) 35%, transparent);
    background: color-mix(in oklab, var(--status-serious) 10%, transparent);
}
.pick-driver--caveat::before { content: 'but '; opacity: .75; }

/* A picked batter who is not in his game's posted lineup. Muted-critical: the pick is real and
   locked, the player is not playing, and the card should say so rather than imply he starts. */
.not-starting {
    font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
    padding: 1px 6px; border-radius: 999px; margin-left: 6px; white-space: nowrap;
    color: var(--status-critical);
    background: color-mix(in oklab, var(--status-critical) 12%, transparent);
}

/* Marks a filter the page applied for you, not one you chose. Accent-tinted so it reads as
   active state rather than as another neutral fact chip beside it. */
.chip.chip--auto {
    color: var(--accent);
    border-color: color-mix(in oklab, var(--accent) 40%, transparent);
    background: color-mix(in oklab, var(--accent) 12%, transparent);
    font-weight: 600;
}


/* --- Top-K sweep table -------------------------------------------------------------------
   DIVERGING, on the site's own percentile scale: deep blue below parity, pale through the
   middle, deep red above. Those steps are interpolated from the exact RAMP in
   _percentiles.html, which follows Baseball Savant -- red is elite, blue is poor.

   The first version of this table used the sequential --seq-* blue ramp for "beats the naive
   picker". That inverted the convention a reader has already learned on every player page,
   so the strongest cells read as the weakest. In this domain blue is cold.

   Diverging is right here rather than merely permissible: 1.00x is a real midpoint -- parity
   with sorting the slate by recent form -- not an arbitrary middle of the range. */
:root {
    --lift-b3: #1558a8;   /* well below parity */
    --lift-b2: #6692c6;
    --lift-b1: #a4bede;   /* just below */
    --lift-r1: #e2bebe;   /* just above */
    --lift-r2: #cf8285;
    --lift-r3: #c05358;
    --lift-r4: #b02028;   /* well above */
}
.ksweep-card { margin-top: 14px; }
.ksweep-scroll { overflow-x: auto; }          /* the page must never scroll sideways */
table.ksweep { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
table.ksweep th, table.ksweep td { padding: 7px 10px; text-align: center; }
table.ksweep thead th {
    font-size: 12px; font-weight: 600; color: var(--text-secondary);
    border-bottom: 1px solid var(--gridline); white-space: nowrap;
}
table.ksweep thead th.thin { color: var(--text-muted); }   /* k<=2: very few picks */
table.ksweep th.mkt {
    text-align: left; white-space: nowrap; font-weight: 600;
    color: var(--text-primary); font-size: 13px;
}
table.ksweep th.mkt .dot {
    display: inline-block; width: 8px; height: 8px; border-radius: 50%;
    margin-right: 7px; vertical-align: middle;
}
table.ksweep tbody tr + tr th.mkt, table.ksweep tbody tr + tr td { border-top: 2px solid var(--surface-1); }
table.ksweep td.k { border-radius: 5px; line-height: 1.25; }
table.ksweep td.k .rate { display: block; font-size: 15px; font-weight: 600; color: var(--text-primary); }
table.ksweep td.k .lift { display: block; font-size: 11px; color: var(--text-secondary); margin-top: 1px; }
/* Sample size, in the cell rather than only in a tooltip. A -100% built from 10 bets and
   one built from 200 are different claims, and the grid invites comparing them. */
table.ksweep td.k .kn { display: block; font-size: 10px; color: var(--text-muted); margin-top: 2px; }
table.ksweep td.b3 .kn, table.ksweep td.b2 .kn, table.ksweep td.r3 .kn, table.ksweep td.r4 .kn { color: rgba(255,255,255,0.75); }

table.ksweep td.b3 { background: var(--lift-b3); }
table.ksweep td.b2 { background: var(--lift-b2); }
table.ksweep td.b1 { background: var(--lift-b1); }
table.ksweep td.r1 { background: var(--lift-r1); }
table.ksweep td.r2 { background: var(--lift-r2); }
table.ksweep td.r3 { background: var(--lift-r3); }
table.ksweep td.r4 { background: var(--lift-r4); }
/* White ink only on the steps dark enough to need it. */
table.ksweep td.b3 .rate, table.ksweep td.b3 .lift,
table.ksweep td.b2 .rate, table.ksweep td.b2 .lift,
table.ksweep td.r3 .rate, table.ksweep td.r3 .lift,
table.ksweep td.r4 .rate, table.ksweep td.r4 .lift { color: #fff; }
table.ksweep td.na .rate, table.ksweep td.na .lift { color: var(--text-muted); }

table.ksweep tfoot td, table.ksweep tfoot th {
    font-size: 11px; color: var(--text-muted); padding-top: 9px;
    border-top: 1px solid var(--gridline);
}
table.ksweep tfoot td.thin { color: var(--status-serious); }   /* flag the tiny samples */
table.ksweep tfoot th.mkt { font-weight: 500; color: var(--text-muted); font-size: 11px; }

.ksweep-legend { margin-top: 10px; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.ksweep-legend .swatch {
    display: inline-block; width: 14px; height: 10px; border-radius: 2px;
    border: 1px solid var(--border);
}
.ksweep-legend .swatch.b3 { background: var(--lift-b3); }
.ksweep-legend .swatch.b1 { background: var(--lift-b1); }
.ksweep-legend .swatch.r1 { background: var(--lift-r1); }
.ksweep-legend .swatch.r4 { background: var(--lift-r4); }

/* The floor: what a random pick from that slate hits. Deliberately recessive -- it is a
   reference point, not a result, and it must not compete with the cells that are. */
table.ksweep .baseline-col {
    font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums;
    border-right: 1px solid var(--gridline); padding-right: 14px;
}
table.ksweep thead th.baseline-col { font-weight: 500; }

/* Live-vs-backtest marker on the day-by-day picker. Deliberately quiet: it is a provenance
   note, not a status. The dot carries the distinction and the text says it plainly, because
   colour alone would be meaningless to anyone who has not learned this page's palette. */
.day-kind {
    display: inline-flex; align-items: center; gap: 6px;
    font-size: 12px; color: var(--text-secondary);
    padding: 4px 10px; border-radius: 999px;
    background: var(--surface-2); border: 1px solid var(--border);
}
.day-kind::before {
    content: ''; width: 7px; height: 7px; border-radius: 50%; flex: none;
}
.day-kind.live::before { background: var(--status-good); }
.day-kind.backtest::before { background: var(--text-muted); }

/* Batter swing profile -- a one-line description under the name. Quiet by construction: it is
   metadata, not a result, and it sits directly beneath an h1 where anything loud would compete
   with the player's own name. */
.swing-profile {
    display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
    margin: -4px 0 16px; padding: 9px 14px;
    background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px;
}
.swing-label { font-size: 14px; font-weight: 600; color: var(--text-primary); white-space: nowrap; }
.swing-metrics { display: flex; gap: 16px; flex-wrap: wrap; }
.swing-metric { display: flex; align-items: baseline; gap: 5px; font-variant-numeric: tabular-nums; }
.swing-metric .mname { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
.swing-metric .mval  { font-size: 13px; font-weight: 600; color: var(--text-primary); }
/* Above/below the league is a direction, not a verdict -- a long swing is not "good". Muted
   ink and an arrow, deliberately NOT the good/bad status colours. */
.swing-metric .mdiff { font-size: 11px; color: var(--text-muted); }
.swing-metric .mdiff.up::before   { content: '\2191 '; }
.swing-metric .mdiff.down::before { content: '\2193 '; }

/* Swing-shape chip on Pitch Mix Analysis. Distinct from the batter/pitcher chips beside it,
   because it is a DESCRIPTION rather than a selection -- dotted border says "this is context",
   not "this is a filter you set". */
.chip--swing {
    background: transparent; border-style: dotted;
    color: var(--text-secondary); font-weight: 500;
}

/* Pitch-familiarity line on Pitch Mix Analysis. A left rule carries the verdict so the text
   stays in normal ink -- the numbers are the point, and colouring them would make a soft
   sample look like a hard result. Deliberately NOT the status palette: this is "better or
   worse than his own norm", not good/bad. */
.familiarity {
    display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
    margin: 0 0 14px; padding: 9px 14px;
    background: var(--surface-1); border: 1px solid var(--border);
    border-left: 3px solid var(--baseline); border-radius: 8px;
    font-size: 13px; color: var(--text-secondary);
}
.familiarity .fam-seen { color: var(--text-primary); }
.familiarity .fam-sep { color: var(--text-muted); }
.familiarity .fam-delta { font-weight: 600; font-variant-numeric: tabular-nums; }
.familiarity.good { border-left-color: var(--lift-r3); }
.familiarity.good .fam-delta { color: var(--lift-r4); }
.familiarity.bad { border-left-color: var(--lift-b2); }
.familiarity.bad .fam-delta { color: var(--lift-b3); }

/* Recent-form strip, directly under the swing profile. Same recessive treatment: metadata, not
   a verdict. A flagged metric gets weight, not colour -- "moved" is not "bad", and the whole
   point of the finding is that direction does not determine consequence. */
.form-change {
    display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
    margin: -6px 0 16px; padding: 8px 14px;
    border: 1px dashed var(--border); border-radius: 10px;
}
.form-head { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
.form-tag { margin-left: 6px; padding: 2px 8px; border-radius: 999px; font-size: 11px; }
.form-tag.settled { background: var(--surface-2); color: var(--text-muted); }
.form-tag.moved { background: var(--accent-tint); color: var(--accent); font-weight: 600; }
.form-metrics { display: flex; gap: 15px; flex-wrap: wrap; }
.form-metric { display: flex; align-items: baseline; gap: 5px; font-variant-numeric: tabular-nums; }
.form-metric .fname { font-size: 11px; color: var(--text-muted); }
.form-metric .fval { font-size: 13px; color: var(--text-primary); }
.form-metric .fpct { font-size: 11px; color: var(--text-muted); }
.form-metric.flag .fname, .form-metric.flag .fpct { color: var(--text-secondary); font-weight: 600; }
.form-metric.flag .fval { font-weight: 700; }

/* Today's Picks signal badges. Quiet by default: these are context for a pick, not a verdict
   on it, and the model does not read them. Only the extremes get any weight. */
/* The performance number now leads the arsenal column, so it carries the weight the pitch
   count used to -- primary ink, tabular, and wide enough that the deltas beside it line up. */
.fam-xwoba { font-variant-numeric: tabular-nums; color: var(--text-primary); font-weight: 600; }
.badge-seen { font-size: 12px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.badge-seen.strong { color: var(--text-primary); font-weight: 600; }
.badge-delta { font-size: 11px; margin-left: 5px; font-variant-numeric: tabular-nums; color: var(--text-muted); }
.badge-delta.up { color: var(--lift-r4); font-weight: 600; }
.badge-delta.down { color: var(--lift-b3); font-weight: 600; }
.badge-form { font-size: 11px; padding: 2px 7px; border-radius: 999px; white-space: nowrap; }
.badge-form.steady { background: var(--surface-2); color: var(--text-muted); }
/* "moved" is not "worse" -- the inverted-U finding means direction does not decide the
   consequence -- so it gets attention, not a warning colour. */
/* Direction, not magnitude. A support badge argues for the pick and a caveat against it, so
   they must not look alike -- the old single .moved style painted both the same. */
.badge-form.support { background: var(--accent-tint); color: var(--accent); font-weight: 600; }
.badge-form.caveat { background: var(--surface-2); color: var(--text-secondary); font-weight: 600; }
.badge-form.moved { background: var(--accent-tint); color: var(--accent); font-weight: 600; }

/* Career vs last-30 on the familiarity line. The recent figure is deliberately lighter: it
   rests on about a fifth of the pitches, so it should read as a hint beside the career number
   rather than a competing headline. */
.familiarity .fam-recent { color: var(--text-muted); }
.familiarity .fam-recent strong { color: var(--text-secondary); }
.familiarity .fam-thin { font-size: 11px; color: var(--text-muted); }
.badge-recent {
    display: block; font-size: 10px; color: var(--text-muted);
    font-variant-numeric: tabular-nums; margin-top: 1px;
}

/* --- Navigation veil ---------------------------------------------------------------------
   Covers the old page while the next one is fetched, so a click has something to show for
   itself. Sits above everything including popovers. Not display:none when idle -- opacity and
   visibility keep it composited, so it can fade in without a layout pass at the exact moment
   the browser is busy starting a navigation. */
.nav-veil {
    position: fixed; inset: 0; z-index: 9999;
    display: flex; align-items: center; justify-content: center;
    background: var(--page-plane);
    opacity: 0; visibility: hidden;
    transition: opacity 140ms ease-out, visibility 0s linear 140ms;
}
.nav-veil.is-on { opacity: 1; visibility: visible; transition-delay: 0s, 0s; }
.nav-veil img {
    width: 84px; height: 84px;
    animation: nav-veil-spin 1.1s linear infinite;
    /* The ball is off-white and the light page plane is #f9f9f7, so without this it is a pale
       shape on a pale field and only the seams read. A SYMMETRIC halo, not an offset shadow:
       an offset one rotates with the ball and gives away that it is a spinning image rather
       than a spinning ball. Invisible on the dark plane, where the ball already stands out. */
    filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.30));
}
/* Linear, not eased: a ball that slows at the same point every turn reads as stuttering
   rather than spinning. */
@keyframes nav-veil-spin { to { transform: rotate(360deg); } }
/* Motion is decoration here -- the veil still does its job standing still. */
@media (prefers-reduced-motion: reduce) {
    .nav-veil img { animation: none; }
    .nav-veil { transition: none; }
}
