/* css/page-dashboard.css — page-scoped styles for the Dashboard page
 * (js/pages/dashboard.js). Owned by the Dashboard page agent; add rules here so
 * page work never touches shared main.css / chrome.css / datatable.css.
 * Reuses shared .page-header/.page-title/.page-subtitle (chrome.css),
 * .stats-row/.stat-card/.stat-value/.stat-label (main.css), .btn (main.css). */

/* ── Stat tiles: 4 across on desktop, links to /pipeline ── */
.dash-stat-grid {
  grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 720px) {
  .dash-stat-grid { grid-template-columns: repeat(4, 1fr); }
}
.dash-stat-grid a.stat-card {
  text-decoration: none;
  color: inherit;
  display: block;
  border-top-color: var(--color-primary);
}
.dash-stat-sub {
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--text-muted);
  margin-top: 0.15rem;
  font-variant-numeric: tabular-nums;
}
/* On Hold — paused deals; amber accent, distinct from the primary cards. */
.dash-stat-grid a.stat-card.dash-stat-hold {
  border-top-color: #d97706;
}
.dash-stat-grid a.stat-card.dash-stat-hold .stat-value {
  color: #b45309;
}
.dash-stat-grid a.stat-card.dash-stat-hold .dash-stat-sub {
  color: #b45309;
}
/* Tracked — pre-active pipeline (target/anticipated/incumbent/hold); slate accent. */
.dash-stat-grid a.stat-card.dash-stat-tracked {
  border-top-color: #64748b;
}
.dash-stat-grid a.stat-card.dash-stat-tracked .stat-value {
  color: #475569;
}
.dash-stat-grid a.stat-card.dash-stat-tracked .dash-stat-sub {
  color: #475569;
}

/* ── Deals-by-status strip: one compact cell per lifecycle status ── */
.dash-status-strip {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.5rem 0.85rem;
}
@media (min-width: 560px) { .dash-status-strip { grid-template-columns: repeat(4, 1fr); } }
@media (min-width: 960px) { .dash-status-strip { grid-template-columns: repeat(8, 1fr); } }
.dash-status-cell {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.2rem;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  background: var(--bg-secondary);
  min-width: 0;
}
.dash-status-cell .pd-pill,
.dash-status-cell .dash-chip {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}
.dash-status-count {
  font-size: 1.25rem;
  font-weight: 800;
  color: var(--text-primary);
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}
.dash-status-amt {
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

/* ── Panels (card surfaces for the breakdown + list sections) ── */
.dash-panel {
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  box-shadow: var(--shadow-sm);
  padding: 1rem 1.15rem 1.15rem;
  margin-bottom: 1rem;
}
.dash-panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 0.75rem;
  flex-wrap: wrap;
}
.dash-panel-title {
  font-size: 1rem;
  font-weight: 700;
  color: var(--text-primary);
  margin: 0;
}
.dash-link {
  font-size: 0.8rem;
  color: var(--link-color);
  text-decoration: none;
  font-weight: 600;
  white-space: nowrap;
}
.dash-link:hover { text-decoration: underline; }

/* ── Two-column rows (breakdown tables / list sections) ── */
.dash-cols {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1rem;
}
@media (min-width: 860px) {
  .dash-cols { grid-template-columns: 1fr 1fr; }
}
.dash-cols .dash-panel { margin-bottom: 1rem; }

/* ── Breakdown tables ── */
.dash-table-wrap {
  overflow-x: auto;
  width: 100%;
}
.dash-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.85rem;
}
.dash-table th {
  text-align: left;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
  font-weight: 600;
  padding: 6px 8px;
  border-bottom: 1px solid var(--border-color);
  white-space: nowrap;
}
.dash-table td {
  padding: 7px 8px;
  border-bottom: 1px solid var(--border-color);
  color: var(--text-primary);
  vertical-align: middle;
}
.dash-table tbody tr:last-child td { border-bottom: none; }
.dash-table .dash-r { text-align: right; font-variant-numeric: tabular-nums; }
.dash-table tbody tr:hover { background: var(--bg-secondary); }
/* Totals footer — the TRUE dataset-wide value, not a sum of the rows above, so
   it is set apart rather than reading as one more row.
   STICKY because a breakdown table lives in a fixed-height cell whose body
   scrolls: without this the total is the one row you can never see, which is
   the row people open the widget for. */
.dash-table tfoot td {
  position: sticky;
  bottom: 0;
  z-index: 1;
  padding: 7px 8px;
  border-top: 2px solid var(--border-color);
  font-weight: 700;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
  background: var(--bg-secondary);
}
/* Same reasoning for the header on a scrolled table. */
.dash-table thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--bg-primary);
}
/* The folded remainder row. Muted so it never reads as a real group. */
.dash-table tbody tr.dash-table-other td {
  color: var(--text-muted);
  font-style: italic;
}
/* Productivity table: a personal best set on the latest day of data. Amber,
   matching the old Excel sheet this widget reproduces; hover must not wash the
   flag out, so the hover rule below is overridden too. */
.dash-table tbody tr.dash-row-best td,
.dash-table tbody tr.dash-row-best:hover td {
  background: #fef3c7;
  color: #92400e;
  font-weight: 600;
}
.dash-table tbody tr.dash-row-best:hover { background: #fef3c7; }
.dash-table td:not(:first-child),
.dash-table tfoot td:not(:first-child) { font-variant-numeric: tabular-nums; }

/* Totals footer emphasis */
.dash-group-table tfoot td {
  border-top: 2px solid var(--border-color);
  font-weight: 700;
  color: var(--text-primary);
}
.dash-group-table tbody tr:last-child td { border-bottom: 1px solid var(--border-color); }

/* Stage color chip (mirrors the pills used elsewhere) */
.dash-chip {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: 600;
  white-space: nowrap;
}

/* ── Count badge + sub-header (Overdue / Closing) ── */
.dash-sub {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.6rem;
}
.dash-sub-label {
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text-primary);
}
.dash-badge-count {
  min-width: 26px;
  height: 22px;
  padding: 0 7px;
  border-radius: 11px;
  background: var(--bg-secondary);
  color: var(--text-muted);
  font-size: 0.78rem;
  font-weight: 700;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-variant-numeric: tabular-nums;
}
.dash-badge-count.dash-danger { background: #fee2e2; color: #991b1b; }
.dash-badge-count.dash-accent {
  background: rgba(var(--color-primary-rgb), 0.12);
  color: var(--color-primary);
}
.dash-badge-count.dash-success { background: #d1fae5; color: #059669; }

/* ── List rows (overdue activities / closing deals) ── */
.dash-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 8px 6px;
  border-bottom: 1px solid var(--border-color);
  text-decoration: none;
  color: inherit;
}
.dash-item:last-child { border-bottom: none; }
.dash-item:hover { background: var(--bg-secondary); }
.dash-item-main {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.dash-item-title {
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.dash-item-sub {
  font-size: 0.75rem;
  color: var(--text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.dash-item-meta {
  flex-shrink: 0;
  text-align: right;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.dash-item-top {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
.dash-item-top.dash-overdue-date { color: #991b1b; }
.dash-item-top.dash-item-money { color: var(--color-primary); }
.dash-item-bottom {
  font-size: 0.72rem;
  color: var(--text-muted);
}

/* ── Empty states ── */
.dash-empty {
  color: var(--text-muted);
  font-size: 0.85rem;
  font-style: italic;
  padding: 0.75rem 0.25rem;
}
.dash-emptystate {
  text-align: center;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  box-shadow: var(--shadow-sm);
  padding: 3rem 1.5rem;
}
.dash-emptystate-title {
  font-size: 1.15rem;
  font-weight: 700;
  color: var(--text-primary);
  margin-bottom: 0.5rem;
}
.dash-emptystate p {
  color: var(--text-muted);
  margin: 0 0 1.25rem;
}

/* ── Tab strip: Overview | custom dashboards | + New Dashboard ── */
.dash-tabs {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  flex-wrap: wrap;
  border-bottom: 1px solid var(--border-color);
  margin-bottom: 1rem;
}
.dash-tab {
  display: inline-block;
  padding: 0.45rem 0.7rem;
  font-size: 0.85rem;
  font-weight: 600;
  font-family: inherit;
  color: var(--text-muted);
  text-decoration: none;
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  cursor: pointer;
  white-space: nowrap;
  max-width: 220px;
  overflow: hidden;
  text-overflow: ellipsis;
}
.dash-tab:hover { color: var(--text-primary); }
.dash-tab.active {
  color: #4f46e5;
  border-bottom-color: #4f46e5;
}
.dash-tab-new { color: #4f46e5; }
.dash-tab-new:hover { color: #4338ca; }

/* ── Drag-to-reorder the custom tabs (js/dnd-sortable.js) ──────────────
   Only tabs carrying data-dash-id are draggable — Overview and
   "+ New Dashboard" are plain controls and keep the pointer cursor. The
   engine owns geometry (it writes position/size on .dnd-indicator inline);
   these rules supply paint + affordance only, and must never transition the
   dragged node. .dash-tabs-draggable is set by wireTabsDnd() ONLY once the
   engine is live, so a read_only user is never offered a grab cursor for a
   gesture that does nothing. */
.dash-tabs-draggable .dash-tab[data-dash-id] { cursor: grab; }
.dash-tabs-draggable .dash-tab[data-dash-id]:active { cursor: grabbing; }
.dash-tab.dnd-dragging {
  opacity: 0.4;
  transition: none !important;
}
/* Vertical insertion line: in a horizontal strip the engine's 'grid' axis
   picks the x-midpoints and hands us a 2px-wide, tab-tall rect. min-width /
   min-height keep it visible in the between-rows case too (the strip wraps). */
.dnd-indicator {
  z-index: 60;
  min-width: 2px;
  min-height: 2px;
  border-radius: 2px;
  background: var(--color-primary);
  box-shadow: 0 0 0 1px rgba(var(--color-primary-rgb), 0.25);
  pointer-events: none;
}

/* ── Custom-dashboard header (name + owner-only actions) ── */
.dash-page-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}
.dash-head-actions {
  display: flex;
  gap: 0.5rem;
  flex-shrink: 0;
}

/* ── Dashboard filter bar (owner / business line) ──────────────────────
   One compact row under the page header on the Dashboard page, and the SAME
   row above the canvas in the builder (pages/dashboard-builder.js reuses these
   classes; only its placement wrapper lives in page-dashboard-builder.css).
   Rendered for EVERYONE — a filtered dashboard that looks unfiltered is a trap
   — but only owner/admin get the selects; .dash-filterbar-static is the
   viewer's read-only variant, where each value is plain text. */
.dash-filterbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem 0.9rem;
  margin: 0 0 1rem;
  padding: 0.45rem 0.7rem;
  border: 1px solid var(--border-color);
  border-radius: var(--radius-md);
  background: var(--bg-secondary);
  font-size: 0.8rem;
}
/* An ACTIVE slice is the one state a viewer must not miss. */
.dash-filterbar-active {
  border-color: rgba(79, 70, 229, 0.45);
  background: rgba(79, 70, 229, 0.06);
}
.dash-filter-field {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
  margin: 0;
}
.dash-filter-label {
  color: var(--text-muted);
  font-weight: 600;
  white-space: nowrap;
}
/* Not .form-select: this is a compact inline control, not a form row. */
.dash-filter-select {
  max-width: 15rem;
  padding: 0.2rem 0.4rem;
  border: 1px solid var(--border-color);
  border-radius: var(--radius-sm, 4px);
  background: var(--bg-primary, #fff);
  color: var(--text-primary);
  font-family: inherit;
  font-size: 0.8rem;
}
.dash-filter-select:focus {
  outline: none;
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
}
/* The viewer's static value — same line, no control. */
.dash-filter-value {
  color: var(--text-primary);
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 15rem;
}
.dash-filter-clear {
  margin-left: auto;
  padding: 0.2rem 0.5rem;
  border: 1px solid var(--border-color);
  border-radius: var(--radius-sm, 4px);
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-size: 0.75rem;
  font-weight: 600;
  cursor: pointer;
}
.dash-filter-clear:hover {
  color: var(--text-primary);
  background: var(--bg-primary, #fff);
}

/* ── "Use dashboard filters" (widget inspector + the view-mode drawer) ──
   The widget's own owner/business-line fields stay VISIBLE while the box is
   ticked — disabled, not hidden — so it is obvious what is driving them. */
.dash-inherit-row {
  margin-top: 0.9rem;
  padding-top: 0.7rem;
  border-top: 1px solid var(--border-color);
}
.dash-inherit-label {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  font-weight: 600;
  color: var(--text-primary);
  cursor: pointer;
}
.dash-inherit-label input { margin: 0; }
.dash-inherit-hint {
  margin-top: 0.25rem;
  color: var(--text-muted);
  font-size: 0.75rem;
  line-height: 1.35;
}
.dash-field-inherited { opacity: 0.55; }
.dash-field-inherited input:disabled,
.dash-field-inherited select:disabled {
  background: var(--bg-secondary);
  cursor: not-allowed;
}

/* ── Widget grid: FIVE columns; span 1-5; responsive 2 → 1 ──
   Five because the board this app replaces has 5-tile rows (docs/seed-board.md),
   and a 5-across strip is the unit its readers already know. */
.dash-grid {
  /* A row is a FIXED unit, so widget.rows is a real height: rows-2 is exactly
     twice rows-1 everywhere. Before this, grid-auto-rows was `auto`, so a track
     stretched to its tallest member — two widgets both marked "1 tall" could
     differ by 200px, and a KPI beside a list was dragged up to the list's
     height. Change --dash-row-h to rescale every dashboard at once. */
  --dash-row-h: 140px;
  --dash-gap: 1rem;
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  /* Tracks are `auto`, but every DATA widget states an exact height below, so a
     row is still a fixed unit and rows-2 is still exactly twice rows-1. The
     reason tracks are not a fixed track: a section bar is a divider, not a
     tile, and a fixed track left ~100px of dead space under every one of them.
     With auto tracks a divider-only row shrinks to the band itself. */
  grid-auto-rows: auto;
  gap: var(--dash-gap);
  align-items: stretch;
}
.dash-widget {
  position: relative;
  min-width: 0;
}
 /* widget.span (1–5) — the ONLY width control. The CRM's legacy .dash-size-*
   and .dash-tall classes are deliberately ABSENT: this app has no stored
   document predating span/rows, and keeping them would be a second, silently
   disagreeing source of truth for width (dashboard-widgets.js layoutClass
   never emits them). */
.dash-span-1 { grid-column: span 1; }
.dash-span-2 { grid-column: span 2; }
.dash-span-3 { grid-column: span 3; }
.dash-span-4 { grid-column: span 4; }
.dash-span-5 { grid-column: span 5; }
/* widget.rows (1–3) — the height control, written by the builder's grid picker.
   Heights come from the FIXED --dash-row-h track, not from these rules, so
   "2" is genuinely twice "1" no matter what a neighbouring widget contains. */
.dash-rows-1 { grid-row: span 1; height: var(--dash-row-h); }
.dash-rows-2 { grid-row: span 2; height: calc(var(--dash-row-h) * 2 + var(--dash-gap)); }
.dash-rows-3 { grid-row: span 3; height: calc(var(--dash-row-h) * 3 + var(--dash-gap) * 2); }
/* Height from the CONTENT, for a full-width widget only. A two-row cross tab
   needs about 164px: one track (140) clips it and two (296) waste 130. Nothing
   sits beside a span-5 widget, so there is no neighbour for a ragged height to
   drag out of line — which is the only reason the tracks above are fixed.
   The floor keeps an empty or still-loading widget from collapsing to a sliver. */
.dash-rows-fit { grid-row: span 1; height: auto; min-height: var(--dash-row-h); }
/* The panel stretches to the tile; with an auto tile it must not also claim a
   height, or the two definitions chase each other. */
.dash-rows-fit .dash-panel,
.dash-rows-fit .dash-widget-body { height: auto; }
.dash-rows-fit .dash-panel > .dash-panel-body { overflow-y: visible; }

/* Dividers take only the height of their own content, so what follows sits
   where the eye expects rather than a full row-height away. Section bars and
   headings are both labels, not tiles — giving them a 140px track left a band
   of dead space under every one. */
.dash-widget:has(.dash-section-bar),
.dash-widget:has(.dash-widget-heading) { height: auto; }
/* A widget fills its cell exactly; anything longer scrolls INSIDE the panel
   rather than stretching the row (which is what made a "1 tall" list render at
   whatever height its content happened to want). */
.dash-widget-body { height: 100%; min-height: 0; }
.dash-widget-body > .dash-panel,
.dash-widget-body > .stat-card { height: 100%; min-height: 0; }
.dash-widget-body > .dash-panel {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.dash-widget-body > .dash-panel > .dash-panel-head { flex: 0 0 auto; }
/* Exactly ONE content region (the panel helpers wrap the body), so it can fill
   the cell and scroll. Targeting "every child after the head" instead would turn
   each row of a list widget into its own scroll box and collapse it. */
.dash-widget-body > .dash-panel > .dash-panel-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.dash-grid .dash-panel { margin-bottom: 0; }
.dash-widget .stat-card {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 0;
}
/* Charts fill the cell the picker gave them instead of carrying their own fixed
   height — a hardcoded height would fight the fixed row track. */
.dash-widget-chart-wrap {
  position: relative;
  height: 260px;
}
/* Inside a fixed-height cell the chart fills its content region instead of
   carrying its own height, which would fight the row track. */
.dash-widget-body .dash-panel-body > .dash-widget-chart-wrap {
  height: 100%;
  min-height: 0;
}
/* A chart must not be handed a scrolling parent — Chart.js sizes to the box. */
.dash-widget-body > .dash-panel > .dash-panel-body:has(> .dash-widget-chart-wrap) {
  overflow: hidden;
}
.dash-grid > .dash-grid-empty { grid-column: 1 / -1; }

/* Per-widget toolbar (✎ ↑ ↓ ✕) — owner/admin only, revealed on hover ── */
.dash-widget-toolbar {
  position: absolute;
  top: 6px;
  right: 6px;
  z-index: 2;
  display: flex;
  gap: 2px;
  padding: 2px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 6px;
  box-shadow: var(--shadow-sm);
  opacity: 0;
  transition: opacity 0.12s;
}
.dash-widget:hover .dash-widget-toolbar,
.dash-widget-toolbar:focus-within { opacity: 1; }
.dash-widget-toolbar button {
  border: none;
  background: none;
  cursor: pointer;
  padding: 2px 6px;
  font-size: 0.8rem;
  line-height: 1.2;
  color: var(--text-muted);
  border-radius: 4px;
}
.dash-widget-toolbar button:hover:not(:disabled) {
  background: var(--bg-secondary);
  color: var(--text-primary);
}
.dash-widget-toolbar button:disabled {
  opacity: 0.35;
  cursor: default;
}

/* "Following the dashboard filter" — the per-widget disclosure, emitted by
   dashboard-widgets.js renderOne() only for a widget carrying inheritFilters
   while the dashboard actually has a slice set. It sits in the GRID GAP under
   the card, absolutely positioned and pointer-events:none, so it adds nothing
   to the box Chart.js / Leaflet measure and can never cover widget content.
   The grid's gap is 1rem — this line is deliberately shorter than that. */
.dash-widget-slice {
  position: absolute;
  left: 2px;
  right: 2px;
  bottom: -0.95rem;
  z-index: 1;
  pointer-events: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.65rem;
  line-height: 0.9rem;
  font-weight: 600;
  color: var(--color-primary, #4f46e5);
  opacity: 0.85;
}
.dash-widget-slice::before {
  content: '⌁ ';
  opacity: 0.7;
}

/* The builder's grab handle and resize grip are .dbx-handle / .dbx-grip and are
   styled in css/page-dashboard-builder.css alongside the rest of its chrome. */

/* ══════════════════════════════════════════════════════════════════════════
   MEETING-SCREEN MODE ("Present")
   ══════════════════════════════════════════════════════════════════════════
   A VIEW state, never persisted on the doc. It changes CHROME and SCALE only —
   no rule below touches what a widget renders, so a stored dashboard paints
   byte-identically in both modes (same markup, same widget CSS, same colours).

   ── The hook ──────────────────────────────────────────────────────────────
   JS adds the class `dash-presenting`. Put it on <body> (canonical). Every
   rule here is anchored with
       :is(html.dash-presenting, html:has(.dash-presenting))
   so the class ALSO works on <html>, .app-layout, #dashboard-page-root or the
   grid itself — the sidebar and the mobile top bar live OUTSIDE the dashboard
   mount, and :has() is what lets a class set deep in the page still switch
   them off. Specificity of that prefix is (0,1,1), so a one-class descendant
   rule lands at (0,2,1) and beats the chrome.css/main.css originals without a
   single !important.

   ── The ONE lever ─────────────────────────────────────────────────────────
       --dash-present-scale   set on :root (document.documentElement)
   Type and the grid's row unit are BOTH derived from it, so proportions hold:

     * root font-size becomes calc(1rem * scale). On the root element `rem`
       resolves against the font-size's INITIAL value (the user's browser
       default), so this multiplies the user's own preference rather than
       hard-coding 16px — and every rem in main.css / chrome.css / this file /
       the 25 widget packs' inline styles follows for free. That is why type
       scales without restyling a single widget type.
     * --dash-row-h becomes 8.75rem, which IS 140px x scale (8.75 x 16 = 140).
       Scaling type without scaling the row unit would clip widget content
       against the fixed track, so the two move together by construction.

   JS lever:  document.documentElement.style.setProperty('--dash-present-scale', '1.5')
   (an inline value on :root beats the responsive defaults below, and clearing
   it hands control back to them). Setting the var anywhere else does NOT
   work — custom properties inherit downward, and the font-size that everything
   keys off lives on the root.

   Fullscreen is layered ON TOP of this: the mode is pure CSS and is fully
   functional when requestFullscreen() is refused or unavailable.
   ────────────────────────────────────────────────────────────────────────── */

:root {
  /* Wall screen (>= 1600px): a .stat-value KPI goes 20px -> 35px and a row
     goes 140px -> 245px, so ~4 rows still fit in 1080p. */
  --dash-present-scale: 1.75;
  /* Bezel gutter. In rem so it scales with the lever like everything else. */
  --dash-present-gutter: 0.5rem;
  /* Present-mode CHROME (exit button, stamp, filter bar) scales SUB-linearly
     and is clamped: legible on the wall, never shouting over the data. */
  --dash-present-chrome-fs: clamp(12px, 0.52rem, 20px);
  /* Space reserved at the right of the page header for the fixed exit pill. */
  --dash-present-bar-w: 22rem;
}
/* Degrade sanely below the wall-screen case. Media queries key off viewport
   px, which the root font-size change does NOT affect, so the column counts
   below (4 / 2 / 1) behave in presenting mode exactly as they do normally. */
@media (max-width: 1599px) { :root { --dash-present-scale: 1.45; } }
@media (max-width: 1279px) { :root { --dash-present-scale: 1.25; } }
@media (max-width: 900px)  { :root { --dash-present-scale: 1.1; } }
@media (max-width: 600px)  { :root { --dash-present-scale: 1; } }

/* THE lever. Nothing else in presenting mode sets a font-size on content. */
html.dash-presenting,
html:has(.dash-presenting) {
  font-size: calc(1rem * var(--dash-present-scale, 1.5));
}

/* ── App chrome off ── */
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-tabs,
:is(html.dash-presenting, html:has(.dash-presenting)) .sidebar,
:is(html.dash-presenting, html:has(.dash-presenting)) .sidebar-overlay,
:is(html.dash-presenting, html:has(.dash-presenting)) .mobile-topbar,
:is(html.dash-presenting, html:has(.dash-presenting)) .gs-trigger,
:is(html.dash-presenting, html:has(.dash-presenting)) .ai-chat-panel,
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-tabs {
  display: none;
}
/* The grid takes the 210px the rail gave back, and the AI-chat inset too. */
:is(html.dash-presenting, html:has(.dash-presenting)) .main-content {
  margin-left: 0;
}
:is(html.dash-presenting, html:has(.dash-presenting)) #app-content {
  padding: var(--dash-present-gutter);
  margin-right: 0;
}
/* Owner-only edit affordances have no place on a meeting screen — and a stray
   pointer resting on the wall display must not reveal them. Purely chrome:
   the toolbar is opacity:0 until hover in normal mode. */
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-widget-toolbar {
  display: none;
}
/* Page-header actions (Edit layout / Rename / Delete) go away — but never a
   present-mode control, so an exit button rendered into the existing actions
   row instead of the floating pill is still reachable. */
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-head-actions > *:not(.dash-present-ctl):not(.dash-present-exit):not(.dash-present-bar) {
  display: none;
}
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-head-actions:not(:has(.dash-present-ctl, .dash-present-exit, .dash-present-bar)) {
  display: none;
}

/* ── Header: the dashboard's NAME stays (a wall screen with no label is a
   quiz), everything else in the header collapses. Sub-linear so the title
   never costs more vertical space than it earns. ── */
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-page-header {
  align-items: center;
  gap: 0.4rem;
  margin-bottom: var(--dash-present-gutter);
  padding-right: var(--dash-present-bar-w);
  min-height: 0;
}
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-page-header .page-subtitle {
  display: none;
}
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-page-header .page-title {
  margin: 0;
  font-size: clamp(15px, 0.85rem, 30px);
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The filter slice must stay visible — "a filtered dashboard that looks
   unfiltered is a trap" holds double on a screen nobody is standing at — but
   it is chrome, so it rides the clamped chrome size, not the data size. */
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-filterbar {
  margin-bottom: var(--dash-present-gutter);
  padding: 0.2rem 0.45rem;
  font-size: var(--dash-present-chrome-fs);
  gap: 0.2rem 0.6rem;
}
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-filter-select,
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-filter-clear {
  font-size: var(--dash-present-chrome-fs);
}

/* ── The grid gets the viewport, at the scaled row unit ──
   gap stays 1rem: .dash-widget-slice is parked at bottom:-0.95rem INSIDE that
   gap, so shrinking it would drop the slice line onto the row below. Because
   the gap is rem it already scales with the lever, and the slice with it. */
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-grid {
  --dash-row-h: 8.75rem; /* == 140px x --dash-present-scale */
}

/* ── Exit affordance + "last updated" stamp ───────────────────────────────
   Base state is an inline pill (harmless if it is ever rendered outside the
   mode); in presenting it lifts to a fixed top-right overlay so it costs the
   grid no height. Held at 0.55 opacity — always visible, never competing —
   and it comes fully solid on hover or keyboard focus. z-index clears the
   sidebar (1000) and Leaflet's panes (~400-700). */
.dash-present-bar {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  padding: 4px 6px 4px 12px;
  border: 1px solid var(--border-color, #e2e8f0);
  border-radius: 999px;
  background: var(--bg-primary, #fff);
  box-shadow: var(--shadow-sm, 0 1px 3px rgba(15, 23, 42, 0.12));
  font-size: var(--dash-present-chrome-fs);
  line-height: 1.3;
  white-space: nowrap;
  max-width: calc(100vw - 24px);
  overflow: hidden;
}
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-present-bar {
  position: fixed;
  top: 8px;
  right: 10px;
  z-index: 1200;
  opacity: 0.55;
  transition: opacity 0.15s ease;
}
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-present-bar:hover,
:is(html.dash-presenting, html:has(.dash-presenting)) .dash-present-bar:focus-within {
  opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
  .dash-present-bar { transition: none; }
}
/* "Updated 14:05" — tabular so the minute digit does not jitter the pill
   every tick. .is-stale is the failed-refresh marker: the last good render
   stays on screen and only this line changes. */
.dash-present-stamp,
.dash-present-updated {
  color: var(--text-muted, #64748b);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.dash-present-stamp.is-stale,
.dash-present-updated.is-stale,
.dash-present-stamp.dash-present-stale,
.dash-present-updated.dash-present-stale {
  color: #b45309;
}
.dash-present-stamp.is-stale::before,
.dash-present-updated.is-stale::before,
.dash-present-stamp.dash-present-stale::before,
.dash-present-updated.dash-present-stale::before {
  content: '\26A0\FE0E ';
}
/* Refresh-interval picker. Not .form-select / .dash-filter-select: those carry
   their own rem sizing and would tower over the pill at 1.75x. */
.dash-present-interval,
.dash-present-select {
  padding: 2px 4px;
  border: 1px solid var(--border-color, #e2e8f0);
  border-radius: var(--radius-sm, 4px);
  background: var(--bg-primary, #fff);
  color: var(--text-primary, #0f172a);
  font-family: inherit;
  font-size: inherit;
  font-weight: 600;
  line-height: 1.3;
  max-width: 9em;
}
.dash-present-interval:focus,
.dash-present-select:focus,
.dash-present-exit:focus-visible {
  outline: 2px solid var(--color-primary, #4f46e5);
  outline-offset: 1px;
}
.dash-present-exit {
  padding: 3px 10px;
  border: 1px solid var(--border-color, #e2e8f0);
  border-radius: 999px;
  background: var(--bg-secondary, #f1f5f9);
  color: var(--text-primary, #0f172a);
  font-family: inherit;
  font-size: inherit;
  font-weight: 700;
  line-height: 1.3;
  cursor: pointer;
}
.dash-present-exit:hover {
  background: var(--color-primary, #4f46e5);
  border-color: var(--color-primary, #4f46e5);
  color: #fff;
}
/* The Esc hint. Optional in the markup; muted so the pill stays quiet. */
.dash-present-hint {
  color: var(--text-muted, #64748b);
  font-weight: 600;
  opacity: 0.85;
}
.dash-present-label {
  color: var(--text-muted, #64748b);
  font-weight: 600;
}

/* ── Real browser fullscreen (the optional layer) ──────────────────────────
   The UA paints a black backdrop behind a fullscreen element that is not the
   document root, which would frame a light dashboard in black. Scoped to the
   presenting element / the containers this page would ever request, so no
   <dialog> backdrop anywhere else in the app is touched. */
.dash-presenting::backdrop {
  background: var(--bg-secondary, #f1f5f9);
}
#app-content:fullscreen,
.main-content:fullscreen,
#dashboard-page-root:fullscreen,
.dash-grid:fullscreen {
  background: var(--bg-secondary, #f1f5f9);
  overflow-y: auto;
  padding: var(--dash-present-gutter);
}

@media (max-width: 900px) {
  .dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Spans collapse to at most 2 columns. A section bar (span 5) still runs the
     full width, because 2 IS the full width here. */
  .dash-span-2,
  .dash-span-3,
  .dash-span-4,
  .dash-span-5 { grid-column: span 2; }
}
@media (max-width: 600px) {
  .dash-grid { grid-template-columns: 1fr; }
  .dash-span-1,
  .dash-span-2,
  .dash-span-3,
  .dash-span-4,
  .dash-span-5 { grid-column: span 1; }
  /* Row spans picked in the builder (.dash-rows-*) are left alone — the height
     is a deliberate choice and vertical space is not constrained on one column. */
}

/* ══════════════════════════════════════════════════════════════════════════
   Stat tiles, section bands and share bars
   ─────────────────────────────────────────────────────────────────────────
   The board this app replaces (docs/seed-board.md) is a wall of flat filled
   rectangles: a big bold numeral, a small bold caption under it, a corner badge
   and nothing else. These are NOT .dash-panel cards — no header rule, no
   shadow — which is what makes a 5-across row read as one strip rather than as
   five separate boxes.

   Every colour here is a solid fill with white text, so the tile is legible at
   a glance from across a room (this board is shown on a wall display). The
   'neutral' variant is the one exception: it uses the app surface and the
   normal text colours, for a tile that should not shout.

   Colours are literal rather than themed on purpose — a status colour that
   changes meaning between light and dark is a reporting bug, not a nicety. The
   surrounding chrome still reads from the app's custom properties, so a dark
   theme added later reflows the neutral variant and the text under the tiles
   without touching the filled ones.
   ══════════════════════════════════════════════════════════════════════════ */

.stat-tile {
  position: relative;
  height: 100%;
  min-height: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-align: center;
  gap: 0.15rem;
  padding: 0.6rem 0.7rem;
  border-radius: var(--radius-md, 0.375rem);
  overflow: hidden;
  /* Neutral default — the filled variants below override all three. */
  background: var(--bg-primary, #fff);
  border: 1px solid var(--border-color, #e2e8f0);
  color: var(--text-primary, #1a202c);
}

/* THE number. Fluid so a 5-across tile and a full-width one both fill their
   box, and tabular so a column of tiles has its digits in line. */
.stat-tile-value {
  font-size: clamp(1.5rem, 2.6vw + 0.6rem, 2.6rem);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The caption under the number — the widget's TITLE. Small, bold and
   near-uppercase, per the board being replaced. */
.stat-tile-label {
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1.2;
  opacity: 0.92;
  max-width: 100%;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Top-right pill. On the source board these are computed RANKS ("3 PTD" =
   third-best day project-to-date), not decoration, so they are legible rather
   than faint. Absolutely positioned so they never shift the number off-centre. */
.stat-tile-badge {
  position: absolute;
  top: 5px;
  right: 6px;
  padding: 1px 6px;
  border-radius: 999px;
  font-size: 0.62rem;
  font-weight: 700;
  line-height: 1.5;
  letter-spacing: 0.03em;
  white-space: nowrap;
  background: rgba(255, 255, 255, 0.28);
  color: inherit;
}

.stat-tile-footnote {
  font-size: 0.62rem;
  line-height: 1.25;
  font-weight: 500;
  opacity: 0.8;
  max-width: 100%;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* "Not configured" / "Dataset unavailable" in the value slot: smaller, lighter
   and not tabular, so it can never be mistaken for a measured number. */
.stat-tile-muted .stat-tile-value {
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
  white-space: normal;
  opacity: 0.75;
}

/* ── filled variants ── */
.stat-tile-blue   { background: #1d4ed8; border-color: #1d4ed8; color: #fff; }
.stat-tile-green  { background: #047857; border-color: #047857; color: #fff; }
.stat-tile-red    { background: #b91c1c; border-color: #b91c1c; color: #fff; }
.stat-tile-amber  { background: #b45309; border-color: #b45309; color: #fff; }
.stat-tile-gray   { background: #475569; border-color: #475569; color: #fff; }
/* Neutral keeps the app surface; its badge needs a visible pill on a light
   background, where the white wash used by the filled variants disappears. */
.stat-tile-neutral .stat-tile-badge {
  background: var(--bg-secondary, #f1f5f9);
  border: 1px solid var(--border-color, #e2e8f0);
  color: var(--text-muted, #64748b);
}
.stat-tile-neutral .stat-tile-label,
.stat-tile-neutral .stat-tile-footnote { color: var(--text-muted, #64748b); }

/* ── Section bar — the acceptance board's dividers ──────────────────────────
   A full-width FILLED band, not a heading with a rule. It spans the whole grid
   (span 5) and is what turns a 45-tile board into named sections. */
/* The band is a THIN divider, not a slab. A grid row is a fixed 140px track, so
   without this the band inflates to fill it and the board reads as alternating
   giant blocks. Centring it in the cell turns that height into section spacing
   instead. :has() is already the pattern this stylesheet uses for the chart
   wrapper above. */
.dash-widget-body:has(> .dash-section-bar) {
  display: flex;
  align-items: center;
}
.dash-section-bar {
  height: auto;
  min-height: 2.4rem;
  width: 100%;
  display: flex;
  align-items: center;
  padding: 0.5rem 0.85rem;
  border-radius: var(--radius-md, 0.375rem);
  background: #475569;
  color: #fff;
  overflow: hidden;
}
.dash-section-bar-text {
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.dash-section-bar-blue    { background: #1d4ed8; }
.dash-section-bar-green   { background: #047857; }
.dash-section-bar-red     { background: #b91c1c; }
.dash-section-bar-amber   { background: #b45309; }
.dash-section-bar-gray    { background: #475569; }
.dash-section-bar-neutral {
  background: var(--bg-secondary, #f1f5f9);
  color: var(--text-muted, #64748b);
  border: 1px solid var(--border-color, #e2e8f0);
}
/* No text yet — quiet, so an unfinished band does not look like a real section. */
.dash-section-bar-empty { opacity: 0.5; }

/* ── Share bar (100% stacked) ───────────────────────────────────────────────
   A percentage-width flex row, deliberately NOT an SVG: a one-row bar strip
   re-resolves its own height in a later layout pass, so a measured viewBox
   stops matching the box and the browser letterboxes the bar to ~40% width. */
.dash-share-track {
  display: flex;
  width: 100%;
  height: 100%;
  max-height: 34px;
  min-height: 10px;
  border-radius: 6px;
  overflow: hidden;
  background: var(--bg-secondary, #f1f5f9);
}
.dash-share-seg { height: 100%; }

/* ── Query config-form bits (js/dataset-forms.js) ────────────────────────── */

/* One filter row: column | operator | value | ✕. Grid rather than flex so the
   ✕ stays put when the value input is hidden for "is blank". */
.dwq-filter-row {
  display: grid;
  grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) minmax(0, 1.3fr) auto;
  gap: 0.35rem;
  align-items: center;
  margin-bottom: 0.35rem;
}
.dwq-filter-row > select,
.dwq-filter-row > input { min-width: 0; font-size: 0.82rem; padding: 0.3rem 0.4rem; }
.dwq-filter-row > input[hidden] { display: none; }
.dwq-filter-remove {
  padding: 0.2rem 0.45rem;
  line-height: 1.2;
  color: var(--text-muted, #64748b);
  background: none;
  border: 1px solid var(--border-color, #e2e8f0);
  border-radius: var(--radius-sm, 0.25rem);
  cursor: pointer;
}
.dwq-filter-remove:hover {
  color: #b91c1c;
  border-color: #b91c1c;
}
.dwq-filters:empty + template + .dwq-filter-add { margin-top: 0; }
.dwq-filter-add { margin-top: 0.1rem; }

/* A labelled divider between the query blocks of a multi-query form
   (dualStat's A/B, pctTile's numerator/denominator). */
.form-section-label {
  margin: 0.9rem 0 0.5rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--border-color, #e2e8f0);
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-muted, #64748b);
}
.form-section-label:first-child { margin-top: 0; padding-top: 0; border-top: 0; }

/* The muted one-liner under a field that states what the setting actually does. */
.form-hint {
  margin-top: 0.25rem;
  font-size: 0.72rem;
  line-height: 1.35;
  color: var(--text-muted, #718096);
  font-weight: 400;
}
.form-label .form-hint { margin-left: 0.35rem; display: inline; }

@media (max-width: 600px) {
  /* One column: stack the filter row so each control keeps a usable width. */
  .dwq-filter-row {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas: "col rm" "op rm" "val rm";
  }
  .dwq-filter-row > [data-dwq-part="column"] { grid-area: col; }
  .dwq-filter-row > [data-dwq-part="op"] { grid-area: op; }
  .dwq-filter-row > [data-dwq-part="value"] { grid-area: val; }
  .dwq-filter-row > .dwq-filter-remove { grid-area: rm; align-self: start; }
}

/* ── Metrics v2 tiles — kpiTrend + gauge (js/dashboard-widgets-metrics.js) ──
   These are .stat-tile FACES with one extra piece of picture in them, not new
   kinds of card: everything below is a modifier on the stat tile above, so a
   KPI trend standing next to a stat tile in a 5-across row still reads as the
   same object.

   The pictures are drawn in currentColor throughout, which is what lets one
   sparkline and one arc work on all six variants — dark on the neutral
   surface, white on a filled one. A palette colour would disappear on half
   of them. ───────────────────────────────────────────────────────────────── */

/* The number and its change on one baseline. Wraps rather than overflows, so a
   long formatted value pushes the delta under it instead of off the tile. */
.metric-tile-head {
  display: flex;
  align-items: baseline;
  justify-content: center;
  flex-wrap: wrap;
  gap: 0.1rem 0.4rem;
  max-width: 100%;
  min-width: 0;
}

/* The change pill. Same shape and weight as .stat-tile-badge, because it is
   the same kind of thing: a small computed fact riding on the number. */
.metric-delta {
  flex: none;
  padding: 1px 6px;
  border-radius: 999px;
  font-size: 0.66rem;
  font-weight: 800;
  line-height: 1.5;
  letter-spacing: 0.02em;
  white-space: nowrap;
  background: rgba(255, 255, 255, 0.24);
  color: inherit;
}
/* On a filled tile the pill's own wash carries it and the text stays white —
   a green delta on a blue fill is unreadable, and a red one is worse. The
   ARROW still says which way the number went on every variant; only the
   colour is withheld. */
.stat-tile-neutral .metric-delta {
  background: var(--bg-secondary, #f1f5f9);
  border: 1px solid var(--border-color, #e2e8f0);
  color: var(--text-muted, #64748b);
}
/* Judgement colours, and ONLY on the neutral surface where they are legible.
   'flat' is the default tone: goodDirection 'neutral' never colours anything,
   because a rising skip count is not an improvement. */
.stat-tile-neutral .metric-delta-good {
  background: #ecfdf5;
  border-color: #a7f3d0;
  color: #047857;
}
.stat-tile-neutral .metric-delta-bad {
  background: #fef2f2;
  border-color: #fecaca;
  color: #b91c1c;
}

/* ── kpiTrend ── */

/* A one-row tile is a 140px track and the sparkline is the flexible part: the
   number, the caption and the footnote keep their size and the picture takes
   whatever is left. Below ~14px of picture it is hidden entirely rather than
   painted as a smear (min-height on a flex child that cannot shrink further). */
.stat-tile-kpi { gap: 0.12rem; }
/* :not(.stat-tile-muted) is load-bearing, not tidiness. These modifiers are
   appended AFTER .stat-tile-muted .stat-tile-value above and match at the same
   specificity, so without the guard "Not configured" wins the cascade and gets
   painted at 2.1rem — i.e. a state message rendered exactly like a measured
   number, which is the one thing the muted rule exists to prevent. */
.stat-tile-kpi:not(.stat-tile-muted) .stat-tile-value {
  /* Slightly smaller than a plain stat tile: this face carries a picture too. */
  font-size: clamp(1.25rem, 2.1vw + 0.5rem, 2.1rem);
}
.metric-spark {
  flex: 1 1 auto;
  align-self: stretch;   /* .stat-tile centres its children; this one spans */
  width: 100%;
  min-height: 14px;
  max-height: 54px;
  margin-top: 1px;
  overflow: hidden;
}
/* preserveAspectRatio="none" stretches the 200x40 layout box to the tile; the
   strokes are pinned with vector-effect="non-scaling-stroke" in the markup, so
   this never paints a fat line on a wide tile or a hairline on a narrow one. */
.metric-spark-svg { width: 100%; height: 100%; display: block; }

/* ── gauge ── */

.stat-tile-gauge { gap: 0.15rem; }
/* The pair the arc is made of ("12,345 of 15,000") sits where a stat tile puts
   its number, but it is a sentence rather than a numeral — the PERCENTAGE is
   the big figure here and it lives inside the arc. */
.stat-tile-gauge:not(.stat-tile-muted) .stat-tile-value {
  font-size: 0.8rem;
  font-weight: 700;
  letter-spacing: 0;
  order: 3;             /* under the arc, above the caption */
}
.stat-tile-gauge .stat-tile-label { order: 4; }
.stat-tile-gauge .stat-tile-footnote { order: 5; }
.metric-gauge {
  order: 1;
  flex: 1 1 auto;
  align-self: stretch;
  width: 100%;
  min-height: 44px;
  overflow: hidden;
}
.metric-gauge-svg { width: 100%; height: 100%; display: block; }
/* Over target. The arc is clamped at a full semicircle by design, so this line
   and the true percentage inside the arc are the only places the good news
   still shows — never remove them to "tidy up" the face. */
.metric-gauge-met {
  order: 2;
  font-size: 0.6rem;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1.2;
  opacity: 0.9;
}
.stat-tile-neutral .metric-gauge-met { color: #047857; }

/* A gauge dropped into a one-row cell (minRows says 2, but a stored widget can
   predate that) lets the arc shrink instead of overflowing the tile. */
.dash-rows-1 .metric-gauge { min-height: 0; }

/* ══ MAP pack (js/dashboard-widgets-map.js) ══════════════════════════════════
   Appended by the map widget pack — see the note at the top of that file.
   Leaflet's own stylesheet is vendored at js/vendor/leaflet.css; everything
   here is the seam between it and the dashboard grid. */

/* The flex column inside the chart wrapper: the map fills, the legend and the
   footnote sit under it. .dash-widget-chart-wrap is kept alongside so the
   grid's "fill the cell, do NOT scroll" rules still apply. */
.dw-map-wrap {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  min-height: 0;
}
/* z-index:0 + position:relative makes this a STACKING CONTEXT. Leaflet's panes
   sit at z-index 400-700 and its controls at 800-1000; without a context of
   their own they paint over the widget's edit toolbar (z-index 2 on the
   wrapper), so the ✎ ↑ ↓ ✕ buttons become unclickable on every map. */
.dw-map-box {
  position: relative;
  z-index: 0;
  isolation: isolate;
  flex: 1 1 auto;
  min-height: 60px;
  border-radius: 6px;
  overflow: hidden;
  /* Shown while tiles load, and the colour a blocked-CSP map would stay. */
  background: #e9edf1;
}
/* The map element is created fresh on every render (container reuse throws
   "Map container is already initialized") and fills its box absolutely. */
.dw-map-canvas {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
}
/* Leaflet ships its own font stack; inherit the app's instead. */
.dw-map-box .leaflet-container {
  font-family: inherit;
  font-size: 0.75rem;
  background: #e9edf1;
}
/* Attribution is required by OSM/CARTO, so it is never hidden — only shrunk to
   fit a 140px dashboard row without eating the map. */
.dw-map-box .leaflet-control-attribution {
  font-size: 0.6rem;
  line-height: 1.3;
  padding: 0 4px;
  background: rgba(255, 255, 255, 0.75);
}
.dw-map-box .leaflet-control-attribution a { color: var(--text-muted, #64748b); }
/* The zoom control is a chunky 26px default; a two-row widget cannot spare it. */
.dw-map-box .leaflet-control-zoom { border: 0; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); }
.dw-map-box .leaflet-control-zoom a {
  width: 22px;
  height: 22px;
  line-height: 22px;
  font-size: 0.95rem;
}
.dw-map-box .leaflet-tooltip {
  padding: 2px 6px;
  font-size: 0.7rem;
  font-weight: 600;
  border: 0;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.22);
}

/* ── Flow pack (js/dashboard-widgets-flow.js) ───────────────────────────────
   funnel + calendarHeatmap. stackedTrend is a Chart.js canvas and reuses
   .dash-widget-chart-wrap, so it needs nothing here. */

/* Funnel: the stage cell is a label and a proportional bar on ONE line. The bar
   is scaled to the LARGEST stage, not the first — these are STATES, so a later
   stage legitimately outgrows the first and a first-scaled bar would run off
   the cell.
   One line, not two: stacking the bar under the label costs ~20px a row, which
   is a whole stage pushed out of a two-row cell before anyone has scrolled. */
.dash-funnel-cell { padding-top: 5px; padding-bottom: 5px; }
.dash-funnel-stage {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  line-height: 1.25;
}
.dash-funnel-label {
  flex: 0 1 auto;
  max-width: 60%;
  font-size: 0.85rem;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.dash-funnel-bar {
  flex: 1 1 auto;
  min-width: 24px;
  height: 6px;
  border-radius: 3px;
  background: var(--bg-secondary, #f1f5f9);
  overflow: hidden;
}
.dash-funnel-bar > span {
  display: block;
  height: 100%;
  border-radius: 3px;
  min-width: 0;
}
/* A named stage that matched NO rows. Muted so it reads as "named but empty"
   rather than as a real group, and never dropped — a skipped stage silently
   loses a step of the funnel. */
.dash-funnel-absent .dash-funnel-label { color: var(--text-muted, #64748b); font-style: italic; }
.dash-funnel-absent td { color: var(--text-muted, #64748b); }

/* Calendar heat map: the SVG takes the slack in the panel body and the legend
   and footnote stay pinned under it. The SVG carries width/height 100% from
   helpers.svgHtml, so it letterboxes inside whatever box this leaves it. */
.dash-heat-svg {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: center;
}
.dash-heat-legend {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 3px 12px;
  margin-top: 4px;
  font-size: 0.7rem;
  line-height: 1.2;
  color: var(--text-muted, #64748b);
}
.dash-heat-key {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}
.dash-heat-sw {
  width: 9px;
  height: 9px;
  border-radius: 2px;
  flex: none;
  display: inline-block;
}
/* "No data" — a day OUTSIDE the window, drawn as an outline. It must never be
   confused with a zero: "nobody worked" and "we hold no data for this day" are
   different facts, so they get different marks rather than different shades. */
.dash-heat-sw-out {
  background: none;
  border: 1px solid rgba(148, 163, 184, 0.55);
}

/* ── Row list (dashboard-widgets-list.js) ──────────────────────────────────
   A list of ACTUAL ROWS, so it is denser than a breakdown table and its cells
   hold arbitrary uploaded text (service addresses, dispositions, names).

   The wrap — not the panel body — is the scroll container. Same reasoning as
   the chart wrap above: the row track is a fixed height, so the table must
   scroll INSIDE its own box. That is also what makes the sticky header stick;
   sticky resolves against the nearest scrolling ancestor, and if that is the
   panel body the header scrolls away with the rows. */
.dash-widget-body > .dash-panel > .dash-panel-body:has(> .dash-rowlist-wrap) {
  overflow: hidden;
}
.dash-widget-body .dash-panel-body > .dash-rowlist-wrap {
  height: 100%;
  min-height: 0;
  overflow: auto;
  overscroll-behavior: contain;
}
.dash-rowlist-wrap {
  max-width: 100%;
}
.dash-rowlist {
  font-size: 0.78rem;
  table-layout: auto;
}
.dash-rowlist th {
  padding: 5px 8px;
}
/* ONE line per cell, clipped with an ellipsis and the full value on the title
   attribute. A wrapped service address would make every row a different height
   and turn a 10-row list into a 3-row one; a clipped one keeps the list a list,
   and the horizontal scrollbar on the wrap is what reaches the rest. This is
   also what stops a 90-character cell from widening the grid cell and pushing
   the PAGE sideways. */
.dash-rowlist td {
  padding: 5px 8px;
  max-width: 22ch;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.dash-rowlist th.dash-r,
.dash-rowlist td.dash-r {
  text-align: right;
}

/* ── Image widget (dashboard-widgets-list.js) ──────────────────────────────
   A logo fills the cell it was given and nothing else: the wrap clips, the img
   carries a DEFINITE box (width+height 100%) and object-fit does the letterbox.
   The definite box is load-bearing for SVG — an SVG with only a viewBox and no
   intrinsic width/height renders 0x0 inside a max-width-only img. */
.dash-widget-body > .dash-panel > .dash-panel-body:has(> .dash-img-wrap) {
  overflow: hidden;
}
.dash-widget-body .dash-panel-body > .dash-img-wrap {
  height: 100%;
  min-height: 0;
}
.dash-img-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-width: 0;
  overflow: hidden;
}
.dash-img {
  display: block;
  width: 100%;
  height: 100%;
  min-height: 0;
  max-width: 100%;
  object-position: center;
}
.dash-img-contain { object-fit: contain; }
.dash-img-cover { object-fit: cover; }

/* ══ BRIDGE pack (js/dashboard-widgets-bridge.js) ════════════════════════════
   waterfall + goalProgress. The waterfall is a panel widget carrying one
   hand-rolled SVG; goalProgress is a .stat-tile FACE with a bar in it — a
   modifier on the stat tile, exactly like the metrics pack's gauge, so a goal
   standing next to a stat tile in a 5-across row still reads as the same kind
   of object. ────────────────────────────────────────────────────────────── */

/* ── waterfall ── */

/* The figure takes the slack in the panel body and the footnote stays pinned
   under it. Same shape as .dash-heat-svg's wrapper in the flow pack: the SVG
   carries width/height 100% from helpers.svgHtml, so it letterboxes inside
   whatever box this leaves it. */
.dash-bridge-wrap {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
}
.dash-bridge-fig {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: center;
}
/* preserveAspectRatio is "meet", so the bridge shrinks to fit rather than
   cropping: a bar whose value label is clipped off is a bar the reader cannot
   audit, which defeats the point of drawing the arithmetic. */
.dash-bridge { width: 100%; height: 100%; display: block; }

/* ── goalProgress ── */

.stat-tile-goal { gap: 0.2rem; }
/* :not(.stat-tile-muted) is load-bearing, not tidiness — the same trap the
   metrics pack documents. These rules are appended AFTER
   .stat-tile-muted .stat-tile-value and match at the same specificity, so
   without the guard "Not configured" / "Set a target" would win the cascade
   and be painted at the size of a measured number. */
.stat-tile-goal:not(.stat-tile-muted) .stat-tile-value {
  /* Smaller than a plain stat tile: this face carries a bar and a sub-line. */
  font-size: clamp(1.2rem, 2vw + 0.45rem, 2.1rem);
  order: 1;
}
.dash-goal { order: 2; }
.dash-goal-sub { order: 3; }
.stat-tile-goal .stat-tile-label { order: 4; }
.stat-tile-goal .stat-tile-footnote { order: 5; }

.dash-goal {
  width: 100%;
  max-width: 100%;
  flex: none;
  align-self: stretch;   /* .stat-tile centres its children; this one spans */
  margin: 0.1rem 0 0.05rem;
}
/* The track is a wash of the tile's own colour, like .metric-delta: one rule
   works on all six variants instead of a palette colour that disappears on
   half of them. */
.dash-goal-track {
  height: 9px;
  border-radius: 999px;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.26);
}
.dash-goal-fill {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: currentColor;
  /* A 0.4% bar is still a fact; without this it rounds to nothing on screen. */
  min-width: 2px;
}
/* On the neutral surface the wash has nothing to sit on, so the track takes the
   app's own secondary/border pair and the fill takes the accent. */
.stat-tile-neutral .dash-goal-track {
  background: var(--bg-secondary, #f1f5f9);
  border: 1px solid var(--border-color, #e2e8f0);
}
.stat-tile-neutral .dash-goal-fill { background: #0d9488; }
.stat-tile-neutral .dash-goal-reached .dash-goal-fill { background: #059669; }

/* "12,345 of 15,000" — the pair the percentage is made of. The gauge puts this
   in the tile's value slot and the percentage inside the arc; a bar has no
   inside, so the two swap places and the numbers stay identical. */
.dash-goal-sub {
  display: flex;
  align-items: baseline;
  justify-content: center;
  flex-wrap: wrap;
  gap: 0 0.4rem;
  max-width: 100%;
  font-size: 0.72rem;
  font-weight: 700;
  line-height: 1.25;
  font-variant-numeric: tabular-nums;
  opacity: 0.95;
}
/* Over target. The BAR is clamped at full width by design, so this line and the
   true percentage above it are the only places the good news still shows —
   never remove them to "tidy up" the face. */
.dash-goal-met {
  font-size: 0.6rem;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1.2;
  opacity: 0.9;
}
.stat-tile-neutral .dash-goal-met { color: #047857; }

/* ══ MATRIX pack (js/dashboard-widgets-matrix.js) ════════════════════════════
   heatMatrix (weekday × hour) + treemap. Both are hand-rolled SVG, so all this
   layer does is give the SVG the slack in the panel body and pin the legend and
   footnote underneath it. */

/* The SVG region takes the free height; helpers.svgHtml already carries
   width/height 100%, so the graphic letterboxes inside whatever box is left
   after the legend and the footnote have taken theirs. */
.dw-mx-svg {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: center;
}
.dw-mx-legend {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 3px 12px;
  margin-top: 4px;
  font-size: 0.7rem;
  line-height: 1.2;
  color: var(--text-muted, #64748b);
}
.dw-mx-key {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}
.dw-mx-sw {
  width: 9px;
  height: 9px;
  border-radius: 2px;
  flex: none;
  display: inline-block;
}
/* "No data" — an hour with no rows behind it under a sum/min/max measure, drawn
   as an outline. It must never be confused with a zero: on a COUNT matrix an
   empty hour genuinely is 0 and takes the neutral fill instead, which is why
   only one of these two keys is ever in the legend at a time. */
.dw-mx-sw-out {
  background: none;
  border: 1px solid rgba(148, 163, 184, 0.55);
}
/* A tile label must not swallow the hover: the <title> tooltip lives on the
   RECT, so text over it has to be transparent to the pointer or the biggest
   tiles are the only ones with no tooltip. */
.dw-mx-tree text { pointer-events: none; }
/* Cell tooltips are the whole interaction on the heat map — the pointer says so. */
.dw-mx-heat rect { cursor: default; }

/* ── XY pack (js/dashboard-widgets-xy.js) ───────────────────────────────────
   scatter + gantt. Both are hand-rolled SVG that carries width/height 100%
   from helpers.svgHtml, so the only job here is to hand the drawing the slack
   in the panel body and keep the legend and footnote pinned under it — the
   same deal .dash-heat-svg strikes for the calendar. */
.dash-xy-svg {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: center;
}
/* Overplotting is the point of a scatter: 500 translucent dots read as density
   rather than as ink. The hover ring is what makes an individual dot findable
   again inside that cloud — the <title> tooltip alone gives no visual anchor. */
.dash-xy-plot .dash-xy-dot { transition: fill-opacity 0.08s ease; }
.dash-xy-plot .dash-xy-dot:hover {
  fill-opacity: 1;
  stroke: var(--text-primary, #0f172a);
  stroke-width: 0.6;
}
.dash-xy-gantt .dash-xy-bar:hover { fill-opacity: 0.82; }
@media (prefers-reduced-motion: reduce) {
  .dash-xy-plot .dash-xy-dot { transition: none; }
}

/* ── weather ──────────────────────────────────────────────────────────────
   Heat is the hazard on this programme, so heat is the only thing coloured. */
.dash-weather .dash-weather-now {
    display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
}
.dash-weather-temp {
    font-size: 2.5rem; font-weight: 700; line-height: 1;
    font-variant-numeric: tabular-nums; color: var(--text-strong, #111827);
}
.dash-weather-unit { font-size: 1rem; font-weight: 600; margin-left: 0.1rem; }
.dash-weather-meta { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
.dash-weather-summary { font-weight: 600; }
.dash-weather-place, .dash-weather-wind {
    font-size: 0.75rem; color: var(--text-muted, #6b7280);
}
/* A strip of day cards, always ONE line.
   Wrapping was the obvious choice and the wrong one: seven cards in a
   two-column tile fold onto a second row that the fixed row height then clips,
   which is the same silent truncation the forecast had before. Cards shrink to
   share the width and the strip scrolls sideways only once they would stop
   being legible. */
/* Just the place name, when the current-conditions block is off. */
.dash-weather-place-only { margin-bottom: 0.15rem; }
.dash-weather-place-only:empty { display: none; }
.dash-weather-place-only + .dash-weather-days {
    margin-top: 0.35rem; padding-top: 0; border-top: 0;
}
.dash-weather-days {
    list-style: none; margin: 0.75rem 0 0; padding: 0.7rem 0 0;
    border-top: 1px solid var(--border, #e5e7eb);
    display: grid; gap: 0.4rem;
    grid-auto-flow: column;
    grid-auto-columns: minmax(3.4rem, 1fr);
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: thin;
}
.dash-weather-day {
    display: flex; flex-direction: column; align-items: center; gap: 0.15rem;
    padding: 0.5rem 0.25rem;
    border: 1px solid var(--border, #e5e7eb); border-radius: 0.5rem;
    background: var(--surface-muted, #f9fafb);
    min-width: 0;
}
.dash-weather-day-top {
    display: flex; flex-direction: column; align-items: center; gap: 0.15rem;
}
/* Invisible to layout in the full card, so the readings stack exactly as they
   did before; the compact rule below turns it into a row. */
.dash-weather-day-read { display: contents; }
.dash-weather-day-name {
    font-size: 0.75rem; font-weight: 700; color: var(--text-muted, #6b7280);
    text-transform: uppercase; letter-spacing: 0.02em;
    white-space: nowrap;
}
.dash-weather-icon { width: 1.9rem; height: 1.9rem; color: #475569; margin: 0.1rem 0; }
.dash-weather-day-hi {
    font-size: 0.95rem; font-weight: 700; font-variant-numeric: tabular-nums;
}
.dash-weather-day-lo {
    font-size: 0.85rem; color: var(--text-muted, #9ca3af);
    font-variant-numeric: tabular-nums;
}
/* The unit is spelled out on the "now" figure; on a seven-across strip it is
   noise, and dropping it is what lets a card stay legible at two columns. */
.dash-weather-day .dash-weather-unit { display: none; }
.dash-weather-day-hi::after, .dash-weather-day-lo::after { content: '\00b0'; }
/* The last day's night is usually unpublished. "—" is the honest value; "—°"
   reads as a rendering fault. */
.dash-weather-day-lo-none::after { content: none; }
.dash-weather-day-pop {
    display: flex; align-items: center; gap: 0.1rem;
    font-size: 0.7rem; color: #2563eb; font-variant-numeric: tabular-nums;
}
.dash-weather-day-pop svg { width: 0.7rem; height: 0.7rem; }

/* COMPACT — a one-row tile.
   The row track is a fixed 140px and the full card is five stacked lines, so it
   cannot fit however wide the tile is. Two lines can: weekday beside the icon,
   then the readings on one line. Only reachable with current conditions off,
   which is what frees the whole tile for the strip. */
/* Wider minimum than the stacked card: the readings sit side by side here, so
   3.4rem overlapped them into each other. Past this the strip scrolls, which is
   the behaviour a narrow tile should get. */
.dash-weather-compact { grid-auto-columns: minmax(7.5rem, 1fr); }
.dash-weather-compact .dash-weather-day {
    padding: 0.3rem 0.25rem; gap: 0.05rem;
    white-space: nowrap;
}
.dash-weather-compact .dash-weather-day-top {
    flex-direction: row; align-items: center; gap: 0.3rem;
}
.dash-weather-compact .dash-weather-icon {
    width: 1.15rem; height: 1.15rem; margin: 0;
}
.dash-weather-compact .dash-weather-day-name { font-size: 0.7rem; }
.dash-weather-compact .dash-weather-day-hi { font-size: 0.85rem; }
.dash-weather-compact .dash-weather-day-lo { font-size: 0.75rem; }
/* The three readings share one line. The card is a flex COLUMN, so they have to
   be lifted into a row container — setting inline-flex on them individually
   leaves them stacked, which is what made the first compact card 86px tall in
   58px of space. */
.dash-weather-compact .dash-weather-day-read {
    display: flex; flex-direction: row; align-items: baseline;
    gap: 0.3rem; min-width: 0;
}
.dash-weather-compact .dash-weather-day-pop { font-size: 0.65rem; }
.dash-weather-compact .dash-weather-day-pop svg { width: 0.6rem; height: 0.6rem; }
.dash-weather-state { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.dash-weather-error { color: #b91c1c; }

/* Above 100°F outdoor work slows; above 110°F it stops. */
.dash-weather-hot .dash-weather-temp,
.dash-weather-day.dash-weather-hot .dash-weather-day-hi { color: #c2410c; }
.dash-weather-extreme .dash-weather-temp,
.dash-weather-day.dash-weather-extreme .dash-weather-day-hi { color: #b91c1c; }
.dash-weather-day.dash-weather-hot { border-color: #fdba74; }
.dash-weather-day.dash-weather-extreme { border-color: #fca5a5; }
.dash-panel.dash-weather-extreme { border-left: 3px solid #b91c1c; }
.dash-panel.dash-weather-hot { border-left: 3px solid #ea580c; }

/* ── drill-through ────────────────────────────────────────────────────────
   A widget that opens another board. View mode only. */
.dash-widget-link { cursor: pointer; position: relative; }
.dash-widget-link:hover .dash-panel,
.dash-widget-link:hover .stat-tile,
.dash-widget-link:focus-visible .dash-panel,
.dash-widget-link:focus-visible .stat-tile {
    border-color: var(--accent, #0d9488);
    box-shadow: 0 0 0 1px var(--accent, #0d9488);
}
.dash-widget-link:focus-visible { outline: none; }
/* The affordance: a corner arrow that only appears on hover/focus, so a board
   full of links does not turn into a board full of icons. */
.dash-widget-link::after {
    content: '↗';
    position: absolute; top: 0.5rem; right: 0.6rem;
    font-size: 0.8rem; line-height: 1;
    color: var(--accent, #0d9488);
    opacity: 0; transition: opacity 120ms ease;
    pointer-events: none; z-index: 2;
}
.dash-widget-link:hover::after,
.dash-widget-link:focus-visible::after { opacity: 1; }
.dash-weather-short {
    margin-top: 0.5rem; font-size: 0.7rem; color: var(--text-muted, #6b7280);
}

/* ── cross tab ────────────────────────────────────────────────────────────
   Two dimensions at once. Wide grids scroll inside the widget rather than
   pushing the board sideways. */
/* Scrolls in BOTH axes and never clips: a cross tab taller than its cell used
   to cut off its last rows AND the totals row, which is the one row that has to
   be visible. Header and footer stick so the labels and the totals stay put
   while the body scrolls under them. */
.xt-scroll { overflow: auto; max-width: 100%; max-height: 100%; }
/* separate, not collapse: position:sticky on table cells is ignored under
   border-collapse, which silently defeats the sticky header and totals row.
   Spacing is zeroed so it still reads as a solid grid. */
.xt-table {
    width: 100%; border-collapse: separate; border-spacing: 0;
    font-size: 0.78rem;
}
.xt-table th, .xt-table td { padding: 0.3rem 0.45rem; white-space: nowrap; }
.xt-corner { background: transparent; }
.xt-colhead {
    text-align: right; font-size: 0.68rem; text-transform: uppercase;
    letter-spacing: 0.02em; color: var(--text-muted, #6b7280); font-weight: 600;
    border-bottom: 1px solid var(--border, #e5e7eb);
}
.xt-rowhead {
    text-align: left; font-weight: 500; position: sticky; left: 0;
    background: var(--surface, #fff); max-width: 13rem;
    overflow: hidden; text-overflow: ellipsis;
}
.xt-cell {
    text-align: right; font-variant-numeric: tabular-nums;
    border-bottom: 1px solid var(--border-subtle, #f3f4f6);
}
.xt-zero { color: var(--text-faint, #d1d5db); }
.xt-total, .xt-grand { font-weight: 700; }
.xt-total-row td, .xt-total-row th { border-top: 2px solid var(--border, #e5e7eb); }
.xt-other, .xt-other-row .xt-rowhead { font-style: italic; color: var(--text-muted, #6b7280); }
.xt-note { margin-top: 0.5rem; font-size: 0.7rem; color: var(--text-muted, #6b7280); }

.xt-table thead th {
    position: sticky; top: 0; z-index: 2;
    background: var(--surface, #fff);
}
.xt-table tfoot th, .xt-table tfoot td {
    position: sticky; bottom: 0; z-index: 2;
    background: var(--surface, #fff);
}
/* The corner cells sit at two intersections and must win both. */
.xt-table thead .xt-corner, .xt-table tfoot .xt-rowhead { z-index: 3; }
.xt-rowhead { z-index: 1; }

/* Sortable cross-tab headers. Buttons rather than bare <th> text so a click
   sorts instead of triggering a drill-through on the widget. */
.xt-sort {
    background: none; border: 0; padding: 0; margin: 0; cursor: pointer;
    font: inherit; color: inherit; letter-spacing: inherit;
    text-transform: inherit; text-align: inherit;
    display: inline-flex; align-items: center; gap: 0.15rem;
}
.xt-sort:hover { color: var(--accent, #0d9488); text-decoration: underline; }
.xt-sort:focus-visible { outline: 2px solid var(--accent, #0d9488); outline-offset: 1px; }
.xt-arrow { font-size: 0.7em; color: var(--accent, #0d9488); }
.xt-corner .xt-sort {
    font-size: 0.68rem; text-transform: uppercase; font-weight: 600;
    color: var(--text-muted, #6b7280);
}

/* Starter-board picker. Nothing is ticked by default: this acts on boards
   someone is already using, so the safe default is to do nothing. */
.starter-dialog .card { max-width: 34rem; }
.starter-list {
    display: flex; flex-direction: column; gap: 0.1rem;
    margin: 0.75rem 0; max-height: 62vh; overflow-y: auto;
    position: relative;
}
/* There is more below. macOS hides the scrollbar until the pointer moves, so
   without this the last board in the list is invisible AND unhinted. */
.starter-list.starter-more {
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent 100%);
    mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent 100%);
}
.starter-count {
    display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.45rem;
    border-radius: 999px; font-size: 0.7rem; font-weight: 600;
    background: var(--border-subtle, #f3f4f6); color: var(--text-muted, #6b7280);
    vertical-align: middle;
}
.starter-row {
    display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.6rem;
    align-items: baseline; padding: 0.5rem 0.4rem; border-radius: 6px; cursor: pointer;
}
.starter-row:hover { background: var(--surface-alt, #f9fafb); }
.starter-row input { grid-row: span 2; }
.starter-name { font-weight: 600; }
.starter-meta { grid-column: 2; font-size: 0.75rem; color: var(--text-muted, #6b7280); }
.starter-dialog .form-actions { display: flex; gap: 0.5rem; align-items: center; }
.starter-dialog .form-actions .spacer { flex: 1; }

/* Drag handle on a dashboard card. The tab strip order is the card order, and
   the API has always supported it — this is what finally reaches it. */
.dash-card { position: relative; }
.dash-card-grip {
    position: absolute; top: 0.5rem; right: 0.6rem;
    color: var(--text-faint, #d1d5db); cursor: grab;
    font-size: 0.9rem; line-height: 1; user-select: none;
    opacity: 0; transition: opacity 120ms ease;
}
.dash-card:hover .dash-card-grip,
.dash-card-grip:focus-visible { opacity: 1; }
.dash-card-grip:active { cursor: grabbing; }

/* ── saturation table ─────────────────────────────────────────────────────
   The area-saturation sheet, one row per sector. Wide, so it scrolls inside
   the widget rather than pushing the board sideways. */
.sat-scroll { overflow: auto; max-width: 100%; max-height: 100%; }
.sat-table {
    width: 100%; border-collapse: separate; border-spacing: 0;
    font-size: 0.78rem;
}
.sat-table th, .sat-table td { padding: 0.3rem 0.5rem; white-space: nowrap; }
.sat-th, .sat-th-label {
    position: sticky; top: 0; z-index: 2; background: var(--surface, #fff);
    font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.02em;
    color: var(--text-muted, #6b7280); font-weight: 600;
    border-bottom: 1px solid var(--border, #e5e7eb);
}
.sat-th { text-align: right; }
.sat-th-label { text-align: left; }
.sat-rowhead {
    text-align: left; font-weight: 600; position: sticky; left: 0; z-index: 1;
    background: var(--surface, #fff);
}
.sat-num {
    text-align: right; font-variant-numeric: tabular-nums;
    border-bottom: 1px solid var(--border-subtle, #f3f4f6);
}
.sat-pct { font-weight: 600; }
/* One meaning for colour: below the threshold someone set. */
.sat-below { color: #c2410c; }
.sat-total-row th, .sat-total-row td {
    position: sticky; bottom: 0; z-index: 2; background: var(--surface, #fff);
    font-weight: 700; border-top: 2px solid var(--border, #e5e7eb);
}
.sat-table thead .sat-th-label, .sat-table tfoot .sat-rowhead { z-index: 3; }

/* ── run calendar ─────────────────────────────────────────────────────────
   One tick per thing per day. An EMPTY cell is the finding, so it is drawn as
   a real cell rather than left out. */
.rc-scroll { overflow: auto; max-width: 100%; max-height: 100%; }
.rc-table { border-collapse: separate; border-spacing: 0; font-size: 0.72rem; }
.rc-table th, .rc-table td { padding: 0; }
/* TWO frozen columns: the code and the name. Both stick, and the name's left
   offset is the code column's width — they cannot overlap while scrolling. */
.rc-corner, .rc-rowhead {
    position: sticky; z-index: 2; background: var(--surface, #fff);
    text-align: left; padding: 0.2rem 0.5rem 0.2rem 0.2rem;
    white-space: nowrap; font-weight: 600;
    overflow: hidden; text-overflow: ellipsis;
}
.rc-corner-code, .rc-code { left: 0; max-width: 11rem; }
/* The offset is the code column's MEASURED width, set on .rc-scroll after each
   paint. A hard-coded 8.5rem was wrong the moment the browser sized that column
   to its content instead — 91px against an assumed 136px, leaving a 45px slot
   the scrolling days showed through. The fallback only matters for the single
   frame before the measurement lands. */
.rc-corner-name, .rc-name {
    left: var(--rc-code-w, 8.5rem); max-width: 12rem;
    font-weight: 500; color: var(--text-muted, #4b5563);
    border-right: 1px solid var(--border, #e5e7eb);
}
.rc-corner {
    z-index: 3; top: 0; font-size: 0.66rem; text-transform: uppercase;
    letter-spacing: 0.02em; color: var(--text-muted, #6b7280);
}
.rc-day {
    position: sticky; top: 0; z-index: 1; background: var(--surface, #fff);
    width: 1.35rem; min-width: 1.35rem; text-align: center;
    font-weight: 500; color: var(--text-muted, #9ca3af);
    font-size: 0.6rem; line-height: 1.1; padding-bottom: 0.15rem;
}
.rc-mon { display: block; font-weight: 700; color: var(--text-strong, #374151); }
.rc-dom { display: block; }
.rc-month-start { border-left: 1px solid var(--border, #e5e7eb); }
.rc-cell {
    width: 1.35rem; min-width: 1.35rem; height: 1.35rem;
    text-align: center; vertical-align: middle; line-height: 1.35rem;
    border: 1px solid var(--surface, #fff); border-radius: 3px;
    position: relative;
}
.rc-ok { background: #d1fae5; color: #047857; font-weight: 700; }
.rc-bad { background: #fee2e2; color: #b91c1c; font-weight: 700; }
/* A day nothing ran. Deliberately visible — this is what you are looking for. */
.rc-none { background: var(--border-subtle, #f3f4f6); }
.rc-multi {
    position: absolute; right: 1px; bottom: -1px;
    font-size: 0.5rem; font-weight: 700; opacity: 0.75;
}
.rc-count {
    position: sticky; right: 0; z-index: 2; background: var(--surface, #fff);
    padding: 0 0.4rem; text-align: right; white-space: nowrap;
    font-variant-numeric: tabular-nums; font-weight: 600;
}
/* Sortable run-calendar headers. Buttons, so a drill-through on the widget
   leaves them alone — same reason the cross tab uses them. */
.rc-sort {
    background: none; border: 0; padding: 0; margin: 0; cursor: pointer;
    font: inherit; color: inherit; text-transform: inherit;
    letter-spacing: inherit; text-align: inherit;
    display: inline-flex; align-items: center; gap: 0.15rem;
}
.rc-sort:hover { color: var(--accent, #0d9488); text-decoration: underline; }
.rc-sort:focus-visible { outline: 2px solid var(--accent, #0d9488); outline-offset: 1px; }
.rc-arrow { font-size: 0.7em; color: var(--accent, #0d9488); }
/* A day the thing was never due. Distinct from a gap on purpose: drawing them
   alike made a monthly feed with a perfect record look like the worst on the
   board. */
.rc-notdue { background: transparent; }
/* Due and missed — the cell this whole widget exists to show. */
.rc-missed { background: #fecaca; box-shadow: inset 0 0 0 1px #f87171; }
/* NOT the row header. Colouring the interface's own name red because it missed
   one day in sixty-two makes the feed's identity look wrong rather than its
   record — 61/62 is a blemish, not an outage. The precise red MISSED cells and
   the count carry it instead. */
.rc-count-missed { color: #b91c1c; }


/* ── PRINT: the board as a PDF ─────────────────────────────────────────────
   No rasteriser. The browser lays the page out itself, so the grid, the sticky
   table headers, the inline SVG, the Chart.js canvases and the Leaflet map all
   come out as they look on screen — which a DOM-to-image library cannot
   promise, and which it could not do for the map at all (its tiles load without
   CORS and taint a canvas). */
.dash-print-stamp { display: none; }

@media print {
  /* Colour IS the information here — heat bands, status tiles, cross-tab
     shading. Without this the browser drops every background and a red tile
     prints identical to a green one. */
  *, *::before, *::after {
    -webkit-print-color-adjust: exact !important;
    print-color-adjust: exact !important;
  }

  @page { size: landscape; margin: 10mm; }

  /* Everything that is navigation rather than content. */
  .sidebar, .sidebar-overlay, .mobile-topbar, .dash-tabs, .page-actions,
  .dash-widget-toolbar, .dash-drag-handle, .dbx-widget-chrome, .toast-container,
  .loading-bar,
  /* The map's own controls are buttons; on paper they are decoration. The map
     itself prints — tiles, markers and legend — which is the whole reason this
     is a print stylesheet and not a DOM rasteriser. */
  .leaflet-control-container {
    display: none !important;
  }

  /* The shell is a height-locked flex frame with its own inner scrolling —
     .app-layout is height:100vh/overflow:hidden and #app-content owns the
     scroll. Left alone the PDF is exactly ONE page: everything past the first
     screenful is simply not in the flow. */
  html, body { height: auto !important; overflow: visible !important;
               margin: 0 !important; padding: 0 !important; background: #fff !important; }
  .app-layout {
    display: block !important;
    height: auto !important;
    overflow: visible !important;
  }
  /* The 210px the sidebar occupied is not the sidebar being hidden. */
  .main-content { margin-left: 0 !important; height: auto !important; overflow: visible !important; }
  #app-content {
    height: auto !important; min-height: 0 !important;
    overflow: visible !important; padding: 0 !important; display: block !important;
  }
  /* Only the ACTIVE view — unhiding the rest would print every page of the app. */
  .route-view.active {
    height: auto !important; max-height: none !important; overflow: visible !important;
    display: block !important;
  }

  .dash-print-stamp {
    display: block; margin: 0.15rem 0 0;
    font-size: 0.75rem; color: #6b7280;
  }

  /* A widget takes the height its CONTENT needs. On screen the row track is
     fixed and anything taller scrolls inside its own box — on paper that box
     cannot scroll, so a table would print truncated with nothing to say so. */
  .dash-widget, .dash-rows-1, .dash-rows-2, .dash-rows-3, .dash-rows-fit {
    height: auto !important;
    min-height: 0 !important;
    /* Never split a tile across two sheets. */
    break-inside: avoid; page-break-inside: avoid;
  }
  .dash-widget-body, .dash-widget-body > .dash-panel { height: auto !important; }
  .dash-panel > .dash-panel-body,
  .xt-scroll, .dash-table-scroll, .dash-weather-days {
    overflow: visible !important;
    max-height: none !important;
  }

  /* EXCEPT the two that draw into whatever box they are given: with an auto
     height a chart and a map collapse to nothing. */
  .dash-widget-body .dash-panel-body > .dash-widget-chart-wrap { height: 300px !important; }
  .dash-map, .leaflet-container { height: 320px !important; }

  /* Keep the five-column board a five-column board. */
  .dash-grid { display: grid !important; grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }

  /* NOT `break-after: avoid` on headings, though a heading stranded at the foot
     of a sheet is what you would reach for it to fix. These are grid items, and
     every heading asking to be kept with the next one chains into a constraint
     the layout cannot satisfy — the browser gave up and pushed almost the whole
     board off page one, which is far worse than a heading sitting low. */
  .page-title { font-size: 1.25rem; }
}
