:root {
  color-scheme: light dark;
  --bg: #ffffff;
  --fg: #1a1a1a;
  --muted: #6b7280;
  --border: #e5e7eb;
  --accent: #2563eb;
  --danger: #dc2626;
  --success: #16a34a;

  /* Spacing scale — used for padding/margin/gap everywhere below instead of
     one-off rem values, so density stays consistent across views. */
  --s1: .25rem;
  --s2: .5rem;
  --s3: .75rem;
  --s4: 1rem;
  --s5: 1.5rem;

  --radius: 8px;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.04);

  /* Every top-level band (header, status line, live hero, main) shares one
     content width via --gutter below, so they line up down the left edge. They
     did not before: main was `max-width:960px; margin:0 auto` while the hero
     sat outside it and ran full-bleed, which put two different alignments on
     screen at once on any viewport wider than 960px.
     --prose is separate and much narrower on purpose — a container wide enough
     for a 7-column table is far too wide for a paragraph, so explanatory text
     is capped independently rather than inheriting the table's width. */
  --container: 1200px;
  --prose: 68ch;

  /* Bottom-bar height on mobile, reserved as body padding so a fixed nav never
     covers the last row of a table. */
  --navbar-h: 52px;

  /* Chart palette — read by chart.js:themeColors() via --c1..--c8. Same 8
     hex values in light and dark (colorblind-conscious categorical set;
     reads fine on both backgrounds so it isn't re-themed below). */
  --c1: #4e79a7;
  --c2: #f28e2b;
  --c3: #59a14f;
  --c4: #e15759;
  --c5: #76b7b2;
  --c6: #edc948;
  --c7: #b07aa1;
  --c8: #ff9da7;

  /* Heatmap hues for the lineup's age and HKB-value columns — the base of one
     sequential ramp each (see the heatmap block near .lineup-age). Deliberately
     not --danger/--success: those are reserved for state, and neither age nor
     dynasty value is a state. --heat-value tracks --accent, so it follows the
     dark override below on its own. Age takes the teal rather than a warmer
     hue because the tinted cell sits inside rows that can carry the pink alert
     background, and a mauve or amber pill on a pink row reads as part of the
     alert instead of as a reading. */
  --heat-age: var(--c5);
  --heat-value: var(--accent);
  /* Proj takes the mauve. The paragraph above ruled mauve and amber out because
     an alert row was a pink wash and a warm pill on it read as part of the
     alert; that wash is gone (the alert is now the inset --danger stripe on
     .lineup-row.alert), so the objection no longer applies. Mauve over amber
     because Proj is shaded for every player including the ones flagged below,
     and amber still reads as a caution next to a red stripe. It also has to
     stay separable from the teal and blue in the two columns beside it. */
  --heat-proj: var(--c7);
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #14161a;
    --fg: #e5e7eb;
    --muted: #9ca3af;
    --border: #2a2d33;
    --accent: #60a5fa;
    --danger: #f87171;
    --success: #4ade80;

    /* The dark ramp is picked, not inherited: --c5 at a low tint disappears
       against #14161a, so the age hue steps lighter here to keep the same
       number of distinguishable steps. --heat-value needs no entry — it tracks
       --accent, which is already re-picked for dark just above. */
    --heat-age: #9fdcd7;
    /* Same reason as the age hue: --c7 at a low tint is indistinguishable from
       #14161a, so the mauve steps lighter for dark. */
    --heat-proj: #d8b6d2;

    --shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.3);
  }
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--fg);
}

#login-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 1rem;
}
#login-screen[hidden] { display: none; }

#bootstrap-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 1rem;
}
#bootstrap-screen[hidden] { display: none; }

#login-screen .login-card, #bootstrap-screen .login-card {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  width: 100%;
  max-width: 320px;
}

#login-screen .login-card input, #bootstrap-screen .login-card input {
  padding: var(--s2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg);
  color: var(--fg);
}

#login-screen .login-card button, #bootstrap-screen .login-card button {
  padding: var(--s2);
  border: none;
  border-radius: var(--radius);
  background: var(--accent);
  color: white;
  cursor: pointer;
}

.error { color: var(--danger); min-height: 1.2em; }

#shell[hidden] { display: none; }

/* Shared content edges for every top-level band. max() collapses to the plain
   --s4 gutter whenever the viewport is narrower than --container, so this is a
   no-op on mobile and a centering rule on desktop. */
header, #status-line, #live-hero, main {
  --gutter: max(var(--s4), calc((100% - var(--container)) / 2));
  padding-left: var(--gutter);
  padding-right: var(--gutter);
}

header {
  display: flex;
  align-items: center;
  gap: var(--s4);
  padding-top: var(--s3);
  padding-bottom: var(--s3);
  border-bottom: 1px solid var(--border);
  flex-wrap: wrap;
}

header h1 { font-size: 1.1rem; margin: 0; font-weight: 700; letter-spacing: -.01em; }

nav { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: center; }

/* display:contents dissolves the two mobile groups on desktop, so all ten
   links lay out as direct flex children of nav — one flat row, as before. */
.nav-primary, .nav-secondary { display: contents; }
#nav-more { display: none; }

nav a {
  position: relative;
  color: var(--muted);
  text-decoration: none;
  padding: var(--s1) var(--s2);
  border-radius: var(--radius);
  font-size: .9rem;
  transition: color .15s ease, background-color .15s ease;
}

nav a:hover { color: var(--fg); background: var(--border); }

nav a.active {
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
  font-weight: 600;
}

/* Keyboard focus must stay visible everywhere — the nav is the one surface
   where losing it makes the whole app unnavigable. */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Runs nav item badges itself with a dot while a run is live (live.js). */
nav a.has-live::after {
  content: "●";
  color: var(--accent);
  font-size: .6rem;
  margin-left: .25rem;
  vertical-align: middle;
}

#logout-btn {
  margin-left: auto;
  padding: var(--s1) var(--s3);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: transparent;
  color: var(--fg);
  cursor: pointer;
}

/* Horizontal padding comes from the shared band rule above; only the vertical
   rhythm belongs here. */
main { padding-top: var(--s4); padding-bottom: var(--s4); }

main h2 { font-size: 1.05rem; font-weight: 650; margin: var(--s5) 0 var(--s3); }
main h2:first-child { margin-top: 0; }
main h3 { font-size: .95rem; font-weight: 650; margin: 0 0 var(--s2); }

/* Explanatory text is capped well short of --container. A line of prose stops
   being readable long before a table does. */
main p, .sub, .insights li { max-width: var(--prose); }

/* A caption that sits inside a heading must not be read as part of it. These
   carry the window/role/method notes that projections.js and value.js append
   to their <h2>s; without the size and weight drop they wrap to three lines at
   heading size on a phone and the caveat looks like the title. */
h2 .cap, h3 .cap {
  display: block;
  font-size: .8rem;
  font-weight: 400;
  line-height: 1.35;
  letter-spacing: 0;
  color: var(--muted);
  margin-top: .15rem;
  max-width: var(--prose);
}
@media (min-width: 640px) {
  /* Wide enough to sit on the heading's own line without wrapping it. */
  h2 .cap, h3 .cap { display: inline; margin-top: 0; margin-left: var(--s2); }
}

table {
  border-collapse: collapse;
  width: 100%;
  margin: var(--s2) 0;
  font-size: .88rem;
}
th, td { text-align: left; padding: var(--s2) var(--s3); border-bottom: 1px solid var(--border); }
thead th {
  color: var(--muted);
  font-size: .75rem;
  font-weight: 650;
  text-transform: uppercase;
  letter-spacing: .03em;
  border-bottom: 2px solid var(--border);
}
/* table-layout:fixed pins the value column's width to what's left after the
   12rem label column, regardless of content. Needed because render.js's
   renderKeyValue nests a renderTable() (wrapped in .table-wrap) inside a
   <td> for any object value that's itself an array of objects (e.g. a
   backtest run's {start,end,days:[...]} output) — under the default
   table-layout:auto, a <td>'s width is sized to fit its content, so a wide
   nested table drags the whole kv-table past its card no matter what
   overflow rule the nested .table-wrap carries; a table cell only honors a
   descendant's overflow-x:auto once the cell itself has a fixed width. */
.kv-table { table-layout: fixed; }
/* min(12rem, 40%): plain 12rem broke down under render.js's recursive
   renderKeyValue — an object value containing an array (e.g. a backtest
   run's {mae,bias,by_position:[...]}) nests a second kv-table inside the
   first one's <td>, and every level after the first has less than 12rem of
   actual width to give the label column. table-layout:fixed treats a
   literal 12rem as non-negotiable regardless of how narrow its container
   is, so the second-level table rendered wider than the space it was given
   and dragged the page sideways with it; the percentage half of min() lets
   the label column shrink with each level of nesting the way the fixed rem
   value couldn't, while 12rem still wins at normal (unnested, or single
   level) widths, where 40% would otherwise be needlessly wide. */
.kv-table th { color: var(--muted); width: min(12rem, 40%); }
/* A fixed-width column doesn't stop an unbreakable value (a run id, a token,
   a long URL) from overflowing its cell — table-layout:fixed only allocates
   the width, it doesn't make content wrap. Same fix as .infra-meta below. */
.kv-table td { overflow-wrap: anywhere; }
/* overflow-wrap is inherited, so without this a nested .table-wrap (a
   renderTable() result sitting in a kv-table <td>, e.g. by_position inside a
   backtest run's output) would break every cell's text one character per
   line once the label column above it has squeezed the available width down
   to a few dozen pixels. A nested table already has its own scroll handling
   — it doesn't need to be legible at that width, just scrollable at its
   natural one — so this resets the cascade at the boundary between the two. */
.kv-table td .table-wrap { overflow-wrap: normal; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }

table.data-table tbody tr:hover { background: color-mix(in srgb, var(--border) 40%, transparent); }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--fg); }
tr.best { background: color-mix(in srgb, var(--accent) 10%, transparent); }
tr.best td { font-weight: 600; }
tr.lead { background: color-mix(in srgb, var(--success) 12%, transparent); }
tr.lead td { font-weight: 600; }
td.over { color: var(--danger); }
td.under { color: var(--success); }
td.cov.warn { color: var(--danger); }

.muted { color: var(--muted); }
.sub { font-size: .85rem; margin: var(--s2) 0; }

.card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: var(--s4);
  margin-bottom: var(--s4);
  background: var(--bg);
}

.badge {
  display: inline-block;
  padding: .15rem var(--s2);
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 600;
  white-space: nowrap;
  /* Default styling so a status the CSS has never heard of (a new Fantrax
     roster state, say) still reads as a badge rather than as bare text. */
  background: color-mix(in srgb, var(--muted) 18%, transparent);
  color: var(--muted);
}
.badge-ok, .badge-success { background: color-mix(in srgb, var(--success) 20%, transparent); color: var(--success); }
.badge-running { background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); }
/* .badge-locked deliberately falls through to the neutral default above. It was
   grouped with .badge-running on accent blue, which gave the most common state
   on the lineup board the same weight as a live run — twenty accent chips
   competing with the one red BENCHED chip that actually wants the eye. Locked
   is information, so it recedes; the rule is split rather than restyled because
   accent is still right for a run that is genuinely in progress. */
.badge-benched, .badge-failed, .badge-failure, .badge-il { background: color-mix(in srgb, var(--danger) 20%, transparent); color: var(--danger); }
.badge-info { background: color-mix(in srgb, var(--muted) 20%, transparent); color: var(--muted); }

.swatch {
  display: inline-block;
  width: .65rem;
  height: .65rem;
  border-radius: 2px;
  margin-right: .35rem;
  vertical-align: middle;
}

/* ---- Help bubbles (render.js help/caption) ------------------------------- */

.help-wrap { display: inline; white-space: nowrap; }

.help-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.15rem;
  height: 1.15rem;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 50%;
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: .7rem;
  line-height: 1;
  cursor: pointer;
  vertical-align: baseline;
  transition: color .15s ease, border-color .15s ease;
}
.help-btn:hover { color: var(--accent); border-color: var(--accent); }

/* The tap target is grown past the visible circle rather than the circle being
   drawn at 44px — a 44px dot beside 12px caption text would dominate the line
   it is annotating. */
.help-btn::after {
  content: "";
  position: absolute;
  min-width: 44px;
  min-height: 44px;
  transform: translate(-50%, -50%);
}
.help-btn { position: relative; }

.help-pop {
  /* Popovers with no anchor positioning are centered in the viewport by the
     UA; only sizing and skin are needed here. */
  max-width: min(34rem, calc(100vw - 2rem));
  padding: var(--s4);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg);
  color: var(--fg);
  box-shadow: 0 8px 24px rgba(0, 0, 0, .18), var(--shadow);
  font-size: .875rem;
  font-weight: 400;
  line-height: 1.5;
  /* pre-line, not normal: the help strings use a blank line to separate
     paragraphs, and they are set with textContent (never innerHTML) because
     several of them quote values that came from Fantrax. pre-line is what
     preserves those breaks while still wrapping each paragraph normally. */
  white-space: pre-line;
  text-align: left;
  letter-spacing: 0;
}
.help-pop::backdrop { background: rgba(0, 0, 0, .35); }

@media (prefers-reduced-motion: no-preference) {
  .help-pop { transition: opacity .12s ease, overlay .12s allow-discrete, display .12s allow-discrete; }
  .help-pop:not(:popover-open) { opacity: 0; }
  @starting-style { .help-pop:popover-open { opacity: 0; } }
}

/* ---- Toggle groups (window/role/system/metric segmented controls) ------ */
/* projections.js / value.js build these as <div class="toggle-group"><button
   data-*="..."></button>...</div> and mark the selected button by toggling a
   plain `active` class (not aria-pressed) — see buildSeg/paintSegActive and
   wireMetricSeg/wireLegend. */

.controls { display: flex; flex-wrap: wrap; gap: var(--s3); margin: var(--s3) 0; }

.toggle-group { display: flex; flex-wrap: wrap; gap: var(--s1); }

.toggle-group button {
  padding: .3rem var(--s3);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg);
  color: var(--muted);
  font-size: .82rem;
  cursor: pointer;
  transition: color .15s ease, background-color .15s ease, border-color .15s ease;
}
.toggle-group button:hover { color: var(--fg); border-color: var(--accent); }
.toggle-group button.active {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
  font-weight: 600;
}

/* ---- Stat tiles (scorecard MAE/Bias/RMSE/N, standings-adjacent stats) --- */
/* projections.js's tile() emits <div class="stat-tile"><div class="label">…
   </div><div class="value">…</div><div class="delta good|bad|flat">…</div>
   </div>, grouped in a <div class="stat-row">. */

.stat-row { display: flex; flex-wrap: wrap; gap: var(--s3); margin: var(--s3) 0; }

.stat-tile {
  flex: 1 1 140px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--s3);
  background: color-mix(in srgb, var(--border) 18%, transparent);
}
.stat-tile .label {
  font-size: .72rem;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: .03em;
}
.stat-tile .value {
  font-size: 1.6rem;
  font-weight: 700;
  margin: .1rem 0;
  font-variant-numeric: tabular-nums;
}
.stat-tile .delta { font-size: .78rem; }
.stat-tile .delta.good { color: var(--success); }
.stat-tile .delta.bad { color: var(--danger); }
.stat-tile .delta.flat { color: var(--muted); }

/* ---- Chart canvases ----------------------------------------------------- */
/* chart.js wrappers set responsive:true + maintainAspectRatio:false, which
   need an explicitly-sized, positioned parent or the canvas collapses/grows
   unbounded. */
.chart-box { position: relative; height: 220px; margin: var(--s3) 0; }

/* ---- Collapsible drawers (projections.js diagnostics) -------------------- */
/* Native <details>, so keyboard operation, find-in-page and the open/closed
   state all come from the platform. Styled to match .card exactly: a drawer is
   a card whose contents you asked for. */

.drawer {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  background: var(--bg);
  margin-bottom: var(--s4);
  padding: 0 var(--s4);
}
.drawer[open] { padding-bottom: var(--s4); }

.drawer > summary {
  display: flex;
  align-items: center;
  gap: var(--s2);
  padding: var(--s3) 0;
  min-height: 44px;
  cursor: pointer;
  list-style: none;
}
.drawer > summary::-webkit-details-marker { display: none; }
.drawer > summary::before {
  content: "";
  flex: none;
  width: 0;
  height: 0;
  border-left: 5px solid var(--muted);
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  transition: transform .15s ease;
}
.drawer[open] > summary::before { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) {
  .drawer > summary::before { transition: none; }
}
.drawer > summary:hover { color: var(--accent); }
.drawer > summary h3 { margin: 0; flex: 1 1 auto; font-weight: 650; }

.drawer-count:not(:empty) {
  display: inline-block;
  margin-left: var(--s2);
  padding: .05rem .4rem;
  border-radius: 999px;
  background: color-mix(in srgb, var(--muted) 20%, transparent);
  color: var(--muted);
  font-size: .72rem;
  font-weight: 600;
}

/* ---- Insights list ------------------------------------------------------- */
.insights { list-style: none; padding: 0; margin: var(--s2) 0; display: flex; flex-direction: column; gap: var(--s2); }
.insights li {
  padding: var(--s2) var(--s3);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--border) 25%, transparent);
  font-size: .85rem;
}
.insights li.warn { border-left: 3px solid var(--danger); }
.insights li.flat { color: var(--muted); background: transparent; }

/* ---- Team badges (value.js standings) ----------------------------------- */
.team { display: flex; align-items: center; gap: var(--s2); }
.logo { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }

/* ---- Lineup (#lineup) ---------------------------------------------------- */
/* One row per slot instead of one card per slot. The grid is fixed at four
   columns so slot codes, projections and the alert stripe all line up down the
   page — the scan you actually do here is vertical, down the projection
   column, and a wrapping card grid makes that impossible. */

.lineup-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
  flex-wrap: wrap;
  margin-bottom: var(--s3);
}
.lineup-head h2 { margin: 0; }
.lineup-total { display: flex; align-items: baseline; gap: var(--s2); }
.lineup-total strong { font-size: 1.5rem; font-variant-numeric: tabular-nums; }
.lineup-total span { font-size: .8rem; }

.lineup-warnings {
  border-left: 3px solid #d97706;
  background: color-mix(in srgb, #d97706 10%, transparent);
  border-radius: var(--radius);
  padding: var(--s2) var(--s3);
  margin-bottom: var(--s3);
  max-width: var(--prose);
}
.lineup-warnings ul { margin: 0; padding-left: 1.1rem; }
.lineup-warnings li { font-size: .85rem; }
.lineup-warnings li + li { margin-top: var(--s1); }

.lineup-list {
  list-style: none;
  margin: var(--s2) 0;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.lineup-row {
  display: grid;
  grid-template-columns: 2.75rem 2rem minmax(0, 1fr) auto 2.75rem 3.75rem 3.25rem;
  align-items: center;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  border-bottom: 1px solid var(--border);
  min-height: 44px;
  font-size: .88rem;
}
.lineup-row:last-child { border-bottom: none; }

/* The column header carries the labels for the two numeric columns the legend
   colors. It sits in the same grid as the data rows so the labels land over the
   numbers they name. */
.lineup-colhead {
  min-height: 0;
  padding-top: var(--s1);
  padding-bottom: var(--s1);
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--muted);
  background: color-mix(in srgb, var(--border) 25%, transparent);
}
.lineup-colhead .lineup-name { font-weight: 700; }
/* Both cells carry their own font-size for the data rows, which would otherwise
   beat the header row's .68rem and leave POS visibly larger than the labels
   beside it. The header is one type size across all seven columns. */
.lineup-colhead .lineup-pos,
.lineup-colhead .lineup-club { font-size: inherit; color: inherit; }

/* Sortable column headers. This list is a grid of <li>, not a <table>, so the
   th.sortable rule above does not reach it and the affordance is rebuilt here.

   display:block + width:100% rather than inline-flex, so each button inherits
   the text-align of the column it sits in — the three numeric columns are
   right-aligned, and a flex button would need its own justify-content to match,
   which is a second place for the alignment to drift out of sync. The button is
   stripped back to its parent's typography (font, color, letter-spacing,
   text-transform) so turning a label into a control changed nothing visible
   about the label. */
.lineup-sort {
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  text-align: inherit;
  cursor: pointer;
  user-select: none;
}
.lineup-sort:hover { color: var(--fg); }
.lineup-sort.active { color: var(--fg); }
.lineup-sort:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 3px;
}
/* The arrow is the only thing marking the active column, so it must not be the
   first thing to fall off a narrow screen — it rides in the same inline flow as
   the label and shrinks with it. */
.lineup-arrow { font-size: .85em; margin-left: .25em; }

.lineup-slot {
  font-size: .7rem;
  font-weight: 700;
  letter-spacing: .04em;
  color: var(--muted);
  text-transform: uppercase;
}
.lineup-name {
  display: flex;
  align-items: center;
  gap: var(--s2);
  min-width: 0;
  font-weight: 600;
}
/* The name is the only field allowed to truncate; everything else is short and
   fixed, so a long name must not push the projection off the row. */
.lineup-name > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lineup-empty { font-weight: 400; font-style: italic; color: var(--muted); }
/* The club is a logo, not a text abbreviation: it is recognised faster than
   three letters are read, and at eighteen rows that is the difference between
   scanning the column and reading it. The abbreviation survives as alt/title
   and as the visible fallback, so nothing depends on recognising the mark.

   text-align rather than flex, so the header button — which inherits its
   parent's alignment — centres over the logos without needing its own
   justify-content, the same reason .lineup-sort is display:block. */
.lineup-club { text-align: center; }
.lineup-logo {
  width: 18px;
  height: 18px;
  object-fit: contain;
  vertical-align: middle;
}
.lineup-club-text { font-size: .7rem; color: var(--muted); white-space: nowrap; }
.lineup-pos {
  font-size: .78rem;
  color: var(--muted);
  white-space: nowrap;
  text-align: right;
}
.lineup-proj {
  --heat-pct: 0%;
  text-align: right;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  padding: 2px var(--s1);
  border-radius: 4px;
  background: color-mix(in srgb, var(--heat-proj) var(--heat-pct), transparent);
}

/* ---- Age / HKB value heatmap -------------------------------------------- */
/* Two sequential single-hue ramps, one per column, both running the same way:
   more is darker. The step is an alpha tint of one hue over the row surface,
   set per cell by lineup.js as --heat-pct, so the ramp stays monotonic in
   lightness by construction rather than by a hand-picked list of steps that
   could drift out of order.

   Each ramp keeps its own hue so the two columns never read as one scale: age
   takes a palette hue with no status meaning anywhere else in the dashboard,
   value takes the accent already used for "this is the quantity that matters"
   (tr.best, .lead). Neither uses --danger or --success — those are reserved for
   state, and an expensive 33-year-old is not an error.

   The text stays in normal ink at every step: at a 26% ceiling the tint moves
   the surface far too little to threaten the 4.5:1 the label needs, in either
   theme. The dark mode is chosen rather than inherited — see --heat-age under
   prefers-color-scheme: dark. */
.lineup-age,
.lineup-value {
  --heat-pct: 0%;
  text-align: right;
  font-variant-numeric: tabular-nums;
  padding: 2px var(--s1);
  border-radius: 4px;
}
.lineup-age { background: color-mix(in srgb, var(--heat-age) var(--heat-pct), transparent); }
.lineup-value { background: color-mix(in srgb, var(--heat-value) var(--heat-pct), transparent); }
/* The header and the empty-slot dashes are not readings, so they are never
   shaded — a tinted "—" would claim a magnitude that does not exist. */
.lineup-colhead .lineup-age,
.lineup-colhead .lineup-value,
.lineup-colhead .lineup-proj,
.lineup-age.muted,
.lineup-value.muted { background: none; padding-left: 0; padding-right: 0; }
/* .lineup-proj.muted is deliberately NOT in that list. A muted Proj is a 0.0 —
   a real projection for a player whose team has no game — not a missing value,
   so it keeps the column's padding and stays aligned with the numbers above and
   below it. It needs no background rule: at the foot of its ramp --heat-pct is
   already 0%, so it renders transparent on its own. */

.lineup-legend {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s2) var(--s4);
  margin: var(--s2) 0;
  font-size: .75rem;
  color: var(--muted);
}
.lineup-key { display: inline-flex; align-items: center; gap: var(--s2); }
.lineup-ramp {
  width: 46px;
  height: 10px;
  border-radius: 3px;
  border: 1px solid var(--border);
}
.lineup-ramp.ramp-age {
  background: linear-gradient(to right,
    color-mix(in srgb, var(--heat-age) 0%, transparent),
    color-mix(in srgb, var(--heat-age) 26%, transparent));
}
.lineup-ramp.ramp-value {
  background: linear-gradient(to right,
    color-mix(in srgb, var(--heat-value) 0%, transparent),
    color-mix(in srgb, var(--heat-value) 26%, transparent));
}
/* One ramp class, used by both Proj keys: hitters and pitchers are the same
   quantity against different populations, so they share the hue and are told
   apart by the two domains printed beside them. */
.lineup-ramp.ramp-proj {
  background: linear-gradient(to right,
    color-mix(in srgb, var(--heat-proj) 0%, transparent),
    color-mix(in srgb, var(--heat-proj) 26%, transparent));
}

/* The stripe is the anomaly marker — it reads down the left edge, so spotting
   the rows that need attention among the rest takes no reading at all.

   It is an edge stripe and NOT a row wash on purpose. The wash tinted the whole
   row, which set the tinted status cells and the heatmap cells on the same
   background and made a routine LOCKED row look like a failure. Status is now
   carried by the tag in the name cell; the stripe only says "this one is still
   actionable", and slotNeedsAttention keeps it off the informational states, so
   it marks one row on a typical evening rather than twenty-one. */
.lineup-row.alert {
  box-shadow: inset 3px 0 0 var(--danger);
}

@media (max-width: 560px) {
  /* Position used to be dropped here as "the least-scanned field". It is now a
     column you can sort on, and stripping the universal UT shortened it from
     "2B/SS/INF/UT" to "2B/SS/INF" — often just "OF" or "P" — so it earns its
     place down to the next breakpoint. The fixed columns tighten instead. */
  .lineup-row { grid-template-columns: 2.5rem 1.9rem minmax(0, 1fr) auto 2.6rem 3.4rem 2.9rem; }
}
@media (max-width: 420px) {
  /* Below this the six columns cannot all hold their width, and squeezing them
     truncates the name — the one field with nothing to fall back on. Age is
     dropped rather than HKB value because value is the field with the wider
     spread and therefore the one the heatmap is actually read for; the legend
     drops its age key to match, so the page never explains a column it is not
     showing. */
  .lineup-row { grid-template-columns: 2.5rem 1.9rem minmax(0, 1fr) 3.4rem 2.9rem; }
  .lineup-age { display: none; }
  /* Position goes here rather than at 560px: below this width the name starts
     truncating, and the club logo says more per pixel than the eligibility
     list does. */
  .lineup-pos { display: none; }
  /* Separate rule on purpose: one invalid selector invalidates its whole
     selector list, so pairing :has() with .lineup-age would take the column
     hide down with it on a browser without :has — leaving six items in a
     five-column grid. Alone, it degrades to a legend key for a hidden column,
     which is untidy rather than broken. */
  .lineup-key:has(.ramp-age) { display: none; }
}

/* ---- Wide-table scroll container ---------------------------------------- */
/* Every table.data-table wraps in this: views.js, trades.js, projections.js,
   value.js, runs.js, and render.js's generic renderTable (arbitrary JSON, so
   its column count is unbounded). Without it, table{width:100%} can't shrink
   a table below its intrinsic content width, so the overflow bubbles up
   through <main>/<body> and widens the whole page's layout viewport on
   narrow screens instead of staying inside the table — first caught when
   views.js referenced this class before it was ever defined (the readership
   table pushed the page sideways on a phone), then again on the Projections
   tab's comparison/position/misses tables (rosterbot-81n, measured: 463px
   effective width on a 390px device). .kv-table (2 columns, key/value) is
   the one data table that stays unwrapped directly — it constrains its own
   width via table-layout:fixed instead (see above), since it's the one
   place a .table-wrap can itself end up nested inside another table's <td>
   (render.js's renderKeyValue, for an object value that's itself an array),
   where overflow-x:auto on the wrap alone isn't enough. */
.table-wrap { overflow-x: auto; }

/* Columns a table can afford to drop on a phone. Applied where the same value
   is reachable one tap away (the Runs table's Duration/Trigger both appear in
   the run detail), never where dropping it would leave the row ambiguous. */
@media (max-width: 600px) {
  .col-secondary { display: none; }
}

/* ---- Signed deltas ------------------------------------------------------- */
/* Bare .good/.bad, as opposed to the .stat-tile .delta.good pair above, so a
   table cell can carry a direction. trades.js uses these on both a value
   change and a rank change — note that for rank, a LOWER number is better, so
   the class is chosen by the caller, never derived from the sign here. */
.good { color: var(--success); }
.bad { color: var(--danger); }

/* ---- Trades (#trades) ---------------------------------------------------- */
/* Each offer is a card: a head with the verdict badge, then one panel per
   side. Sides are a grid rather than a fixed two-column layout because
   tradevalue is n-sided — a three-team trade renders as three panels without
   a CSS change (internal/transactions' fixed [2]TradeSide is the bug this
   avoids inheriting). */
.trade-head { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; }
.trade-head h2 { margin: 0; }

.trade-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--s3);
  margin: var(--s3) 0;
}

.trade-side {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--s3);
  background: color-mix(in srgb, var(--border) 18%, transparent);
}
.trade-side-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s2);
  margin-bottom: var(--s2);
}

.trade-assets { list-style: none; padding: 0; margin: 0; }
.trade-assets li {
  display: flex;
  align-items: baseline;
  gap: var(--s2);
  padding: .2rem 0;
  font-size: .88rem;
}
/* Values are the column you scan, so they lead and are tabular-aligned. */
.trade-val {
  min-width: 3.5rem;
  text-align: right;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}
.trade-name { flex: 1 1 auto; }
.trade-pos { font-size: .78rem; }

.trade-totals {
  display: flex;
  justify-content: space-between;
  gap: var(--s2);
  margin-top: var(--s2);
  padding-top: var(--s2);
  border-top: 1px solid var(--border);
  font-size: .85rem;
  font-variant-numeric: tabular-nums;
}

/* The values table's filter row. .controls elsewhere holds only toggle-groups,
   so scoping the select to it affects nothing but this view. */
.trade-search,
.controls select {
  padding: var(--s2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg);
  color: var(--fg);
  font-size: .85rem;
}
.trade-search { flex: 1 1 200px; }

/* ---- Jobs (#jobs) -------------------------------------------------------- */
/* One collapsed drawer per job. The list is tighter than the .drawer default
   used on Projections: there are fourteen of these and the point of the view
   is to scan the names, so the closed rows sit close together and the box
   drawing is shared rather than per-item. */

.job-list {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  background: var(--bg);
  overflow: hidden;
}
.job-drawer {
  border: none;
  border-radius: 0;
  box-shadow: none;
  margin: 0;
  border-bottom: 1px solid var(--border);
}
.job-drawer:last-child { border-bottom: none; }
.job-drawer[open] { padding-bottom: var(--s3); background: color-mix(in srgb, var(--border) 12%, transparent); }
.job-drawer > summary { padding: var(--s2) 0; }
.job-drawer > summary h3 { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; font-size: .9rem; }
.job-drawer > p { margin: 0 0 var(--s3); }

form.job-form { display: flex; flex-direction: column; gap: var(--s3); max-width: 420px; }
.job-field { display: flex; flex-direction: column; gap: var(--s1); }
/* A checkbox belongs beside its label, not under it — stacked, a lone switch
   reads as a section heading with an orphaned control. */
.job-field-inline { flex-direction: row; align-items: center; gap: var(--s2); }
.job-field-inline label { font-weight: 400; }

.job-actions { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; }
.job-status { margin: 0; font-size: .82rem; }
.job-status.error { color: var(--danger); }

/* Two native pickers composing into the CLI's "start:end" string.
   Grid rather than flex: a date input's intrinsic width is set by its
   "mm/dd/yyyy" placeholder and the calendar button, and it cannot be
   compressed below that without clipping the day field. Under flex-wrap the
   second input dropped to its own line while the "to" stayed beside the
   first, which read as a label on the start date rather than as a separator.
   Stacking all three below 30rem keeps the order start / to / end intact. */
.date-range {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s2);
}
.date-range input { min-width: 0; }
.date-range-sep { font-size: .8rem; color: var(--muted); }

@media (max-width: 30rem) {
  .date-range { grid-template-columns: 1fr; justify-items: stretch; }
  .date-range-sep { justify-self: start; }
}

form.job-form label { font-size: 0.85rem; font-weight: 600; color: var(--fg); }
form.job-form input, form.job-form select {
  padding: var(--s2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg);
  color: var(--fg);
  /* 16px stops iOS Safari zooming the viewport on focus, which on a phone
     leaves the form scrolled sideways after every tap into a field. */
  font-size: 16px;
  min-height: 44px;
}
form.job-form input[type="checkbox"] { min-height: 0; width: 1.15rem; height: 1.15rem; align-self: flex-start; }
.field-hint { font-size: .78rem; color: var(--muted); }

button.primary {
  padding: var(--s2) var(--s4);
  border: none;
  border-radius: var(--radius);
  background: var(--accent);
  color: white;
  cursor: pointer;
  align-self: flex-start;
}
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }

button.danger {
  padding: var(--s2) var(--s4);
  border: 1px solid var(--danger);
  border-radius: var(--radius);
  background: transparent;
  color: var(--danger);
  cursor: pointer;
}
button.danger:hover { background: color-mix(in srgb, var(--danger) 15%, transparent); }
button.danger:disabled { opacity: 0.5; cursor: not-allowed; }

pre {
  white-space: pre-wrap;
  word-break: break-word;
  background: color-mix(in srgb, var(--border) 40%, transparent);
  padding: var(--s2);
  border-radius: var(--radius);
  max-height: 300px;
  overflow-y: auto;
}

/* ---- Status line (status.js) ---------------------------------------------
   One line under the header answering "is everything fine?" before any
   scrolling. It reads the runs ledger that live.js already polls, so job
   health and lineup freshness cost no extra request; only the pending-offer
   count is its own fetch. */

#status-line:empty { display: none; }
#status-line { padding-top: var(--s2); padding-bottom: var(--s2); }

.status {
  display: flex;
  align-items: baseline;
  gap: var(--s2);
  flex-wrap: wrap;
  font-size: .85rem;
  line-height: 1.4;
}
.status-dot {
  width: .55rem;
  height: .55rem;
  border-radius: 50%;
  flex: none;
  align-self: center;
}
.status-dot.ok { background: var(--success); }
.status-dot.warn { background: #d97706; }
.status-dot.bad { background: var(--danger); }
.status-dot.idle { background: var(--muted); }

.status-main { font-weight: 600; }
.status-main.bad { color: var(--danger); }
.status-rest { color: var(--muted); }
/* Separates the facts without a bullet character between every pair. */
.status-rest > * + *::before { content: " · "; }

.status a { color: inherit; }

/* ---- Live run hero + progress (live.js) ---------------------------------- */

.hero { display: flex; flex-direction: column; gap: var(--s2); }
.hero-head { display: flex; align-items: center; gap: var(--s2); }
.progress { height: 8px; background: var(--border); border-radius: 999px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); transition: width .3s ease; }
.progress.indeterminate .progress-fill { width: 40% !important; animation: indet 1.2s ease-in-out infinite; }
@keyframes indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
.phases { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s2); padding: 0; margin: 0; }
.phase { font-size: .8rem; color: var(--muted); }
.phase-active { color: var(--accent); font-weight: 600; }
.phase-done { color: var(--success); }
.phase-warn { color: var(--danger); }

/* ---- Toasts (live.js) ---------------------------------------------------- */

#toasts { position: fixed; bottom: 1rem; right: 1rem; display: flex; flex-direction: column; gap: .5rem; z-index: 50; }
.toast { padding: .5rem .9rem; border-radius: var(--radius); box-shadow: var(--shadow); background: var(--bg); border: 1px solid var(--border); }
.toast-ok { border-left: 3px solid var(--success); }
.toast-fail { border-left: 3px solid var(--danger); }

/* ---- Mobile: nav becomes a bottom bar --------------------------------------
   Breakpoint is 950px because that is where the desktop row stops fitting:
   measured, the header needs 946px to hold the title, ten links and the logout
   button on one line. Below that the old rule let nav wrap to three rows,
   which cost 202px of header and pushed content to y=314 on a 375x812 phone —
   39% of the screen gone before any data, on every tab.

   Moving nav to the bottom does two things at once: it takes that 202px back,
   and it puts navigation in thumb reach on a device held one-handed. */
@media (max-width: 950px) {
  header {
    gap: var(--s2);
    padding-top: var(--s2);
    padding-bottom: var(--s2);
  }
  header h1 { flex: 1 1 auto; }
  #logout-btn { margin-left: 0; }

  /* column-reverse puts .nav-secondary (second in the DOM) visually above
     .nav-primary, so the four-item thumb row keeps the bottom edge and the
     More panel opens upward over the content. */
  nav {
    position: fixed;
    inset: auto 0 0 0;
    z-index: 40;
    flex-direction: column-reverse;
    flex-wrap: nowrap;
    gap: 0;
    background: var(--bg);
    border-top: 1px solid var(--border);
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }

  .nav-primary {
    display: flex;
    gap: 0;
  }
  .nav-primary > * { flex: 1 1 0; }

  .nav-secondary {
    display: none;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--s1);
    padding: var(--s2);
    border-bottom: 1px solid var(--border);
  }
  nav.open .nav-secondary { display: grid; }

  nav a, #nav-more {
    display: flex;
    align-items: center;
    justify-content: center;
    /* 44px is the minimum comfortable touch target; the old 0.25rem padding
       produced rows around 28px. */
    min-height: 44px;
    padding: var(--s1);
    border-radius: 0;
    font-size: .78rem;
    text-align: center;
  }
  .nav-secondary a { border-radius: var(--radius); font-size: .8rem; }

  nav a.active {
    background: transparent;
    color: var(--accent);
    box-shadow: inset 0 2px 0 var(--accent);
  }
  .nav-secondary a.active { background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: none; }
  nav a:hover { background: transparent; }

  #nav-more {
    display: flex;
    border: none;
    background: transparent;
    color: var(--muted);
    font-family: inherit;
    cursor: pointer;
  }
  nav.open #nav-more { color: var(--accent); }

  /* Reserve the bar's height so a fixed nav never covers the last table row. */
  body { padding-bottom: calc(var(--navbar-h) + env(safe-area-inset-bottom, 0px)); }

  /* Toasts must clear the bar too. */
  #toasts { bottom: calc(var(--navbar-h) + env(safe-area-inset-bottom, 0px) + var(--s2)); }
}

/* --- Infra status (#infra) --------------------------------------------- */
/* Reuses the existing --success/--danger/--accent/--muted tokens, so light
   and dark both come for free from the :root / prefers-color-scheme blocks. */

.infra-summary {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  flex-wrap: wrap;
  margin-bottom: 1rem;
}
.infra-summary button {
  margin-left: auto;
}

.infra-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  gap: 0.75rem;
}

.infra-card {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.infra-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}
.infra-meta {
  font-size: 0.85rem;
  color: var(--fg);
  /* Long prefixes and counts wrap rather than forcing the card wider — the
     grid is minmax()'d, so an overflowing card would push the page sideways. */
  overflow-wrap: anywhere;
}
.infra-detail {
  font-size: 0.85rem;
  color: var(--muted);
}

.infra-gap {
  font-size: 0.85rem;
  padding: 0.5rem;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--muted) 12%, transparent);
  overflow-wrap: anywhere;
}
/* A gap that can never be refilled reads as an error, not a note. */
.infra-gap-permanent {
  background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.infra-warn {
  margin-top: 0.25rem;
  color: var(--danger);
}

.health-ok { background: color-mix(in srgb, var(--success) 20%, transparent); color: var(--success); }
.health-gap, .health-missing { background: color-mix(in srgb, var(--danger) 20%, transparent); color: var(--danger); }
.health-stale { background: color-mix(in srgb, #d97706 22%, transparent); color: #b45309; }
.health-unknown { background: color-mix(in srgb, var(--muted) 20%, transparent); color: var(--muted); }

@media (prefers-color-scheme: dark) {
  /* Amber needs lifting on a dark ground to stay legible. */
  .health-stale { color: #fbbf24; }
}

.chip {
  display: inline-block;
  padding: 0.1rem 0.4rem;
  border-radius: 4px;
  background: color-mix(in srgb, var(--accent) 15%, transparent);
  color: var(--accent);
  font-size: 0.78rem;
}

@media (max-width: 480px) {
  .infra-grid { grid-template-columns: 1fr; }
}

/* .mono is used by the infra view for prefixes, partition dates and error
   strings — anything where character-level alignment aids scanning. */
.mono {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.85em;
}

/* Settings page (rosterbot-crq.5). Only the pieces the existing vocabulary
   does not already cover — cards, badges, .muted and .kv-table are reused. */
.disclosure {
  border-left: 3px solid var(--danger);
  padding: 0.6rem 0 0.6rem 0.9rem;
  margin: 1rem 0;
}
.disclosure h3 { margin: 0 0 0.4rem; font-size: 0.95rem; }
.disclosure ul { margin: 0; padding-left: 1.1rem; }
.disclosure li { margin-bottom: 0.4rem; color: var(--muted); }
.connect-form { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.5rem; }
.connect-form h3 { flex-basis: 100%; margin: 0.4rem 0 0; font-size: 0.95rem; }
.connect-form input { flex: 1 1 14rem; }
.connect-form p { flex-basis: 100%; margin: 0.3rem 0 0; }
.toggle { display: flex; align-items: center; gap: 0.6rem; margin: 0.6rem 0 0.2rem; cursor: pointer; }
.toggle input { width: 1.1rem; height: 1.1rem; }
.banner {
  padding: 0.6rem 0.9rem;
  border-radius: 6px;
  margin-bottom: 0.6rem;
  font-size: 0.9rem;
}
.banner.bad {
  background: color-mix(in srgb, var(--danger) 14%, transparent);
  color: var(--danger);
}
.banner a { color: inherit; font-weight: 600; }

/* tenants.js management controls (rosterbot-2twx). The action buttons reuse
   the bare <button> look; the minted-link box is the one place a credential is
   ever displayed, so it gets width and a visible border rather than blending
   into the card. */
.tenant-actions { white-space: nowrap; }
.tenant-actions button { margin-right: 0.35rem; }
.minted-link .link-row { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
.minted-link .link-row input { flex: 1 1 auto; font-family: var(--mono, monospace); }
.tenant-actions button.danger {
  color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 45%, transparent);
}
.tenant-actions button.danger:hover {
  background: color-mix(in srgb, var(--danger) 12%, transparent);
}
