/* pod-tracker — light/dark, print-friendly, no framework. */

:root {
  color-scheme: light;
  --surface-0: #f6f5f2;
  --surface-1: #fcfcfb;
  --surface-2: #f0efec;
  --border: #dedcd6;
  --border-strong: #c6c3ba;
  --text-primary: #0b0b0b;
  --text-secondary: #52514e;
  --text-muted: #7a7873;

  /* Categorical — validated (light): worst adjacent CVD dE 9.1, normal 22.9. */
  --series-1: #2a78d6; /* ordered */
  --series-2: #eb6834; /* picked */
  --series-3: #1baf7a; /* packed (printed proxy) */
  --series-4: #eda100; /* shipped */

  /* Ordinal funnel ramp — validated single-hue, all dL gaps >= 0.06. */
  --stage-1: #86b6ef;
  --stage-2: #5598e7;
  --stage-3: #2a78d6;
  --stage-4: #1c5cab;
  --stage-5: #104281;

  --status-good: #1a7f37;
  --status-warning: #9a6700;
  --status-critical: #b3261e;

  --radius: 10px;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root:where(:not([data-theme='light'])) {
    color-scheme: dark;
    --surface-0: #121211;
    --surface-1: #1a1a19;
    --surface-2: #232322;
    --border: #34342f;
    --border-strong: #4a4a44;
    --text-primary: #ffffff;
    --text-secondary: #c3c2b7;
    --text-muted: #918f86;

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

    --stage-1: #cde2fb;
    --stage-2: #9ec5f4;
    --stage-3: #6da7ec;
    --stage-4: #3987e5;
    --stage-5: #184f95;

    --status-good: #4ac26b;
    --status-warning: #d4a72c;
    --status-critical: #ff6b63;
  }
}

:root[data-theme='dark'] {
  color-scheme: dark;
  --surface-0: #121211;
  --surface-1: #1a1a19;
  --surface-2: #232322;
  --border: #34342f;
  --border-strong: #4a4a44;
  --text-primary: #ffffff;
  --text-secondary: #c3c2b7;
  --text-muted: #918f86;
  --series-1: #3987e5;
  --series-2: #d95926;
  --series-3: #199e70;
  --series-4: #c98500;
  --stage-1: #cde2fb;
  --stage-2: #9ec5f4;
  --stage-3: #6da7ec;
  --stage-4: #3987e5;
  --stage-5: #184f95;
  --status-good: #4ac26b;
  --status-warning: #d4a72c;
  --status-critical: #ff6b63;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--surface-0);
  color: var(--text-primary);
  font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
}

/* header + tabs stick as one block, so no gap can open between them. */
.topbar {
  position: sticky;
  top: 0;
  z-index: 20;
  background: var(--surface-1);
  border-bottom: 1px solid var(--border);
}

header {
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  padding: 14px 24px;
}

header h1 { font-size: 17px; margin: 0; letter-spacing: -0.01em; }
header .spacer { flex: 1; }

.controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

button, select {
  font: inherit;
  font-size: 13px;
  color: var(--text-primary);
  background: var(--surface-2);
  border: 1px solid var(--border-strong);
  border-radius: 7px;
  padding: 5px 11px;
  cursor: pointer;
}
button:hover, select:hover { border-color: var(--text-muted); }
button[aria-pressed='true'] { background: var(--series-1); border-color: var(--series-1); color: #fff; }

/* Tabs sit directly under the sticky header and stick with it. */
.tabs {
  display: flex;
  gap: 4px;
  padding: 0 24px;
  border-top: 1px solid var(--border);
  overflow-x: auto;
}
.tabs .tab {
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  border-radius: 0;
  padding: 9px 12px;
  color: var(--text-secondary);
  font-size: 13.5px;
  white-space: nowrap;
}
.tabs .tab:hover { color: var(--text-primary); }
.tabs .tab[aria-selected='true'] {
  color: var(--text-primary);
  background: none;
  border-bottom-color: var(--series-1);
  font-weight: 600;
}

main { padding: 20px 24px 64px; max-width: 1500px; margin: 0 auto; }
section { margin-bottom: 28px; }

h2 {
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--text-secondary);
  margin: 0 0 4px;
  font-weight: 600;
}

.sub { color: var(--text-muted); font-size: 13px; margin: 0 0 12px; max-width: 78ch; }

.card {
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 16px 18px;
}

.grid { display: grid; gap: 14px; }
.tiles { grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); }
.two-col { grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); align-items: start; }
/* minmax(0, 1fr), not 1fr: a grid item's automatic minimum size is its content,
   so a plain `1fr` lets a wide table push the column past the viewport and scroll
   the whole page sideways instead of scrolling inside its own .table-scroll. */
@media (max-width: 900px) { .two-col { grid-template-columns: minmax(0, 1fr); } }
.two-col > * { min-width: 0; }

.tile { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
.tile .label { font-size: 12px; color: var(--text-secondary); display: flex; align-items: center; gap: 7px; }
.tile .swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; }
.tile .value { font-size: 30px; font-weight: 620; letter-spacing: -0.025em; margin-top: 6px; font-variant-numeric: tabular-nums; }
.tile .value small { font-size: 14px; font-weight: 500; color: var(--text-muted); margin-left: 4px; letter-spacing: 0; }
.tile .foot { font-size: 12px; color: var(--text-muted); margin-top: 3px; font-variant-numeric: tabular-nums; }
.tile.alert { border-color: var(--status-warning); }
.tile.alert .value { color: var(--status-warning); }

.funnel { display: flex; flex-direction: column; gap: 2px; }
.funnel-row { display: grid; grid-template-columns: 132px 1fr 112px; align-items: center; gap: 12px; }
.funnel-row .name { font-size: 13px; color: var(--text-secondary); text-align: right; }
.funnel-bar-track { background: var(--surface-2); border-radius: 4px; height: 26px; overflow: hidden; }
.funnel-bar { height: 100%; border-radius: 0 4px 4px 0; min-width: 2px; transition: width 0.25s ease; }
.funnel-row .name .logiwa-name { display: block; font-size: 11px; color: var(--text-muted); }
.funnel-row .num { font-size: 13px; font-variant-numeric: tabular-nums; }
.funnel-row .num span { color: var(--text-muted); }

.chart-wrap { position: relative; overflow-x: auto; }
svg.chart { display: block; width: 100%; height: auto; }
.legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 2px 0 10px; font-size: 12.5px; color: var(--text-secondary); }
.legend .item { display: flex; align-items: center; gap: 6px; }
.legend .swatch { width: 12px; height: 3px; border-radius: 2px; }
.legend .swatch.sq { height: 10px; width: 10px; border-radius: 2px; }

.tooltip {
  position: absolute;
  pointer-events: none;
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: 8px;
  padding: 9px 11px;
  font-size: 12.5px;
  box-shadow: 0 6px 22px rgba(0, 0, 0, 0.16);
  z-index: 10;
  min-width: 176px;
}
.tooltip .t-day { font-weight: 620; margin-bottom: 6px; }
.tooltip .t-row { display: flex; align-items: center; gap: 7px; justify-content: space-between; }
.tooltip .t-row .k { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); }
.tooltip .t-row .v { font-variant-numeric: tabular-nums; }
.tooltip .swatch { width: 9px; height: 9px; border-radius: 2px; }

table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); }
th { color: var(--text-secondary); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:hover { background: var(--surface-2); }
.table-scroll { overflow-x: auto; max-height: 420px; overflow-y: auto; }
code, .mono { font-family: var(--mono); font-size: 12.5px; }

.pill { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11.5px; border: 1px solid var(--border-strong); color: var(--text-secondary); }
.pill.warn { color: var(--status-warning); border-color: var(--status-warning); }
.pill.crit { color: var(--status-critical); border-color: var(--status-critical); }

/* Inline warnings inside a caption or a paragraph.
 *
 * These had NO rule until 2026-08-14, so the board's "N open orders have no
 * warehouse recorded" — written deliberately as `<span class="warn">` — rendered
 * as ordinary grey body text and read as a footnote rather than as a warning.
 * A caveat nobody's eye lands on is the same as no caveat. */
span.warn { color: var(--status-warning); font-weight: 600; }
span.crit { color: var(--status-critical); font-weight: 600; }
.pill.good { color: var(--status-good); border-color: var(--status-good); }

.caveats { font-size: 12.5px; color: var(--text-secondary); }
.caveats dt { font-weight: 620; color: var(--text-primary); margin-top: 10px; }
.caveats dd { margin: 2px 0 0; }

.muted { color: var(--text-muted); }
.err { color: var(--status-critical); }
#gate { padding: 64px 24px; text-align: center; }

details > summary { cursor: pointer; font-size: 13px; color: var(--text-secondary); padding: 4px 0; }

@media print {
  .topbar { position: static; }
  .tabs { display: none; }
  body { background: #fff; }
  .card, .tile { break-inside: avoid; }
}

/* A funnel sub-row: the same geometry, dimmer and inset, so it reads as part of
   the row above rather than as another state of its own. */
.funnel-row.child .name {
  font-size: 12px;
  color: var(--text-muted);
  padding-left: 14px;
}
.funnel-row.child .funnel-bar-track { height: 16px; }
.funnel-row.child .num { font-size: 12px; color: var(--text-secondary); }
.funnel-note {
  font-size: 12px;
  margin: 6px 0 10px 144px;
  max-width: 70ch;
}
@media (max-width: 720px) { .funnel-note { margin-left: 0; } }

/* --- press pipeline (Printing tab) ------------------------------------- */

/* The swatch was only ever styled inside a tile/legend/tooltip; the press
   tables put one in a cell to tie a stage back to its funnel colour. */
td .swatch {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 2px;
  margin-right: 6px;
  vertical-align: baseline;
}

/* Press stage names are sentences ("RIPped, waiting for a printer"), not the
   one-word Logiwa statuses the base funnel was sized for. */
.funnel.press .funnel-row { grid-template-columns: 232px 1fr 108px; }

@media (max-width: 720px) {
  .funnel.press .funnel-row { grid-template-columns: 130px 1fr 92px; }
}

/* A share bar sitting inside a table row, where the 26px funnel bar is far too
   tall to keep rows scannable. */
.bar-mini {
  background: var(--surface-2);
  border-radius: 3px;
  height: 10px;
  min-width: 90px;
  overflow: hidden;
}
.bar-mini > div { height: 100%; border-radius: 0 3px 3px 0; min-width: 2px; }

/* --- day-by-day history (Order volume tab) ------------------------------ */

.table-scroll.tall { max-height: 620px; }

/* The column headers stick, not the week rows: a long history scrolls far enough
   that unlabelled number columns are useless, and only one of the two can own
   top:0 without the other floating over a header that has scrolled away. */
.table-scroll.tall thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--surface-1);
}

/* Week subtotals sit inside the same scroll area as their days, so they need to
   read as structure rather than as another day. */
tr.week-row > * {
  background: var(--surface-2);
  font-weight: 600;
  font-size: 12px;
  letter-spacing: 0.02em;
  color: var(--text-secondary);
}
tr.week-row th { text-align: left; }

/* Narrower than the printer-roster bar: here it only has to show relative shape
   under a date, not carry a percentage. */
#daily-table .bar-mini { min-width: 0; height: 5px; }

/* The BSN channel split sits INSIDE Ordered, so it is drawn as a bracketed
   sub-group rather than as more series: secondary text weight, and rules down
   both sides so the eye reads "of which" instead of adding the columns up. */
#daily-table .split { color: var(--text-secondary); }
#daily-table thead th.split { font-weight: 500; }
#daily-table .split-first { border-left: 1px solid var(--border); }
#daily-table .split-last { border-right: 1px solid var(--border); }

tfoot th, tfoot td {
  border-top: 2px solid var(--border);
  font-weight: 600;
  background: var(--surface-1);
}
tfoot th { text-align: left; }

/* --- Pace tab ------------------------------------------------------------
   The only screen here that gives an instruction rather than a number, so it
   leads with a verdict banner instead of a tile row. Colours come from the
   status tokens, never from literals, so the light/dark swap stays in one
   place. */

.verdict {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-1);
  padding: 16px 18px;
  margin-bottom: 22px;
}
.verdict .v-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  flex: none;
  margin-top: 5px;
  background: var(--text-muted);
}
.verdict .v-title { font-size: 19px; font-weight: 640; letter-spacing: -0.02em; }
.verdict .v-sub { color: var(--text-secondary); font-size: 13.5px; margin-top: 4px; max-width: 88ch; }
.verdict.crit { border-color: var(--status-critical); }
.verdict.crit .v-title { color: var(--status-critical); }
.verdict.crit .v-dot { background: var(--status-critical); }
.verdict.good { border-color: var(--status-good); }
.verdict.good .v-title { color: var(--status-good); }
.verdict.good .v-dot { background: var(--status-good); }

/* --- Replacement alert (Current overview) --------------------------------
   Reuses the Pace verdict banner so an alert looks like an alert everywhere in
   the app. It exists only while something is waiting; nothing here styles an
   empty state because there is none. */
.rpl { margin-bottom: 28px; }
.rpl .verdict { margin-bottom: 12px; }
.rpl-table td { vertical-align: top; }
.rpl-table td.item { line-height: 1.35; }
.rpl-garment { font-weight: 600; }
.rpl-garment-sub { font-size: 12px; margin-top: 2px; }
.rpl-reason {
  display: inline-block;
  padding: 1px 8px;
  border-radius: 999px;
  font-size: 11.5px;
  border: 1px solid var(--status-critical);
  color: var(--status-critical);
  white-space: nowrap;
}
.rpl-unavailable { margin-bottom: 18px; }
/* The TV strip is markup that is always rendered and only ever SHOWN under
   [data-tv]; the full table is the reverse. One renderer, two readers. */
.rpl-tv { display: none; }

/* Shipped / projected / short, as one bar. The projected segment is hatched
   because it is an estimate sitting next to two measured quantities, and a flat
   fill would read as fact. */
.p-bar {
  display: flex;
  height: 30px;
  border-radius: 6px;
  overflow: hidden;
  background: var(--surface-2);
  border: 1px solid var(--border);
}
.p-seg { height: 100%; transition: width 0.25s ease; }
.p-seg.done { background: var(--status-good); }
.p-seg.proj {
  background: repeating-linear-gradient(
    135deg,
    var(--series-4) 0 7px,
    color-mix(in srgb, var(--series-4) 55%, transparent) 7px 14px
  );
}
.p-seg.short { background: var(--status-critical); }

.p-key { margin-top: 14px; display: flex; flex-direction: column; gap: 7px; }
.p-key-row { display: flex; align-items: center; gap: 9px; font-size: 13px; }
.p-key-row .k { color: var(--text-secondary); }
.p-key-row .v { margin-left: auto; font-variant-numeric: tabular-nums; font-weight: 600; }
.p-key-row .v.bad { color: var(--status-critical); }
.p-key-row.total { border-top: 1px solid var(--border); padding-top: 8px; margin-top: 2px; }
.p-key .sw { width: 10px; height: 10px; border-radius: 2px; flex: none; }
.p-key .sw.done { background: var(--status-good); }
.p-key .sw.proj { background: var(--series-4); }
.p-key .sw.short { background: var(--status-critical); }
.p-note { font-size: 12.5px; margin: 12px 0 0; }

/* One recommendation. The rule that fired is worth as much as its headline, so
   the detail is always shown rather than hidden behind a disclosure. */
.rec { padding: 12px 0; border-top: 1px solid var(--border); }
.rec:first-child { border-top: 0; padding-top: 0; }
.rec-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.rec-title { font-weight: 620; font-size: 14px; }
.rec-detail { color: var(--text-secondary); font-size: 13px; margin-top: 5px; max-width: 80ch; }
.rec.critical .rec-title { color: var(--status-critical); }
.rec.warn .rec-title { color: var(--status-warning); }
.rec.ok .rec-title { color: var(--status-good); }

/* Stage names here are sentences, like the press funnel. */
.funnel.pace-stages .funnel-row { grid-template-columns: 236px 1fr 108px; }
@media (max-width: 720px) {
  .funnel.pace-stages .funnel-row { grid-template-columns: 130px 1fr 92px; }
}

.ot-hero {
  font-size: 42px;
  font-weight: 640;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  color: var(--status-good);
}
.ot-hero.bad { color: var(--status-critical); }
.ot-hero small { font-size: 14px; font-weight: 500; color: var(--text-muted); margin-left: 8px; letter-spacing: 0; }

/* --- drill-down ----------------------------------------------------------
   Every figure on the Pace tab that is a set of orders opens the drawer below.
   The affordance is deliberately quiet — a dotted underline and a pointer, not a
   link colour — because on a page where most numbers are clickable, styling them
   all as links makes the page unreadable. */

.drillable { cursor: pointer; }
.tile.drillable:hover { border-color: var(--border-strong); background: var(--surface-2); }
.tile.drillable:hover .value { text-decoration: underline dotted; text-underline-offset: 3px; }
.p-key-row.drillable:hover .k { color: var(--text-primary); }
.p-key-row.drillable:hover .v { text-decoration: underline dotted; text-underline-offset: 3px; }
.funnel-row.drillable:hover .name { color: var(--text-primary); }
.funnel-row.drillable:hover .funnel-bar { filter: brightness(1.12); }
[data-drill]:focus-visible { outline: 2px solid var(--series-1); outline-offset: 2px; border-radius: 4px; }

/* An explicit "show these orders" affordance, used where a number sits inside a
   sentence and cannot carry the hover treatment on its own. */
.drill-link {
  cursor: pointer;
  color: var(--series-1);
  border-bottom: 1px dotted currentColor;
  white-space: nowrap;
}
.drill-link:hover { color: var(--text-primary); }

/* The drawer. A right-hand sheet rather than a centred modal: the numbers it
   explains stay on screen next to it on a wide display, which is how you check a
   list against the figure that produced it. */
.drill { position: fixed; inset: 0; z-index: 40; }
.drill-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.34); }
.drill-panel {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: min(980px, 96vw);
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 16px 20px 20px;
  background: var(--surface-1);
  border-left: 1px solid var(--border-strong);
  box-shadow: -12px 0 40px rgba(0, 0, 0, 0.22);
  overflow: hidden;
}
.drill-head { display: flex; align-items: flex-start; gap: 16px; }
.drill-headings { min-width: 0; }
.drill-head .controls { margin-left: auto; flex: none; }
.drill-title { font-size: 18px; font-weight: 640; letter-spacing: -0.02em; }
.drill-meta { font-size: 13px; color: var(--text-secondary); margin-top: 2px; font-variant-numeric: tabular-nums; }
.drill-note { margin: 0; max-width: 92ch; }

/* Channel / status mix, so a list of 340 rows still answers "made up of what". */
.mix { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; font-size: 12px; }
.mix-t { color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; font-size: 11px; }

/* The table scrolls inside the drawer; the header row stays put. */
.drill-body { flex: 1; min-height: 0; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius); }
.drill-body table { font-size: 12.5px; }
/* Cells never wrap: on a narrow window the table scrolls inside the drawer, which
   keeps an order code and its cart on one line where they are scannable. */
.drill-body th, .drill-body td { white-space: nowrap; }
.drill-body thead th { position: sticky; top: 0; z-index: 1; background: var(--surface-1); }
.drill-body th.sortable { cursor: pointer; white-space: nowrap; }
.drill-body th.sortable:hover { color: var(--text-primary); }
.drill-body .bad { color: var(--status-critical); }

/* Outbound links to Logiwa / the POD Companion. Underlined on hover only: in a
   table where two of ten columns are links, permanent link colour on every row
   reads as noise. */
a.ext { color: var(--series-1); text-decoration: none; }
a.ext:hover { text-decoration: underline; }
.drill-links { margin: 0; }
.drill-body p { padding: 14px 16px; }

/* Stop the page behind the drawer from scrolling under it. */
body.drill-open { overflow: hidden; }

@media print { .drill { display: none; } }

/* ---------------------------------------------------------------------------
   Catch-up board — the screen on the warehouse wall
   ---------------------------------------------------------------------------
   Two audiences, one markup. In the app it is an ordinary tab; at ?tv=1 it is a
   wall display, which mostly means "bigger and with the chrome gone". Everything
   TV-specific hangs off [data-tv] on <html> so there is no second stylesheet and
   no second page to keep in step. */

.board-head { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 4px; }
.board-head h2 { margin-bottom: 2px; }
.board-head-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; flex: none; }

a.btn {
  display: inline-block;
  padding: 5px 11px;
  border: 1px solid var(--border-strong);
  border-radius: 7px;
  background: var(--surface-1);
  color: var(--text-secondary);
  font-size: 13px;
  text-decoration: none;
}
a.btn:hover { color: var(--text-primary); background: var(--surface-2); }

.board-tiles { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
.board-tile .value { font-size: 42px; }
.board-tile.good .value { color: var(--status-good); }
.board-tile.warn .value { color: var(--status-warning); }
.board-tile.crit .value { color: var(--status-critical); }

.board-table td.strong { font-weight: 640; }
.board-table td.good { color: var(--status-good); }
.board-table td.warn { color: var(--status-warning); }
.board-table td.crit { color: var(--status-critical); }
.board-table td.mono { font-family: var(--mono); font-size: 12.5px; }

/* The blocked list's two wide columns: what the order needs, and where it is.
   The SKU sits under the product name because the name is what a person
   recognises and the SKU is what they type into a scanner. */
.blocked-table td.item { line-height: 1.35; }
/* Clipped with an ellipsis rather than wrapped: a two-line item name doubles the
   row height, and the blocked list is the panel a wall screen can least afford to
   lose rows from. A cut-off name still identifies the garment. */
.item-name,
.item-sku {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.item-sku {
  font-family: var(--mono);
  font-size: 11.5px;
  color: var(--text-muted);
}
/* The pull list. One <details> per location, so the page opens as a short list
   of trips and expands into the items and the orders behind them. The summary
   row carries the number that decides whether the trip is worth making, right
   aligned, because that is the column somebody scans down. */
.pull-group { border-bottom: 1px solid var(--border); }
.pull-group:last-child { border-bottom: 0; }
.pull-group > summary {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 9px 2px;
  cursor: pointer;
  list-style: revert;
}
.pull-group > summary::-webkit-details-marker { color: var(--text-muted); }
.pull-loc { font-family: var(--mono); font-size: 13.5px; font-weight: 640; }
.pull-where { color: var(--text-muted); font-size: 12px; }
.pull-count { margin-left: auto; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.pull-table { margin: 0 0 12px; }
.pull-table td.item { line-height: 1.35; }
/* The order refs wrap: a location holding fifteen orders is the good case, and
   clipping them to one line is exactly the fact this panel exists to show. */
.pull-refs { white-space: normal; }
.pull-refs .loc { margin: 0 6px 3px 0; }

.where { color: var(--text-secondary); }
.where .good { color: var(--status-good); }
.where .warn { color: var(--status-warning); }
.where .crit { color: var(--status-critical); }
/* A location code and its quantity, as one unbreakable chip — "88-STN-09-C" and
   "8" on separate lines is two facts nobody can rejoin from across a room. */
.loc {
  display: inline-block;
  white-space: nowrap;
  margin-left: 8px;
  font-family: var(--mono);
  font-size: 11.5px;
  color: var(--text-muted);
}
.loc b { margin-left: 4px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }

/* The crew column's view switch. A real control in the app; on TV the same
   markup becomes a read-only indicator of which of the three is showing. */
.sec-head { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
.view-switch { display: flex; gap: 6px; }
.board-client-pick { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-muted); }
.board-client-pick select { font-size: 13px; padding: 4px 8px; }
.view-switch button {
  padding: 3px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: transparent;
  color: var(--text-muted);
  font-size: 12px;
  cursor: pointer;
}
.view-switch button[aria-pressed='true'] {
  border-color: var(--border-strong);
  background: var(--surface-2);
  color: var(--text-primary);
}

/* Packer chips. `display: inline` on the wrapper would let a name and its count
   split across a line break, which at TV size reads as two people. */
.people { line-height: 1.9; }
.person {
  display: inline-block;
  white-space: nowrap;
  margin-right: 10px;
  color: var(--text-secondary);
}
.person b { color: var(--text-primary); font-variant-numeric: tabular-nums; }

/* --- TV mode -------------------------------------------------------------
   A wall screen is landscape and CANNOT BE SCROLLED. Those two facts drive the
   whole layout: the app's single tall column put everything below the burndown
   off the bottom of a 16:9 screen, so on TV the board becomes a fixed 100vh
   grid that spends the width it actually has. Nothing here changes the app
   view — the same markup stacks normally without [data-tv]. */
[data-tv] .topbar,
[data-tv] .board-hide-on-tv,
[data-tv] .board-head-actions,
[data-tv] #error:empty { display: none; }

/* The board is the only panel a TV ever shows; nothing navigates it, so the
   other five stay hidden regardless of the hash. */
[data-tv] .panel:not([data-panel='board']) { display: none; }

/* The app centres content in 1500px, which on a 1920-wide screen throws away a
   fifth of the picture and is exactly the space this layout needs. */
[data-tv] body { overflow: hidden; }
[data-tv] main { padding: 0; max-width: none; }
[data-tv] { font-size: 20px; }

[data-tv] .panel.board {
  display: grid;
  /* The left column carries the two things the floor watches minute to minute
     (the line, and who is on the bench); the right carries the two reference
     tables. Both rows are minmax(0, …) so their children may shrink — without
     the 0 minimum a grid track floors at its content height and the page grows
     a scrollbar, which is the one thing a TV cannot use. */
  /* TWELVE columns, not two, so the split can differ per row — the top row wants
     a wide chart and a narrow brand table, the bottom row wants the opposite,
     because the blocked list carries item names and stock locations and a
     two-column grid gave it the same ~640px the brand table needed. Every track
     is `minmax(0, …)` for the same reason the rows are: a track's automatic
     minimum is its content, and a table of `nowrap` cells has a wide min-content,
     so plain `1fr` columns pushed the whole right-hand column off a 1366px screen
     — and the page did not scroll (the panel hides overflow), it just quietly cut
     two columns off every row, which reads as a layout that fits. */
  grid-template-columns: repeat(12, minmax(0, 1fr));
  grid-template-rows: auto auto minmax(0, 1fr) minmax(0, 1.15fr);
  grid-template-areas:
    'head  head  head  head  head  head    head    head    head    head    head    head'
    'tiles tiles tiles tiles tiles tiles   tiles   tiles   tiles   tiles   tiles   tiles'
    'chart chart chart chart chart chart    chart   brands  brands  brands  brands  brands'
    'hours hours hours hours hours blocked blocked blocked blocked blocked blocked blocked';
  gap: 12px 16px;
  height: 100vh;
  padding: 14px 20px;
  box-sizing: border-box;
  overflow: hidden;
}

/* Replacements waiting to be picked TAKE THE BOTTOM-RIGHT CELL, and the blocked
   list stands down for as long as they are there (Lee Tepper, 2026-09-18:
   "can replace bottom right tile with above", over a photo of the wall screen
   with an arrow drawn from the blocked panel up to the replacement strip).
   Whenever nothing is waiting, `has-rpl` is off and the cell is the blocked
   list again, exactly as before — so no row of this grid ever holds an empty
   element, which is what the strip's own `auto` row had to work around.

   This is a straight swap and NOT a fifth panel. The wall's 2x2 is sized to the
   cell and an extra area starves the other four (it is the same reason the
   "Bring this down" list is deliberately not on the TV at all), and it is why
   the strip existed in the first place. The trade it makes is deliberate: a
   replacement is a stock request somebody walks to a shelf for in the next few
   minutes, while the blocked rows on the screen Lee photographed had been stuck
   since Jul 16, Aug 19 and Aug 23 — reference, not this hour's work. The
   blocked list is one hash away in the app for anyone who wants it. */
[data-tv] #replacements { display: none; }
[data-tv] .panel.board.has-rpl #replacements {
  /* A flex column, not a block: the grid stretches THIS element to the cell, and
     a block parent would then hand `.rpl-tv` only its content height — leaving
     the red box floating above a strip of empty cell. */
  display: flex;
  flex-direction: column;
  grid-area: blocked;
  min-height: 0;
}
/* (0,4,0) — it has to out-specify `[data-tv] #board-sec-blocked { grid-area }`
   AND `[data-tv] .panel.board section { display: flex }`, which is the trap the
   caveats block fell into (see CLAUDE.md). Without the extra weight the blocked
   list stays flex, keeps its grid-area, and the two panels stack in one cell. */
[data-tv] .panel.board.has-rpl section#board-sec-blocked { display: none; }
/* (0,3,0): same trap, for the desk-only full table. */
[data-tv] .panel.board section.rpl-full { display: none; }
/* In the cell it is a panel like its three neighbours — a flex column whose card
   takes the rest of the height and clips — rather than the auto-height banner it
   was in the strip. `min-height: 0` on both is what lets the card clip instead of
   pushing the grid taller; a 100% height would not resolve against a flex parent
   (see CLAUDE.md). The critical-red border stays: it is the one thing on this
   screen that is somebody's next five minutes. */
[data-tv] .rpl-tv {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  border: 2px solid var(--status-critical);
  border-radius: 10px;
  padding: 8px 16px 10px;
  background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-1));
}
[data-tv] .rpl-tv > .card {
  flex: 1;
  min-height: 0;
  overflow: hidden;
  background: none;
  border: 0;
  padding: 0;
  margin-top: 4px;
  /* A COLUMN, not a block — the same rule the other three panels' cards carry,
     and for the same reason. A block parent gives `#rpl-tv-rows` its content
     height however small the cell is, so clientHeight equals scrollHeight,
     `fitTableToCard()` sees nothing to trim, and the list simply runs off the
     bottom of a 768px screen. That is precisely what it did before this line. */
  display: flex;
  flex-direction: column;
}
[data-tv] #rpl-tv-rows { flex: 1; min-height: 0; overflow: hidden; }
[data-tv] .rpl-tv-title {
  display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;
  color: var(--status-critical); font-size: 26px; font-weight: 700; letter-spacing: -0.02em;
}
[data-tv] .rpl-tv-title .v-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--status-critical); align-self: center; }
[data-tv] .rpl-tv-sub { color: var(--text-secondary); font-size: 16px; font-weight: 500; }
[data-tv] .rpl-tv-code { color: var(--text-secondary); }
/* The cell is the widest thing on the board after the chart, so the garment name
   gets the slack and every other column stays on one line. */
[data-tv] .rpl-tv-table { width: 100%; }
/* Tighter than the other board tables. Six `nowrap` columns have a min-content
   width this cell cannot always give them — at 1366x768 the table measured 791px
   against a 731px wrapper and the request code was clipped by `overflow: hidden`,
   which is the exact failure mode CLAUDE.md warns about: a silent horizontal cut
   reads as a layout that fits. The padding is the slack; the type stays big,
   because being readable across a room is the whole point of the panel. */
[data-tv] .rpl-tv-table th,
[data-tv] .panel.board .rpl-tv-table td { padding: 5px 6px; }
[data-tv] .rpl-tv-table td.item { white-space: normal; }
[data-tv] .rpl-tv-table .item-name { font-weight: 600; }

[data-tv] .board-head { grid-area: head; margin: 0; align-items: baseline; }
[data-tv] .board-tiles { grid-area: tiles; grid-template-columns: repeat(5, 1fr); }
[data-tv] #board-sec-chart { grid-area: chart; }
[data-tv] #board-sec-hours { grid-area: hours; }

/* The wrapper vanishes as a box so its two children are placed by the grid
   directly. It exists only to keep the app view's stacking order; on TV the
   blocked list has to be a cell of its own because it now carries item names and
   stock locations and needs the WIDTH — sharing a narrow right-hand column with
   the brand table (which is what it did until 2026-08-14) left it eight
   characters per column. */
[data-tv] #board-side { display: contents; }
[data-tv] #board-sec-brands { grid-area: brands; }
[data-tv] #board-sec-blocked { grid-area: blocked; }

/* Each panel is a column: heading, then a card that takes the rest of the cell.
   min-height: 0 on both is what lets the card actually clip instead of pushing
   the grid taller. */
/* Descendant, not child: the brands/blocked pair now sits inside #board-side. */
[data-tv] .panel.board section {
  margin: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
/* MUST out-specify the rule above, which is (0,3,0) against the plain
   `.board-hide-on-tv` hide rule's (0,2,0). Without this the caveats block
   reappears as a flex child, takes an implicit extra grid row, and starves the
   real rows to ~1px each — the layout looked completely broken while every
   individual rule was correct. */
[data-tv] .panel.board section.board-hide-on-tv { display: none; }
[data-tv] .panel.board section > .card {
  flex: 1;
  min-height: 0;
  overflow: hidden;
  padding: 10px 14px;
  /* A column, so the table wrapper inside is a flex item with a RESOLVED
     height. It used to be `height: 100%`, which does not resolve against a
     parent whose own height came from `flex: 1` — so clientHeight reported the
     wrapper's CONTENT height and fitTableToCard trimmed every list to one row
     on a 768px-tall screen while leaving obvious empty space beneath it. */
  display: flex;
  flex-direction: column;
}

/* The explanatory paragraphs under each heading are for someone reading at a
   desk. From across a room they are unreadable anyway and they cost four rows
   of table, so on TV only the freshness line survives. */
[data-tv] .panel.board section > .sub { display: none; }

/* The header and the tiles are `auto` rows, so every pixel they take comes
   straight out of the four lists below. Title and freshness share one line. */
[data-tv] .board-head { gap: 18px; flex-wrap: wrap; }
[data-tv] .board-head > div:first-child { display: flex; align-items: baseline; gap: 18px; }
[data-tv] .board-head h2 { font-size: 28px; margin: 0; }
[data-tv] .board-head .sub { font-size: 16px; margin: 0; max-width: none; }
[data-tv] h2 { font-size: 22px; margin-bottom: 5px; }
[data-tv] .board-tile { padding: 6px 14px; }
[data-tv] .board-tile .label { font-size: 16px; }
[data-tv] .board-tile .value { font-size: 56px; margin-top: 0; line-height: 1.1; }
[data-tv] .board-tile .foot { font-size: 15px; }
[data-tv] table { font-size: 19px; }
[data-tv] th { font-size: 14px; }
[data-tv] th, [data-tv] .panel.board td { padding: 5px 10px; }
[data-tv] .board-table td.mono { font-size: 18px; }
[data-tv] .legend { font-size: 17px; margin: 4px 0 0; }

/* The chart sizes itself from this box rather than a fixed pixel height, so it
   fills whatever the grid gives it on this particular screen. */
[data-tv] #board-sec-chart > .card { display: flex; flex-direction: column; }
[data-tv] #board-chart-wrap { flex: 1; min-height: 0; }
[data-tv] #board-chart { display: block; }

/* The three table panels clip inside their cell. Scoped by id rather than
   `.card > div`, which would also catch the chart's wrapper and its legend and
   break the flex column above. */
[data-tv] #board-hours,
[data-tv] #board-brands,
[data-tv] #board-blocked { flex: 1; min-height: 0; overflow: hidden; }

[data-tv] .people { line-height: 1.6; }

/* One line per row. On a narrower screen a wrapping brand name or column header
   turned a 30px row into 90px, so three rows filled a cell that could hold eight
   — the tables looked cramped when the real problem was row height. The packer
   column is the deliberate exception: it is a list of names and is meant to
   wrap. Overflow is hidden by the cell's own cell, not by a scrollbar. */
[data-tv] .panel.board th,
[data-tv] .panel.board td { white-space: nowrap; }
[data-tv] .panel.board td.people { white-space: normal; }
[data-tv] #board-brands td:first-child,
[data-tv] #board-blocked td:first-child,
[data-tv] #board-blocked td:nth-child(2) {
  max-width: 24ch;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The blocked table divides the width it is GIVEN rather than asking for what it
   wants: `table-layout: fixed` plus these column widths, so on a narrow screen
   the cells clip instead of the table forcing its column wider than the screen.
   The item and location columns get the lion's share because they are the reason
   the card exists; order, brand and date are lookups. */
[data-tv] #board-blocked table { table-layout: fixed; width: 100%; }
[data-tv] #board-blocked col.c-order { width: 14%; }
[data-tv] #board-blocked col.c-brand { width: 17%; }
[data-tv] #board-blocked col.c-item { width: 27%; }
[data-tv] #board-blocked col.c-where { width: 31%; }
[data-tv] #board-blocked col.c-since { width: 11%; }
[data-tv] #board-blocked td { overflow: hidden; text-overflow: ellipsis; }

/* On a narrower panel the blocked card loses its two least actionable columns
   rather than ellipsising all five. At 1366 wide the cell is ~550px and five
   columns left "7SQM…", "Outdo…" and "1 in storage …" — the location, which is the
   only reason this card exists, was the first thing cut. Brand is recoverable
   from the order prefix (#OV, #MR, #TG) and the date is the least useful fact on
   a wall, so those two go and item/location get the room. */
@media (max-width: 1400px) {
  [data-tv] #board-blocked col.c-order { width: 17%; }
  [data-tv] #board-blocked col.c-item { width: 40%; }
  [data-tv] #board-blocked col.c-where { width: 43%; }
  [data-tv] #board-blocked col.c-brand,
  [data-tv] #board-blocked col.c-since,
  [data-tv] #board-blocked th:nth-child(2),
  [data-tv] #board-blocked td:nth-child(2),
  [data-tv] #board-blocked th:nth-child(5),
  [data-tv] #board-blocked td:nth-child(5) { display: none; }
}
/* The brand table divides the width it is GIVEN, for the same reason the blocked
   one does. It gained a sixth figure column on 2026-09-08 — "All" = target +
   blocked, the warehouse manager's leading number — and immediately stopped
   fitting: at 1366 wide the cell is ~513px, the table wanted 608, and BLOCKED
   went off the right edge with no scrollbar and nothing to say it had gone.
   Ellipsis on the cells rather than a wider table, so a long brand name loses
   its tail instead of a whole column disappearing. */
[data-tv] #board-brands table { table-layout: fixed; width: 100%; }
[data-tv] #board-brands th,
[data-tv] #board-brands td { overflow: hidden; text-overflow: ellipsis; }
/* Seventh figure column on 2026-09-25: Remaining became PICK/PACK + AT PRESS
   (Lee — the press queue was reading as floor work). % DONE moved to 7th.
   EIGHTH on 2026-09-28: PICK/PACK itself split into TO PICK and READY TO PACK
   either side of AT PRESS (Astrid Flores, feedback widget), so the row now reads
   left to right in the order the work physically moves — pick, press, pack —
   and % DONE is 8th with BLOCKED 9th.
   Headings WRAP rather than ellipsise — "PICK/PA…" across a room is a guess, and
   the card has spare height under its rows but no spare width. */
[data-tv] #board-brands th { white-space: normal; vertical-align: bottom; line-height: 1.15; }
/* These indices assume the NINE-column header renderBoardBrands draws when the
   payload carries `floor.leftPress`. The two-column fallback (an older Worker
   behind a newer page) shifts everything right of At press by one and loses a
   little polish on the wall; it is not a state a deploy leaves behind. */
/* TARGET is hidden on the wall at EVERY width now, not only below 1400. Nine
   columns do not fit the brand cell even at 1920: measured 2026-09-28, the row
   rendered "2,6…", "2,5…", "1,1…" and a heading of "AT PRE…". An ellipsised
   FIGURE is the worst outcome available here — a hidden column is honestly
   absent, a truncated number reads as a number. Target is the safe one to lose
   because it is All minus Blocked and both are on the row; that was already the
   argument for dropping it below 1400. Eight columns on the wall, which is what
   the table carried before this change. */
[data-tv] #board-brands th:nth-child(3),
[data-tv] #board-brands td:nth-child(3) { display: none; }
/* SHIPPED and BLOCKED are the two single-word headings that cannot wrap, so they
   need the width their own text asks for or they ellipsise — 11% rendered
   "SHIPP…" at 1920. TO PICK, AT PRESS, READY TO PACK and % DONE all break over
   two lines and can be narrower. */
[data-tv] #board-brands th:first-child { width: 21%; }
[data-tv] #board-brands th:nth-child(2) { width: 10%; }
[data-tv] #board-brands th:nth-child(4) { width: 13%; }
[data-tv] #board-brands th:nth-child(5) { width: 10%; }
[data-tv] #board-brands th:nth-child(6) { width: 10%; }
[data-tv] #board-brands th:nth-child(7) { width: 12%; }
[data-tv] #board-brands th:nth-child(8) { width: 10%; }
[data-tv] #board-brands th:nth-child(9) { width: 13%; }

/* Below 1400 it drops % DONE rather than squeezing seven figures into 500px. That
   column is the only one on the row DERIVED from two others sitting right beside
   it — shipped over target — so it is the one whose loss costs no information,
   and dropping it is what buys the room for the ones that carry the day's actual
   size. */
@media (max-width: 1400px) {
  /* ALL goes too, on top of the Target hidden above. That one is a real loss
     rather than a derivation — but the alternative was merging To pick and Ready
     to pack back together on the wall, which is the whole point of the change,
     and "how much is there" is the leading TILE as well as this column. ALL is
     dropped rather than SHIPPED because Shipped is the numerator of the day's
     progress and the burndown beside it is drawn from it. */
  [data-tv] #board-brands th:nth-child(2),
  [data-tv] #board-brands td:nth-child(2),
  [data-tv] #board-brands th:nth-child(8),
  [data-tv] #board-brands td:nth-child(8) { display: none; }
  [data-tv] #board-brands th:first-child { width: 26%; }
  [data-tv] #board-brands th:nth-child(4) { width: 14%; }
  [data-tv] #board-brands th:nth-child(5) { width: 14%; }
  [data-tv] #board-brands th:nth-child(6) { width: 14%; }
  [data-tv] #board-brands th:nth-child(7) { width: 17%; }
  [data-tv] #board-brands th:nth-child(9) { width: 15%; }
  /* Buying the last few pixels from the gutters and the header, rather than from
     a column: an ellipsised HEADING is a worse read across a room than a tight
     one, because the reader has to infer which figure they are looking at. */
  [data-tv] #board-brands th,
  [data-tv] #board-brands td { padding-left: 5px; padding-right: 5px; }
  [data-tv] #board-brands th { font-size: 13px; letter-spacing: 0.02em; }
}

[data-tv] .item-sku { font-size: 14px; }
[data-tv] .loc { font-size: 15px; }

/* On a wall there is no pointer, so the switch stops being a control and becomes
   a label saying which of the three views is up and that two more are coming. */
[data-tv] .sec-head { justify-content: space-between; }
[data-tv] .view-switch { pointer-events: none; }
[data-tv] .board-client-pick { display: none; }
[data-tv] .view-switch button { font-size: 14px; padding: 2px 10px; }

/* Written by fitTableToCard() when a list did not fit its cell. It is the whole
   point of that function — the alternative is a table that stops at the bottom
   edge and reads as though it ended. `flex: none` so it is never itself squeezed
   by the flex column it lives in. */
.fit-note { font-size: 16px; padding: 5px 10px; font-style: italic; flex: none; }

/* Short screens — a 768p panel, or a 1080p one in a portrait-ish window. The
   type that makes a 4K TV readable across a room leaves no room for rows here,
   so step it down rather than trim every list to a stub. */
@media (max-height: 900px) {
  [data-tv] { font-size: 17px; }
  [data-tv] .board-head h2 { font-size: 23px; }
  [data-tv] h2 { font-size: 18px; }
  [data-tv] .board-tile .value { font-size: 42px; }
  [data-tv] .board-tile .label { font-size: 14px; }
  [data-tv] .board-tile .foot { font-size: 13px; }
  [data-tv] table { font-size: 16px; }
  [data-tv] th, [data-tv] .panel.board td { padding: 3px 8px; }
  [data-tv] .fit-note { font-size: 14px; padding: 3px 8px; }
}

/* Crew chips that have a breakdown behind them, and the panel it opens.
   ------------------------------------------------------------------------
   Only chips carrying `work` get the affordance: an hour rolled up before this
   app recorded job type has nothing to show, and a dotted underline promising a
   hover that opens an empty box is worse than a plain name. */
.person.has-work { cursor: help; border-bottom: 1px dotted var(--border-strong); }
.person.has-work:hover,
.person.has-work:focus-visible { color: var(--text-primary); outline: none; }

/* "+N more" is a real control in the app view. Styled as text rather than as a
   button because it sits inline in a list of names and a chrome button there
   reads as a fourth column. */
button.crew-more {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--text-secondary);
  text-decoration: underline dotted;
  text-underline-offset: 3px;
  cursor: pointer;
}
button.crew-more:hover { color: var(--text-primary); }

/* `position: fixed`, so the panel is never clipped by the scrolling table
   wrapper it is anchored inside. `.tooltip` sets `position: absolute`, hence the
   override rather than a shared rule. */
.crew-pop {
  position: fixed;
  display: none;
  max-width: 340px;
  min-width: 220px;
}
.crew-pop .t-client {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin-top: 7px;
  font-weight: 620;
  color: var(--text-primary);
}
.crew-pop .t-client:first-of-type { margin-top: 2px; }
.crew-pop .t-job { padding-left: 10px; }
.crew-pop .t-job .k { white-space: normal; }
.crew-pop .t-foot {
  margin-top: 8px;
  padding-top: 6px;
  border-top: 1px solid var(--border);
  color: var(--text-secondary);
  font-size: 12px;
}

/* Printer signals panel: a silent / down / aborted marker. */
#signals-table .bad, #signals-jobs-table .bad { color: var(--status-critical); font-weight: 600; }
