:root {
  --bg: #eef1f5; --panel: #ffffff; --panel2: #f3f5f8; --line: #d9dee6;
  --text: #232733; --muted: #6b7280; --accent: #3b6ef5; --accent2: #7c5cff;
  --ok: #12a150; --danger: #dc2626; --warn: #d97706;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
  font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

.topbar { display: flex; align-items: center; gap: 20px; padding: 0 20px; height: 54px;
  background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 10; }
.brand { font-weight: 700; font-size: 16px; color: var(--text); flex-shrink: 0; }
.topbar nav { display: flex; align-items: center; gap: 4px; margin-right: auto; min-width: 0; }
.topbar nav a { color: var(--muted); padding: 6px 12px; border-radius: 6px; }
.topbar nav a:hover { background: var(--panel2); text-decoration: none; }
.topbar nav a.active { color: var(--text); background: var(--panel2); }
/* The current project's name, shown as a breadcrumb next to "Projects"
   instead of repeating it in a page-level <h1> on every project page —
   see project_base.html. */
.crumb-sep { color: var(--muted); flex-shrink: 0; }
.crumb-project { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--muted); }
.topbar-toggle { display: none; }
.who .email { color: var(--text); }
.logout { color: var(--muted); }

.container { max-width: 1100px; margin: 0 auto; padding: 24px 20px 60px; }
.container-wide { max-width: none; margin: 0; padding: 24px 20px 60px; }
h1 { font-size: 22px; margin: 6px 0 12px; }
h2 { font-size: 16px; margin: 22px 0 10px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }

.role { text-transform: uppercase; font-size: 10px; font-weight: 700; letter-spacing: .5px;
  padding: 2px 7px; border-radius: 10px; }
.role-admin { background: rgba(124,92,255,.14); color: #6d4bd6; }
.role-reviewer { background: rgba(59,110,245,.12); color: var(--accent); }

.flash { padding: 10px 14px; border-radius: 8px; margin: 10px 0; }
.flash.ok { background: rgba(18,161,80,.12); color: #0f7a3d; border: 1px solid rgba(18,161,80,.28); }
.flash.err { background: rgba(220,38,38,.1); color: #b42318; border: 1px solid rgba(220,38,38,.28); }
.flash.warn { background: rgba(217,119,6,.12); color: #92400e; border: 1px solid rgba(217,119,6,.28); }

table { width: 100%; border-collapse: collapse; margin: 8px 0; }
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; }
tr.dim { opacity: .55; }

.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; margin: 12px 0;
  box-shadow: 0 1px 2px rgba(16,24,40,.04); }
.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 12px 0; }
.stat-card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px;
  box-shadow: 0 1px 2px rgba(16,24,40,.04); }
.stat-card .stat-value { font-size: 26px; font-weight: 700; line-height: 1.2; }
.stat-card .stat-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; margin-top: 4px; }
.row-form { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 10px; }
.row-form .grow { flex: 1; min-width: 220px; }
.row-form label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }

/* Required-field marker. On a form where most fields are optional, which
   four are not has to be visible before someone types, not discovered by a
   browser tooltip on submit — see _student_add_block.html. */
.req::after { content: " *"; color: var(--danger); font-weight: 700; }
.req-note { font-size: 12px; color: var(--muted); }
.req-note::before { content: "*"; color: var(--danger); font-weight: 700; margin-right: 4px; }

/* The Total line on the district report — first body row, not a footer, so
   it stays visible without scrolling a 38-district table. */
.school-wise tr.sw-total td { --cell-bg: var(--panel2); background: var(--panel2); border-bottom: 2px solid var(--line); }

.form-section { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px;
  padding: 16px 18px; margin: 14px 0; }
.form-section + .form-section { margin-top: 14px; }
.form-section .row-form:last-child { margin-bottom: 0; }
.form-section-title { font-size: 14px; font-weight: 700; color: var(--text); margin: 0 0 12px;
  display: flex; align-items: center; gap: 8px; }
.form-section-title .muted { font-weight: 400; font-size: 12px; }

.chip-check:has(input:checked) { background: rgba(59,110,245,.12); border-color: var(--accent); color: var(--accent); font-weight: 600; }
.task-card { border-left: 3px solid var(--accent); }

.task-select-list { max-height: 260px; overflow-y: auto; border: 1px solid var(--line); border-radius: 7px;
  background: var(--panel); margin-top: 4px; }
.task-select-row { display: flex; align-items: center; gap: 8px; padding: 9px 12px; margin: 0;
  font-size: 14px; cursor: pointer; border-bottom: 1px solid var(--line); }
.task-select-row:last-child { border-bottom: none; }
.task-select-row:hover { background: var(--panel2); }
.task-select-row:has(input:checked) { background: rgba(59,110,245,.1); }
.task-select-row input { width: auto; flex-shrink: 0; }

.combo { position: relative; }
.combo .task-select-list { display: none; position: absolute; top: 100%; left: 0; right: 0; z-index: 30;
  box-shadow: 0 8px 24px rgba(0,0,0,.15); }
.combo .task-select-list.combo-open { display: block; }

input, select { width: 100%; background: var(--panel); border: 1px solid var(--line); color: var(--text);
  padding: 8px 10px; border-radius: 7px; font-size: 14px; }
input:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(59,110,245,.12); }
input[type="checkbox"], input[type="radio"] { width: auto; padding: 0; }

.choice-cell { padding: 0 !important; }
.choice-cell label { display: flex; align-items: center; gap: 8px; padding: 10px; margin: 0;
  cursor: pointer; border-radius: 6px; }
.choice-cell label:hover { background: var(--panel2); }
.choice-cell label:has(input:checked) { background: rgba(59,110,245,.12); }
.select-all-link { font-size: 11px; font-weight: 600; text-transform: none; letter-spacing: normal;
  cursor: pointer; color: var(--accent); margin-left: 6px; }
.select-all-link:hover { text-decoration: underline; }
.cta-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
/* A row of actions that stays on one line and scrolls rather than wrapping
   — see the calling grid, where wrapping cost a second row above the table
   that is the whole point of the page. app.js fades whichever edge has
   something past it. */
.action-row { display: flex; flex-wrap: nowrap; overflow-x: auto; align-items: center; gap: 8px;
  margin-bottom: 8px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.action-row::-webkit-scrollbar { display: none; }
.action-row > * { flex-shrink: 0; }

/* A guest's campaign page has no sidenav (see project_base.html), so the
   layout collapses to a single full-width column instead of leaving the
   sidebar's empty track. */
.project-layout-bare { display: block; }

/* Phone number in the calling grid: a button, not a bare number, because
   tapping it offers Call / WhatsApp / Add to contacts rather than only the
   browser's own dial action. Styled as a link so the grid still reads as
   data. See openPhoneMenu in app.js. */
.phone-btn { background: none; border: none; padding: 0; font: inherit; color: var(--accent);
  cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
/* The dial button a campaign shows instead of the parent's number when it
   goes through mCube (CallCampaign.mcube_enabled). Deliberately a real
   button rather than the link-styled .phone-btn above: it performs an action
   with a side effect — it makes a phone ring — instead of opening a menu.
   See mcubeDial in app.js. */
.mcube-btn { font: inherit; font-size: 12.5px; padding: 3px 10px; cursor: pointer;
  color: var(--accent); background: var(--panel); border: 1px solid var(--accent);
  border-radius: 6px; white-space: nowrap; }
.mcube-btn:hover:not(:disabled) { background: var(--panel2); }
.mcube-btn:disabled { color: var(--muted); border-color: var(--line); cursor: not-allowed; }
/* Held for a few seconds after a dial is accepted, then reverted — a caller
   whose phone did not ring has to be able to press again. */
.mcube-btn.save-ok { color: #0f7a3d; border-color: var(--ok); background: rgba(18,161,80,.08); }

.phone-menu { position: fixed; z-index: 60; display: flex; flex-direction: column; padding: 4px;
  min-width: 170px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
  box-shadow: 0 8px 24px rgba(16,24,40,.18); }
.phone-menu a { padding: 11px 12px; border-radius: 6px; color: var(--text); font-size: 14px; }
.phone-menu a:hover { background: var(--panel2); text-decoration: none; }

.status-dot { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); white-space: nowrap; }
.status-dot::before { content: ""; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--muted); }
.status-dot.ok { color: #0f7a3d; }
.status-dot.ok::before { background: var(--ok); }
.status-dot.pending { color: #92400e; }
.status-dot.pending::before { background: var(--warn); }
.status-dot.bad { color: #b42318; }
.status-dot.bad::before { background: var(--danger); }

.chip { display: inline-block; background: var(--panel2); border: 1px solid var(--line);
  padding: 1px 8px; border-radius: 10px; font-size: 12px; margin: 1px 3px 1px 0; }
.chip-check { display: inline-flex; align-items: center; gap: 6px; background: var(--panel2);
  border: 1px solid var(--line); padding: 6px 10px; border-radius: 8px; font-size: 13px; width: auto; }
.chip-check input { width: auto; }
.thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; border: 1px solid var(--line); }
.qcell { max-width: 220px; }
.meta-pre { white-space: pre-wrap; max-height: 200px; overflow: auto; font-size: 11px;
  margin: 6px 0 0; padding: 6px 8px; background: var(--panel2); border-radius: 6px; }
/* Caps a multi-line table cell's own height instead of letting long content
   (many contacts, many resolved course ids) stretch the whole row — applying
   overflow directly on a <td> doesn't reliably clip in every browser, so this
   wraps the cell's content in a block-level element to constrain instead. */
.cell-scroll { display: block; max-height: 72px; overflow-y: auto; white-space: pre-line; }
/* Same multi-line rendering as .cell-scroll but no height cap — the row
   just grows to fit instead of scrolling internally. */
.cell-wrap { white-space: pre-line; }

.pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; }
.pill-ok { background: rgba(18,161,80,.15); color: #0f7a3d; }
.pill-off { background: rgba(220,38,38,.14); color: #b42318; }
.pill-pending { background: rgba(217,119,6,.15); color: #92400e; }

/* Inline loading indicator for an ajax form's submit button — see
   setFormLoading in app.js. Sized off font-size/em so it drops into a
   .pill button (11px) or a normal .btn (14px) without extra tuning, and
   currentColor picks up whatever text color the button already has. */
.btn-spinner {
  display: inline-block; width: 1em; height: 1em; vertical-align: -0.15em;
  border: 2px solid currentColor; border-right-color: transparent;
  border-radius: 50%; opacity: 0.6; animation: btn-spin 0.6s linear infinite;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .btn-spinner { animation-duration: 1.5s; }
}

.list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
/* A summed row that sits at the top of a table rather than the bottom, so
   it stays on screen while the rows below scroll. */
tr.row-total > td { background: var(--panel2); border-bottom: 2px solid var(--line); }

/* Attendance and school-wise grids. Column freezing is the shared enhanced
   table's job (data-enh-freeze-cols), not hand-rolled sticky rules — two
   mechanisms fighting over `position: sticky` on the same cells is how a
   frozen column ends up scrolling out from under its own header. What is
   left here is cell colour and density. */
.attendance td, .attendance th { padding: 4px 8px; font-size: 13px; white-space: nowrap; }
.attendance td { text-align: center; }
.attendance .att-email { text-align: left; }
/* A table being re-fetched after a sort/filter/page click (see the
   .table-link handler). The rows stay readable and stay put; what changes is
   that they are visibly stale and no longer clickable, plus a progress bar
   along the top edge so a slow round trip looks like waiting rather than
   like nothing happening. */
.table-swapping { position: relative; }
.table-swapping > * { opacity: .45; pointer-events: none; }
.table-swapping::before {
  content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; z-index: 3;
  background: linear-gradient(90deg, transparent, var(--accent), transparent);
  background-size: 40% 100%; background-repeat: no-repeat;
  animation: table-swap-bar 1s linear infinite;
}
@keyframes table-swap-bar {
  from { background-position: -40% 0; }
  to   { background-position: 140% 0; }
}
/* Reduced motion keeps the signal and drops the movement — a static bar
   still says "this is loading". */
@media (prefers-reduced-motion: reduce) {
  .table-swapping::before { animation: none; background-size: 100% 100%; opacity: .5; }
}

/* Graded colour on a numeric column — see applyScale in app.js, which sets
   --enh-scale to 0..1 per cell. Two shapes: a bar for comparing magnitudes
   down a column, a tint for finding the extremes. The number stays fully
   legible in both, so colour is a second signal and never the only one. */
.enh-table td.enh-scale-bar { position: relative; }
.enh-table td.enh-scale-bar::after {
  content: ""; position: absolute; left: 0; bottom: 0; height: 4px; z-index: 0;
  width: calc(var(--enh-scale, 0) * 100%);
  background: hsl(188 62% 44%); opacity: .75;
}
/* Through --cell-bg so the enhanced-table base rule resolves to it — the
   convention the att-* colours use.
 *
 * Teal, deliberately not the interface blue: the accent is already the link,
 * the sort arrow and the selected-column highlight, so a data colour that
 * matches the chrome reads as UI rather than as a value. It is also clear of
 * the semantic cell colours (red pending, amber leave, grey off), so a
 * tinted cell can never be mistaken for one of those.
 *
 * Saturation and lightness both ramp, over a wide range. The first version
 * moved lightness alone across 32 points, which sounds ample until the data
 * lands in one part of a fixed 0-100 domain: Installed % runs 14.8 to 42.3
 * on Bihar, used 8.8 of those 32, and looked flat. This spans 64 points of
 * lightness and 48 of saturation, so the same data gets 17.6 — and a column
 * sitting at 90%+ is now plainly dark where one at 15% is plainly pale,
 * which is most of what a fixed domain is for. */
.enh-table td.enh-scale-heat {
  --cell-bg: hsl(188 calc(28% + var(--enh-scale, 0) * 48%)
                     calc(96% - var(--enh-scale, 0) * 64%));
  background: var(--cell-bg);
}
/* Past the middle of the ramp the tint is dark enough that the body colour
   fails against it. Applied from JS, because only there is the fraction
   known. */
.enh-table td.enh-scale-heat.enh-scale-ink,
.enh-table td.enh-scale-heat.enh-scale-ink * { color: #fff; }

/* The toolbar's own swatches for the two scale modes. */
.enh-tool-note { font-size: 10px; color: var(--muted); max-width: 150px; line-height: 1.25; margin-top: 2px; }
.enh-sw-bar { background: linear-gradient(90deg, hsl(188 62% 44%) 55%, transparent 55%); }
.enh-sw-heat { background: linear-gradient(90deg, hsl(188 28% 96%), hsl(188 76% 32%)); }

/* Semantic cell colours. Each sets --cell-bg as well as its own background:
   the plain background paints the cell before the enhanced-table JS runs, and
   --cell-bg is what keeps it painted afterwards.

   .enh-table th, .enh-table td sets a background of its own (so a frozen
   column does not show the table through), and at (0,1,1) that out-specifies
   a single class — so the moment initEnhancedTable added .enh-table, every
   one of these went flat. It showed as the attendance grid and the by-school
   Pending count flashing their colours on load and then losing them, which
   is the whole colour coding of that report. Reading the value through a
   custom property takes specificity out of it: the base rule resolves
   whatever the cell itself declares. A manual fill (.enh-fill-*) still wins,
   which is the right precedence — someone choosing a colour beats one the
   report assigned. */
.att-pending { --cell-bg: #fadbd8; background: #fadbd8; }
.att-off { --cell-bg: #e6e6e6; background: #e6e6e6; color: #666; }
.att-leave { --cell-bg: #fdf3d0; background: #fdf3d0; }
/* Before a coordinator started. Deliberately quieter than .att-off: a
   Sunday is a day nobody worked, this is a day nobody was asked to. */
.att-na { --cell-bg: #f4f5f7; background: #f4f5f7; color: #9aa1ab; }
.att-key { padding: 1px 6px; border-radius: 4px; font-size: 11px; }
.school-wise .sw-school { min-width: 220px; }
.school-wise .sw-list { white-space: normal; min-width: 170px; font-size: 12px; }
/* The From/To window above a report's table. Centre-aligned, not
   baseline/end: a date input and a button have different intrinsic
   heights, so anything but centre leaves the buttons sitting proud. */
.report-window { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0; }
.report-window input { width: auto; }
.report-window label { margin: 0; }
/* Colour key for the attendance grid, below its table rather than inside
   the shared table toolbar — the toolbar's other controls all DO
   something, so parking three inert swatches among them read as buttons
   that were broken rather than as a legend. */
.att-legend { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; }

/* Adoption's funnel. Steps sit in one row and narrow left to right, so the
   drop between two of them is visible as shape before anyone reads a number. */
.funnel { display: flex; gap: 8px; align-items: stretch; flex-wrap: wrap; margin: 8px 0 4px; }
.funnel-step { flex: 1 1 150px; background: var(--panel); border: 1px solid var(--line);
  border-radius: 8px; padding: 11px 13px; border-left: 3px solid var(--accent); }
.funnel-step:nth-child(2) { border-left-color: #5b86f0; }
.funnel-step:nth-child(3) { border-left-color: var(--warn); }
.funnel-step:nth-child(4) { border-left-color: #d2691e; }
.funnel-step:nth-child(5) { border-left-color: var(--ok); }
.funnel-l { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
.funnel-v { font-size: 24px; font-weight: 700; line-height: 1.25; font-variant-numeric: tabular-nums; }
.funnel-note { margin-top: 2px; opacity: .75; }

.chart-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
.chart-sub { margin: 0 0 6px; font-size: 13px; color: var(--muted); font-weight: 600; }
/* Chart.js sizes a responsive canvas to its container, so the container is
   what has to have a height — and JS sets it per chart from the bar count.
   position:relative is what Chart.js measures against. */
.chart-box { position: relative; width: 100%; }

@media (max-width: 720px) {
  /* One chart per row below the breakpoint the rest of the app already uses:
     two 320px columns do not fit a phone, and a squeezed bar chart is worse
     than a scrolled one. */
  .chart-grid { grid-template-columns: 1fr; gap: 10px; }
  /* Five funnel steps two-up rather than five-up: 150px each across a phone
     leaves no room for a seven-figure number and its two percentages. */
  .funnel-step { flex: 1 1 132px; padding: 9px 11px; }
  .funnel-v { font-size: 20px; }
  .funnel-note { display: none; }
}

/* Column formatting from the table toolbar: fill, text colour, alignment
   (per column) and text size (whole table). Saved per user in table prefs.
   Each token defines --enh-fill/--enh-ink once and both the swatch in the
   toolbar and the cells in the table read it, so the button can never show
   a different colour from the column it paints.
   Fills are pale on purpose: a column still has to be readable with the
   app's normal text on top of it, and it sits next to five others. */
.enh-fill-1 { --enh-fill: #fff3c4; }   /* yellow */
.enh-fill-2 { --enh-fill: #d7f2e3; }   /* green  */
.enh-fill-3 { --enh-fill: #fde2e1; }   /* red    */
.enh-fill-4 { --enh-fill: #dce7fd; }   /* blue   */
.enh-fill-5 { --enh-fill: #e9e2fd; }   /* purple */
.enh-fill-6 { --enh-fill: #e6e9ef; }   /* grey   */
.enh-ink-1 { --enh-ink: #b42318; }
.enh-ink-2 { --enh-ink: #0f7a3d; }
.enh-ink-3 { --enh-ink: #2b52c9; }
.enh-ink-4 { --enh-ink: #6b7280; }

/* Two classes deep so this beats `.enh-table th, .enh-table td`, which sets
   a background of its own so frozen columns do not show the table through. */
.enh-table td.enh-fill-1, .enh-table th.enh-fill-1,
.enh-table td.enh-fill-2, .enh-table th.enh-fill-2,
.enh-table td.enh-fill-3, .enh-table th.enh-fill-3,
.enh-table td.enh-fill-4, .enh-table th.enh-fill-4,
.enh-table td.enh-fill-5, .enh-table th.enh-fill-5,
.enh-table td.enh-fill-6, .enh-table th.enh-fill-6 { background: var(--enh-fill); }
.enh-table td.enh-ink-1, .enh-table th.enh-ink-1,
.enh-table td.enh-ink-2, .enh-table th.enh-ink-2,
.enh-table td.enh-ink-3, .enh-table th.enh-ink-3,
.enh-table td.enh-ink-4, .enh-table th.enh-ink-4 { color: var(--enh-ink); }
.enh-table td.enh-ink-1 a, .enh-table td.enh-ink-2 a,
.enh-table td.enh-ink-3 a, .enh-table td.enh-ink-4 a { color: inherit; }

.enh-table td.enh-align-l, .enh-table th.enh-align-l { text-align: left; }
.enh-table td.enh-align-c, .enh-table th.enh-align-c { text-align: center; }
.enh-table td.enh-align-r, .enh-table th.enh-align-r { text-align: right; }

.enh-table.enh-text-sm td, .enh-table.enh-text-sm th { font-size: 12px; padding: 6px 8px; }
.enh-table.enh-text-lg td, .enh-table.enh-text-lg th { font-size: 16px; padding: 11px 12px; }

/* Toolbar controls */
.enh-swatches, .enh-seg { display: flex; gap: 3px; align-items: center; }
.enh-sw { width: 17px; height: 17px; border: 1px solid var(--line); border-radius: 3px;
          padding: 0; cursor: pointer; background: var(--enh-fill, #fff); }
.enh-sw.enh-ink-1, .enh-sw.enh-ink-2, .enh-sw.enh-ink-3, .enh-sw.enh-ink-4 {
  background: var(--enh-ink); }
/* "None" reads as a cleared cell, not as another colour to choose. */
.enh-sw-none { background: linear-gradient(to top left, transparent calc(50% - 1px),
               var(--danger) calc(50% - 1px), var(--danger) calc(50% + 1px),
               transparent calc(50% + 1px)) #fff; }
.enh-sw.on, .enh-seg button.on { outline: 2px solid var(--accent); outline-offset: 1px; }
.enh-seg button { min-width: 22px; height: 19px; padding: 0 5px; font-size: 11px; font-weight: 600;
                  border: 1px solid var(--line); border-radius: 3px; background: var(--panel);
                  color: var(--muted); cursor: pointer; }
.enh-swatches[aria-disabled="true"], .enh-seg[aria-disabled="true"] { opacity: .4; pointer-events: none; }

/* Instant tutoring's "Today" view sits beside Weekly and Daily but is not
   the same kind of thing — it is read off the replica on demand, where the
   other two come from the synced window. The dot says so without a word. */
.chip-live::before { content: ""; display: inline-block; width: 6px; height: 6px;
  border-radius: 50%; background: var(--ok); margin-right: 5px; vertical-align: middle; }

/* Export menu in the table toolbar.
   The trigger is styled as toolbar text, not as a .btn: it sits beside
   "Table options", which is a plain muted label, and a filled/bordered
   button next to that reads as the one important thing on a toolbar of
   equals.

   Position is fixed, and the menu is moved to <body> before it opens (see
   toggleExportMenu). Both are needed, for different reasons: overflow-x on
   the toolbar clips an absolutely positioned child, and the .edge-fade-*
   mask below clips and re-parents even a fixed one. The second is what kept
   putting this menu behind the table after the first was fixed. */
.enh-export { position: relative; }
.enh-text-btn { background: none; border: none; padding: 4px 6px; border-radius: 6px;
  font: inherit; font-size: 12px; color: var(--muted); cursor: pointer; white-space: nowrap; }
.enh-text-btn:hover { background: var(--panel2); color: var(--text); }
.enh-text-btn[aria-expanded="true"] { background: var(--panel2); color: var(--text); }
.enh-caret { font-size: 9px; }
.export-menu { position: fixed; z-index: 60;
  display: flex; flex-direction: column; min-width: 190px; padding: 4px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 9px;
  box-shadow: 0 8px 24px rgba(16,24,40,.18); }
.export-menu[hidden] { display: none; }
.export-menu a, .export-menu button { display: block; width: 100%; text-align: left;
  padding: 9px 11px; border: none; background: none; border-radius: 6px;
  font: inherit; font-size: 13px; color: var(--text); cursor: pointer; }
.export-menu a:hover, .export-menu button:hover { background: var(--panel2); text-decoration: none; }
.export-menu button:disabled { color: var(--muted); cursor: default; }
.filters { display: flex; gap: 8px; align-items: center; margin: 8px 0 16px; }
.filters input { width: 260px; }

.btn { background: var(--accent); color: #fff; border: none; padding: 8px 14px; border-radius: 7px;
  font-weight: 600; cursor: pointer; font-size: 14px; display: inline-block; }
.btn:hover { filter: brightness(1.06); text-decoration: none; }
.btn-sm { padding: 5px 10px; font-size: 13px; }
.btn-light { background: var(--panel2); color: var(--text); border: 1px solid var(--line); }
.btn-danger { background: var(--danger); color: #fff; }
.inline-form { display: inline-block; }

/* App-themed confirm dialog — replaces window.confirm() (see appConfirm
   in app.js). Built once and reused, hidden via display:none/flex rather
   than added/removed from the DOM. */
.app-confirm-overlay { display: none; position: fixed; inset: 0; background: rgba(15,18,25,.45);
  align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.app-confirm-box { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
  padding: 20px 22px; max-width: 420px; width: 100%; box-shadow: 0 12px 32px rgba(16,24,40,.18); }
.app-confirm-message { margin: 0 0 18px; font-size: 14px; line-height: 1.5; color: var(--text); white-space: pre-line; }
.app-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.add-toggle { cursor: pointer; display: inline-block; background: var(--panel2); color: var(--text);
  border: 1px solid var(--line); border-radius: 7px; padding: 5px 10px; font-size: 13px; font-weight: 600;
  margin: 8px 0; list-style: none; }
.add-toggle::-webkit-details-marker { display: none; }
.add-toggle:hover { filter: brightness(1.06); }
details[open] > .add-toggle { border-radius: 7px 7px 0 0; margin-bottom: 0; }
.section-toggle { cursor: pointer; list-style: none; font-size: 16px; font-weight: 700; margin: 22px 0 10px;
  display: flex; align-items: center; gap: 8px; color: var(--text); }
.section-toggle::-webkit-details-marker { display: none; }
.section-toggle::before { content: '▸'; display: inline-block; font-size: 12px; color: var(--muted);
  transition: transform .1s; }
details[open] > .section-toggle::before { transform: rotate(90deg); }
.link-btn { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 0 4px; }

/* A collapsible section heading — see the collapsible-sections block in
   app.js. The caret is on the heading rather than a separate control so the
   whole title is the hit area. */
h3.section-head { cursor: pointer; display: flex; align-items: center; gap: 9px; user-select: none; }
h3.section-head::before { content: '▾'; font-size: 11px; color: var(--muted); transition: transform .12s; }
h3.section-head.collapsed::before { transform: rotate(-90deg); }
h3.section-head:hover { color: var(--accent); }
h3.section-head:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
@media (prefers-reduced-motion: reduce) { h3.section-head::before { transition: none; } }
.link-btn:hover { text-decoration: underline; }
.config-box { width: 100%; font-family: ui-monospace, monospace; font-size: 12.5px; resize: vertical; }

.project-layout { display: flex; gap: 28px; align-items: flex-start; }
.project-sidenav { width: 180px; flex-shrink: 0; display: flex; flex-direction: column; gap: 2px;
  position: sticky; top: 74px; align-self: flex-start; transition: width .12s; }
.project-sidenav a { padding: 7px 10px; border-radius: 6px; color: var(--text); font-size: 13.5px; }
.project-sidenav a:hover { background: var(--panel2); text-decoration: none; }
.project-sidenav a.active { background: var(--panel2); font-weight: 600; color: var(--accent); }
.project-sidenav .sub { display: flex; flex-direction: column; gap: 2px; margin: 2px 0 2px 10px;
  padding-left: 10px; border-left: 2px solid var(--line); }
.project-sidenav .sub a { font-size: 13px; padding: 5px 10px; color: var(--muted); }
.project-content { flex: 1; min-width: 0; }
/* Collapse toggle lives inside the sidenav itself so it stays visible (and
   in the same place) whether expanded or collapsed — see toggleSidenav()
   in app.js. Collapsed state hides the links/labels and shrinks the rail
   down to just the toggle button; .project-content's flex:1 reclaims the
   freed width automatically. */
.sidenav-toggle { align-self: flex-end; background: none; border: 1px solid var(--line); border-radius: 6px;
  width: 26px; height: 26px; cursor: pointer; color: var(--muted); font-size: 13px; margin-bottom: 4px; flex-shrink: 0; }
.sidenav-toggle:hover { background: var(--panel2); color: var(--text); }
/* The glyph follows the state class rather than being written by JS, so
   it is correct on first paint instead of after app.js runs. */
.sidenav-toggle::before { content: '\00AB'; }
.project-sidenav.collapsed .sidenav-toggle::before { content: '\00BB'; }
.sidenav-toggle-label { display: none; }
.project-sidenav.collapsed { width: 34px; }
.project-sidenav.collapsed a, .project-sidenav.collapsed .sub { display: none; }
.project-sidenav.collapsed .sidenav-toggle { align-self: center; }
@media (max-width: 720px) {
  /* At 390px the brand, nav, email and logout cannot share one 54px row:
     the flex row crushed .crumb-project down to 24px, so the project name
     — the single most useful piece of context on a phone — was an
     unreadable sliver overlapping the email. Let the bar wrap to as many
     rows as it needs and drop the email (Logout still ends the session),
     which costs ~40px and makes nothing unreachable. */
  /* The bar carries brand, up to six nav links, email and logout. At 390px
     that wrapped to four rows for a platform admin (137px of chrome above
     every page), and before that it crushed .crumb-project to 24px — the
     project name, the context that matters most on a phone, unreadable.
     So on a phone the bar shows only where you are: the one .active link,
     which is the project crumb on a project page and "Jobs"/"Perf" on those.
     Everything else is one tap away behind the ⋯ button, which is why the
     links can be hidden rather than dropped. The email goes for good;
     Logout still ends the session. */
  .topbar { height: auto; min-height: 54px; flex-wrap: wrap; gap: 4px 12px; padding: 8px 14px; }
  .topbar nav { flex-wrap: wrap; }
  .who .email { display: none; }
  .topbar-toggle { display: inline-flex; align-items: center; justify-content: center;
    width: 30px; height: 30px; flex-shrink: 0; background: none; color: var(--muted);
    border: 1px solid var(--line); border-radius: 6px; font-size: 15px; line-height: 1; cursor: pointer; }
  .topbar:not(.topbar-open) nav a:not(.active) { display: none; }
  .topbar:not(.topbar-open) .crumb-sep { display: none; }
  .crumb-project { max-width: 48vw; }
  /* Expanded, the full list takes its own line under the brand rather than
     wrapping around it, so it reads as a list instead of a jumble. */
  .topbar.topbar-open nav { flex-basis: 100%; margin-right: 0; }
  .topbar.topbar-open .topbar-toggle { background: var(--panel2); color: var(--text); }


  .project-layout { flex-direction: column; gap: 12px; }

  /* A column, not a wrapping row. Wrapped rows put a block-level .sub
     mid-line, so "Reports" rendered *after* its own children and Field
     reports' submenu interleaved with "Services" and "Members". A column
     keeps every child under its parent. */
  .project-sidenav { width: 100%; flex-direction: column; position: static; }

  /* Collapsed is the default on a phone, and it is expressed here rather
     than by a class JS adds on load — expanded, this menu is 430px of the
     844px screen, and letting that paint before snapping shut is worse
     than not having fixed it. .sidenav-open is what toggleSidenav()
     switches; .collapsed stays desktop-only. */
  .project-sidenav:not(.sidenav-open) a,
  .project-sidenav:not(.sidenav-open) .sub { display: none; }

  /* Collapsed here is a full-width bar naming the section you are in, not
     the 34px desktop rail — when it is the only nav on screen, a bare
     chevron tells you nothing about where you are, and there is room. */
  .project-sidenav .sidenav-toggle {
    align-self: stretch; width: 100%; height: auto; min-height: 44px;
    display: flex; align-items: center; gap: 10px; padding: 0 14px;
    background: var(--panel); border-radius: 8px; margin-bottom: 0;
    font-size: 14px; font-weight: 600; color: var(--text); text-align: left; }
  .project-sidenav.sidenav-open .sidenav-toggle { margin-bottom: 6px; }
  .project-sidenav .sidenav-toggle::before { content: '\2630'; font-size: 15px; color: var(--muted); }
  .project-sidenav .sidenav-toggle::after { content: '\25BE'; margin-left: auto; font-size: 11px; color: var(--muted); }
  .project-sidenav.sidenav-open .sidenav-toggle::after { content: '\25B4'; }
  .sidenav-toggle-label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  /* Finger-sized rows once the menu is open. */
  .project-sidenav a { padding: 10px 12px; font-size: 14px; }
  .project-sidenav .sub a { padding: 8px 12px; font-size: 13.5px; }

  /* .filters and .list-head are non-wrapping flex rows, and .filters input
     carries a fixed 260px width — so their min-content width is the sum of
     their children and they refuse to shrink. Since .project-layout is a
     *column* flex container here, that min-content width becomes the width
     of .project-content: measured at 1050px inside a 350px parent on the
     Students list, 1235px on Administration.
     Everything else on the page then inherits it, including .tab-nav —
     which has flex-wrap:wrap and would wrap perfectly well, but never gets
     the chance because its parent is 1050px wide, leaving 3 of 6 tabs off
     the right edge of a 390px screen. Constrain the content column and let
     these two rows wrap, and the page comes back to the viewport. */
  .project-content { min-width: 0; max-width: 100%; }
  .filters, .list-head { flex-wrap: wrap; }
  .filters input { width: 100%; }
}

/* Horizontal tab strip — see _tab_nav.html. */
.tab-nav { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--line); margin-bottom: 16px; }
.tab-nav a { padding: 9px 14px; color: var(--muted); font-size: 13.5px; border-bottom: 2px solid transparent;
  margin-bottom: -1px; }
.tab-nav a:hover { color: var(--text); text-decoration: none; }
.tab-nav a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }

/* Mobile tab strips stay on one line and scroll. Six tabs carrying counts
   ("File attachments (3812)") wrapped to three rows on a 390px screen and
   pushed the table that far down the page; sideways scrolling inside the
   strip costs one row instead. app.js centres the active tab on load, so
   the strip never opens showing only tabs you are not on.
   This block must live *after* the .tab-nav rules above, not up with the
   rest of the mobile CSS: same specificity, and the later rule wins — put
   it earlier and .tab-nav's own flex-wrap:wrap silently cancels it. */
@media (max-width: 720px) {
  .tab-nav { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
  .tab-nav::-webkit-scrollbar { display: none; }
  .tab-nav a { flex-shrink: 0; }
  /* Softens the cut edges so a tab sliced mid-word ("...hool/grade") reads
     as "there is more this way" rather than as a rendering fault. Applied
     only to strips app.js found to actually overflow, so a short strip
     keeps its first and last tabs at full contrast. */
}
.btn-dev { background: var(--accent2); color: #fff; }

.login-card { max-width: 340px; margin: 12vh auto; text-align: center; background: var(--panel);
  border: 1px solid var(--line); border-radius: 14px; padding: 32px 28px; box-shadow: 0 4px 20px rgba(16,24,40,.08); }
.login-card h1 { font-size: 26px; }
.login-card .btn { display: block; width: 100%; margin: 10px 0; padding: 11px; }

/* Click-to-edit fields: click the value, an input replaces it in place,
   Enter/blur saves, Escape cancels. See toggleInlineEdit's cousins in app.js
   (startFieldEdit/saveField) and school_detail.html for usage. */
.ed-field { border-bottom: 1px dashed var(--line); cursor: text; padding: 0 1px; }
.ed-field:hover { background: var(--panel2); border-bottom-color: var(--accent); }
.ed-field input, .ed-field select { display: inline-block; width: auto; min-width: 70px;
  padding: 2px 5px; font-size: inherit; border-radius: 4px; }

.compact-list { display: flex; flex-direction: column; }
.compact-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
.compact-row:last-child { border-bottom: none; }
.compact-row .col { min-width: 0; }
.compact-row .name-col { flex: 2; min-width: 90px; font-weight: 500; }
.compact-row .field-col { flex: 1; min-width: 60px; color: var(--muted); }
.compact-row .spacer { flex: 1; }

.icon-btn { background: none; border: none; cursor: pointer; font-size: 14px; line-height: 1;
  padding: 4px 6px; border-radius: 5px; color: var(--muted); }
.icon-btn:hover { background: rgba(220,38,38,.12); color: var(--danger); }

.bulk-add-row { display: flex; gap: 8px; align-items: center; padding: 4px 0; }
.bulk-add-row input { padding: 5px 8px; font-size: 13px; }
.bulk-add-row .col { min-width: 0; }
.bulk-add-actions { display: flex; gap: 8px; margin-top: 6px; }

/* Sheet-style column filter dropdown (Excel/Sheets-style autofilter) — see
   toggleColumnFilter/applyColumnFilter in app.js and
   _report_school_data_grade_table.html for usage. */
.col-filter-btn { background: none; border: none; cursor: pointer; color: var(--muted);
  font-size: 11px; padding: 2px 3px; margin-left: 2px; border-radius: 3px; }
.col-filter-btn:hover { background: var(--panel2); }
/* A filtered column needs to read at a glance across a wide table, not
   just on close inspection of its tiny ▾ icon — so the whole header cell
   gets a tint too, not just the button. */
th.col-filtered { background: rgba(59,110,245,.12); }
.col-filter-btn.filtered { color: #fff; font-weight: 700; background: var(--accent); border-radius: 3px; }
.col-filter-btn.filtered:hover { background: var(--accent2); }
.col-filter-panel { position: absolute; top: 100%; left: 0; z-index: 20; background: var(--panel);
  border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 6px 20px rgba(16,24,40,.14);
  padding: 8px; min-width: 160px; max-width: 260px; text-transform: none; font-weight: 400; }
.col-filter-search { width: 100%; box-sizing: border-box; font-size: 12.5px; padding: 4px 6px; margin-bottom: 6px; }
.col-filter-actions { display: flex; gap: 10px; margin-bottom: 6px; }
.col-filter-options { max-height: 220px; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; }
.col-filter-option { display: flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 400;
  color: var(--text); cursor: pointer; }
.col-filter-option input { width: auto; }
.col-filter-apply { display: flex; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); }
/* On-demand options: the panel opens before its list exists. See
   loadColumnOptions in app.js. */
.col-filter-loading { display: flex; align-items: center; gap: 7px; font-size: 12.5px;
  color: var(--muted); padding: 6px 2px; font-weight: 400; }
.col-filter-note { font-size: 11.5px; color: var(--muted); font-weight: 400;
  padding: 6px 2px 2px; line-height: 1.45; }
/* The list is fetched, so it can be empty for a moment — hold the height to
   stop the Apply row jumping up and back down under the pointer. */
.col-filter-options { min-height: 34px; }

/* Column "bands": the group header row (e.g. "Sessions scheduled", "Grade
   9") only reads as a boundary if the leaf columns under it are visibly
   tied to it — these tint both the group cell and its matching leaf
   header cells the same color, with a colored bottom border capping the
   group row so the two-row header reads as one unit. See column_header's
   `band` param in _sheet_filter_macros.html and each flattened report
   table for which band goes where. */
thead tr:first-child .col-band-a { border-bottom: 2px solid var(--accent); }
thead tr:first-child .col-band-b { border-bottom: 2px solid var(--accent2); }
thead tr:first-child .col-band-c { border-bottom: 2px solid var(--ok); }
thead tr:first-child .col-band-d { border-bottom: 2px solid var(--warn); }
.col-band-a { background: rgba(59,110,245,.07); }
.col-band-b { background: rgba(124,92,255,.07); }
.col-band-c { background: rgba(18,161,80,.07); }
.col-band-d { background: rgba(217,119,6,.07); }

/* Group-collapse toolbar, sits above the table — see toggleColumnGroup in
   app.js and the group_toolbar/colgroup macros in _sheet_filter_macros.html.
   Deliberately outside the table: a toggle placed inside a group's own
   header cell disappears along with it once collapsed. */
.col-group-toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 8px; }
.col-toggle-chip { background: var(--panel2); border: 1px solid var(--line); cursor: pointer;
  color: var(--text); font-size: 12px; padding: 3px 9px; border-radius: 12px; }
.col-toggle-chip:hover { border-color: var(--accent); }
.col-toggle-chip.col-collapsed { background: transparent; color: var(--muted); text-decoration: line-through; }

/* A shadowed row in the New students grid (see _student_row_macros.html's
   new_row) — stays in place rather than moving to a separate list, just
   dimmed and struck through so it still reads at a glance as "excluded". */
.row-ignored { opacity: .5; }
.row-ignored td { text-decoration: line-through; }
.row-ignored td:last-child { text-decoration: none; }

/* Click-to-edit rows (New students / Invalid — see data-click-to-edit in
   app.js). Excludes cells with their own interactive control (checkbox,
   the Shadow button) — those keep the default cursor via the child
   selector below so it's clear they're a separate action, not "click the
   row". */
.row-editable { cursor: pointer; }
.row-editable:hover { background: rgba(0,0,0,.03); }
.row-editable td:has(input, button, a) { cursor: default; }

/* Attachment thumbnails (see _attachment_gallery.html) + the lightbox
   they open into, built by initLightbox() in app.js. */
.attachment-gallery { display: inline-flex; gap: 4px; flex-wrap: wrap; vertical-align: middle; }
.attachment-thumb { width: 36px; height: 36px; object-fit: cover; border-radius: 5px;
  border: 1px solid var(--line); cursor: pointer; }
.attachment-thumb:hover { border-color: var(--accent); }
.attachment-pdf { display: inline-flex; align-items: center; justify-content: center;
  background: var(--panel2); font-size: 16px; }
.attachment-file-link { font-size: 12px; }

#lightbox-overlay { display: none; position: fixed; inset: 0; background: rgba(15,18,26,.88);
  z-index: 1000; align-items: center; justify-content: center; }
#lightbox-overlay img { max-width: 90vw; max-height: 88vh; border-radius: 6px;
  box-shadow: 0 8px 30px rgba(0,0,0,.4); }
.lightbox-frame { width: 88vw; height: 88vh; border: none; border-radius: 6px;
  background: #fff; box-shadow: 0 8px 30px rgba(0,0,0,.4); display: none; }
.lightbox-close { position: absolute; top: 18px; right: 26px; color: #fff; font-size: 32px;
  cursor: pointer; line-height: 1; user-select: none; }
.lightbox-nav { position: absolute; top: 50%; transform: translateY(-50%); color: #fff;
  font-size: 42px; cursor: pointer; user-select: none; padding: 0 18px; opacity: .8; }
.lightbox-nav:hover { opacity: 1; }
.lightbox-prev { left: 6px; }
.lightbox-next { right: 6px; }

/* Enhanced sheet table: wrap-per-column, always-visible resize handles,
   freeze rows/columns, column drag-reorder, multi-select — see
   initEnhancedTable() in app.js. Progressive enhancement over an
   existing server-rendered <table>; every class here is scoped to a
   table this has actually been applied to (never global th/td rules),
   so it's opt-in per page. */
/* One line at every width, with the row count on it: the toolbar wrapped
   to three rows on a phone, and the flat label/stepper siblings split
   across them so a stepper appeared to belong to the label above it. It
   scrolls sideways instead — the controls stay in their reading order and
   the row count keeps its place at the head of the line. */
.enh-toolbar { display: flex; flex-wrap: nowrap; overflow-x: auto; align-items: center; gap: 8px;
  scrollbar-width: none; -webkit-overflow-scrolling: touch;
  border-bottom: 1px solid var(--line); padding: 4px 2px 8px; margin-bottom: 8px; font-size: 12px; }
/* Anything the page wants on the same bar as the table controls — the row
   count and Clear filters, which would otherwise sit on their own line
   directly above and read as a separate strip. */
.enh-toolbar-lead { display: flex; align-items: center; gap: 8px; }
.enh-collapse-btn { background: none; border: none; cursor: pointer; color: var(--muted); font-size: 10px;
  padding: 2px 4px; border-radius: 4px; display: inline-flex; align-items: center; transition: transform .12s; }
.enh-collapse-btn:hover { background: var(--panel2); color: var(--text); }
.enh-collapse-btn.collapsed { transform: rotate(-90deg); }
.enh-toolbar::-webkit-scrollbar { display: none; }
/* Softens the cut edge of anything that scrolls sideways (tab strips, this
   toolbar) so content sliced mid-word reads as "there is more this way"
   rather than as a rendering fault. Each side is faded only when there is
   really something past it — app.js tracks scroll position, not just
   overflow — because a left fade at scrollLeft 0 dims the first item (the
   row count) to hint at content that is not there. */
/* A mask does two things beyond fading an edge: it clips every descendant,
   and it becomes the containing block for position:fixed ones. Anything that
   must escape these bars — the export menu does — has to live outside them,
   not merely outrank them (toggleExportMenu moves it to <body>). */
.edge-fade-left {
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 16px);
  mask-image: linear-gradient(to right, transparent 0, #000 16px); }
.edge-fade-right {
  -webkit-mask-image: linear-gradient(to left, transparent 0, #000 16px);
  mask-image: linear-gradient(to left, transparent 0, #000 16px); }
.edge-fade-left.edge-fade-right {
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100%);
  mask-image: linear-gradient(to right, transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100%); }
.enh-toolbar > * { flex-shrink: 0; }
.enh-toolbar-controls { display: flex; flex-wrap: nowrap; align-items: center; gap: 8px; }
.enh-toolbar-controls > * { flex-shrink: 0; }
.enh-toolbar-controls.enh-hidden { display: none; }
.enh-tool-label { font-size: 11.5px; color: var(--muted); }
.enh-tool-group { display: flex; align-items: center; gap: 8px; }
.enh-divider { width: 1px; align-self: stretch; background: var(--line); margin: 0 4px; opacity: .7; }
.enh-stepper { display: flex; align-items: center; gap: 4px; }
.enh-stepper button { width: 19px; height: 19px; border-radius: 5px; border: 1px solid var(--line);
  background: var(--panel); color: var(--muted); font-size: 12px; line-height: 1; cursor: pointer; }
.enh-stepper button:hover { border-color: var(--accent); color: var(--text); }
.enh-stepper .enh-count { min-width: 14px; text-align: center; font-variant-numeric: tabular-nums;
  font-size: 12px; color: var(--text); }
.enh-icon-btn { background: var(--panel); border: 1px solid var(--line); color: var(--muted);
  width: 24px; height: 24px; border-radius: 5px; cursor: pointer; display: inline-flex;
  align-items: center; justify-content: center; }
.enh-icon-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.enh-icon-btn:disabled { opacity: .4; cursor: default; }
.enh-icon-btn.on { background: var(--accent); border-color: var(--accent); color: #fff; }

/* enh-clip-col (applied at init to plain-text/"sheet" columns only — see
   opts.plainColumns in initEnhancedTable) clips to one line by default,
   respecting the <col> width — same as a spreadsheet defaulting to
   Overflow/Clip. enh-wrap-col (toggled per column from the toolbar,
   mutually exclusive with clip) opts back into full multi-line display.
   Columns holding live interactive markup (the school-picker combo, whose
   dropdown is position:absolute and must not be clipped; the Grades
   checkboxes; the Confirm/Discard buttons) deliberately get neither class
   — clipping those would cut off or hide working controls, not just text. */
.enh-table { border-collapse: separate; border-spacing: 0; width: max-content; min-width: 100%; }
/* --cell-bg lets a cell's own semantic colour survive this rule — see the
   .att-* block above. Falls back to the panel colour, which is what a frozen
   column needs so the table does not show through it. */
.enh-table th, .enh-table td { position: relative; background: var(--cell-bg, var(--panel)); }
/* Body cells only. A <th> must NOT get overflow:hidden: every filterable
   header holds a position:absolute .col-filter-panel, and clipping the
   header clips the dropdown to the header's own box — which reads as
   "the filter doesn't open at all". Header labels are short and already
   white-space:nowrap, so they don't need clipping to stay on one line. */
.enh-table td.enh-clip-col {
  max-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.enh-table td.enh-wrap-col, .enh-table th.enh-wrap-col {
  white-space: normal; text-overflow: clip; overflow: visible; max-width: none;
}

.enh-resize-handle { position: absolute; top: 0; right: 0; width: 9px; height: 100%;
  cursor: col-resize; z-index: 4; }
.enh-resize-handle::after { content: ""; position: absolute; top: 4px; bottom: 4px; left: 4px;
  width: 2px; background: var(--line); border-radius: 1px; }
.enh-resize-handle:hover::after, .enh-resize-handle.active::after { background: var(--accent); width: 3px; }

.enh-selectable { cursor: pointer; }
.enh-col-selected { background: rgba(59,110,245,.09) !important; }
th.enh-col-selected { box-shadow: inset 0 -2px 0 var(--accent); }

.enh-row-gutter { width: 34px; min-width: 34px; max-width: 34px; text-align: center; cursor: pointer;
  color: var(--muted); font-size: 11px; user-select: none; font-variant-numeric: tabular-nums;
  position: relative; }
/* Row number and its checkbox occupy the same 34px: the number reads while
   you scan, the checkbox appears where your cursor already is. The checkbox
   is never display:none — it stays focusable, so the column is reachable by
   keyboard, and :focus-within is one of the states that reveals it. */
.enh-row-gutter input[type="checkbox"] { position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%); margin: 0; opacity: 0; pointer-events: none;
  transition: opacity .1s; }
.enh-row-num { transition: opacity .1s; }
tr:hover > .enh-row-gutter .enh-row-num,
.enh-row-gutter:focus-within .enh-row-num,
.enh-row-gutter.enh-checked .enh-row-num { opacity: 0; }
tr:hover > .enh-row-gutter input[type="checkbox"],
.enh-row-gutter:focus-within input[type="checkbox"],
.enh-row-gutter.enh-checked input[type="checkbox"] { opacity: 1; pointer-events: auto; }
/* The header's select-all has no number to trade places with. */
th.enh-row-gutter input[type="checkbox"] { opacity: 1; pointer-events: auto; }
/* A table folding a per-row action into the gutter (data-enh-action) needs
   room for two controls where there was one. The action follows exactly the
   checkbox's states — hidden behind the row number until the row is hovered,
   focused within, or checked — so the gutter still reads as a row number
   while you scan, and never as a column of buttons. */
.enh-has-row-action .enh-row-gutter { width: 56px; min-width: 56px; max-width: 56px; }
.enh-has-row-action .enh-row-gutter input[type="checkbox"] { left: 17px; }
.enh-row-action { position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
  display: inline-flex; align-items: center; justify-content: center;
  width: 20px; height: 20px; padding: 0; border-radius: 4px; cursor: pointer;
  background: transparent; border: 1px solid transparent; color: var(--muted);
  opacity: 0; pointer-events: none; transition: opacity .1s; }
.enh-row-action svg { width: 12px; height: 12px; }
.enh-row-action:hover { color: var(--accent); border-color: var(--line); background: var(--panel2); }
tr:hover > .enh-row-gutter .enh-row-action,
.enh-row-gutter:focus-within .enh-row-action,
.enh-row-gutter.enh-checked .enh-row-action { opacity: 1; pointer-events: auto; }
/* A touch screen has no hover, so the reveal-above never fires on its own:
   the first tap only synthesises a hover state and the second does the
   actual selecting. Selecting a row took two taps. Show the box permanently
   where there is no hover and drop the row number — the number is the
   decoration here, selection is the function. */
@media (hover: none) {
  .enh-row-gutter input[type="checkbox"] { opacity: 1; pointer-events: auto; }
  .enh-row-action { opacity: 1; pointer-events: auto; }
  .enh-row-gutter .enh-row-num { opacity: 0; }
}
.enh-row-selected .enh-row-gutter, tr.enh-row-selected td { background: rgba(59,110,245,.09) !important; }
.enh-row-selected .enh-row-gutter { color: var(--accent); font-weight: 700; }

.enh-dragging { opacity: .45; }
th.enh-drop-before { box-shadow: inset 2px 0 0 var(--accent); }
th.enh-drop-after { box-shadow: inset -2px 0 0 var(--accent); }
/* Must out-specify `.enh-table th, .enh-table td { position: relative }`
   above (class+element beats a bare class). When it loses, the cell keeps
   position:relative and the `left` the freeze code sets *shifts* it right
   instead of pinning it — every frozen column slides out of line with its
   header, which is what "freeze columns is broken" looks like. Only the row
   gutter escaped it, because row freezing sets position:sticky inline. */
.enh-table th.enh-frozen, .enh-table td.enh-frozen { position: sticky; z-index: 2; }
.enh-table thead .enh-frozen { z-index: 6; }
.enh-frozen-col-edge { box-shadow: 6px 0 8px -6px rgba(16,24,40,.18); }
.enh-frozen-row-edge td, .enh-frozen-row-edge { box-shadow: 0 6px 8px -6px rgba(16,24,40,.18); }

/* Always-on grid cells (sheet.live_cell). Flat until hovered or focused, so a
   screen full of them reads as a table rather than a wall of form controls. */
.ed-live { width: 100%; min-width: 60px; padding: 3px 5px; font-size: inherit;
  background: transparent; border: 1px solid transparent; border-radius: 4px;
  color: var(--text); box-shadow: none; }
.ed-live:hover { border-color: var(--line); background: var(--panel2); }
.ed-live:focus { border-color: var(--accent); background: var(--panel); outline: none;
  box-shadow: 0 0 0 3px rgba(59,110,245,.12); }
.ed-live.save-ok { border-color: var(--ok); }
.ed-live.save-err { border-color: var(--danger); background: rgba(220,38,38,.05); }

/* A save is a network call. Without this a caller on a school's connection
   sees nothing happen and retypes into a field that was already on its way.
   Sits on the control's parent — the grid cell, or the call screen's labelled
   block — which is what lets one rule serve both. */
.save-busy { position: relative; }
.save-busy::after {
  content: ''; position: absolute; right: 7px; top: 50%; margin-top: -6px;
  width: 11px; height: 11px; border: 2px solid var(--muted);
  border-right-color: transparent; border-radius: 50%;
  animation: btn-spin 0.6s linear infinite; pointer-events: none;
}
@media (prefers-reduced-motion: reduce) { .save-busy::after { animation-duration: 1.5s; } }
/* Rejections belong on the field, not in a dialog: a dialog takes the answer
   away from the thing it is about, and mid-call the caller needs to read the
   number back while fixing it. */
.ed-error { display: block; margin-top: 3px; font-size: 12px; color: var(--danger);
  line-height: 1.35; }
/* A value the server recomputed and sent back, so the change is visible
   rather than something the caller has to notice. */
.derived-updated { animation: derived-flash 1.2s ease-out; }
@keyframes derived-flash {
  from { background: rgba(59,110,245,.18); }
  to { background: transparent; }
}
@media (prefers-reduced-motion: reduce) { .derived-updated { animation: none; } }

/* Numeric column filter: a min/max range, shown alongside the checkbox list
   when there is one and on its own when the column has too many distinct
   values for a list to be usable. */
.col-filter-range { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap;
  padding: 8px 10px; border-bottom: 1px solid var(--line); }
.col-filter-range input { width: 0; flex: 1 1 0; min-width: 0; padding: 3px 6px; font-size: 12px; }
/* Blanks ask a question the checkbox list cannot answer at scale, so it sits
   above the list rather than inside it — see column_header. */
.col-filter-blank-row { display: flex; align-items: center; gap: 6px;
  padding: 8px 10px; border-bottom: 1px solid var(--line); font-size: 12px; }
.col-filter-blank-row label { display: flex; align-items: center; gap: 6px;
  color: var(--muted); white-space: nowrap; width: 100%; }
.col-filter-blank-row select { flex: 1 1 0; min-width: 0; padding: 3px 6px; font-size: 12px; }

/* Perf page: severity is carried by colour AND weight, so the eye lands on the
   slow rows without reading every number. Bands are in app/perf.py. */
.sev-ok { color: var(--muted); }
.sev-warn { color: var(--warn); }
.sev-bad { color: var(--danger); }
.sev-worst { color: var(--danger); font-weight: 700; }
tr.row-warn td:first-child { box-shadow: inset 3px 0 0 var(--warn); }
tr.row-bad td:first-child, tr.row-worst td:first-child { box-shadow: inset 3px 0 0 var(--danger); }
/* Share of total time, as its own fixed-width track so the bars are comparable
   down the column — a tint behind right-aligned text was invisible at the
   widths this table actually renders at. */
.share-track { width: 110px; height: 8px; border-radius: 4px; background: var(--line);
  overflow: hidden; }
.share-fill { display: block; height: 100%; border-radius: 4px; background: var(--danger); }
.share-fill.low { background: var(--accent); opacity: .55; }
.stat-card.alarm { border-color: var(--danger); }
.stat-card.alarm .stat-value { color: var(--danger); }
.stat-value { font-size: 22px; font-variant-numeric: tabular-nums; }

/* Row-on-row record comparison (student_batch_review's Possible match /
   Conflict / Matched-no-conflict widgets, and same-document duplicate
   groups). One column per field, one row per record, so agreement reads
   as a shape before anything is read: green where the values match, red
   where they contradict each other, plain where only one side holds a
   value — a blank is missing information, not a disagreement, and
   colouring it red would drown the real conflicts. Kept light: these
   fill most of a card, and saturated blocks at that size are unreadable
   rather than emphatic. */
.cmp-grid { width: 100%; border-collapse: collapse; font-size: 13px; margin: 8px 0 0; }
.cmp-grid th, .cmp-grid td { padding: 5px 9px; border: 1px solid var(--line); text-align: left;
  white-space: nowrap; }
.cmp-grid thead th { background: var(--panel2); color: var(--muted); font-weight: 600; font-size: 12px; }
.cmp-grid tbody th { background: var(--panel2); color: var(--muted); font-weight: 600;
  font-size: 12px; position: sticky; left: 0; }
.cmp-grid td.cmp-same { background: rgba(18, 161, 80, .10); color: #0f7a3d; }
.cmp-grid td.cmp-diff { background: rgba(220, 38, 38, .10); color: #b42318; font-weight: 600; }
/* Differs, but settles itself on commit: a phone number, where both are
   kept. Not red — red is reserved for the contradictions that actually hold
   a row up, and spending it here made a Matched-no-conflict row look stuck.
   The note under the grid says what happens. */
.cmp-grid td.cmp-add { background: rgba(59, 110, 245, .09); color: #2b52c0; }
/* Only one side holds a value. Nothing is in conflict — a blank incoming
   value is ignored outright — so this stays unpainted. */
.cmp-grid td.cmp-one { color: var(--muted); }

/* Instant tutoring report — charts and their filter chips. Server-rendered
   SVG (app/instant_charts.py) reads these custom properties, so the charts
   follow the theme rather than carrying a palette of their own. */
:root { --teal: #0f8ea3; --heat: 59,110,245; }
.filter-group { display: inline-flex; align-items: center; gap: 5px; }
.filter-label { font-size: 11px; color: var(--muted); text-transform: uppercase;
  letter-spacing: .05em; margin-right: 2px; }
.chip { border: 1px solid var(--line); background: var(--panel2); color: var(--muted);
  border-radius: 3px; padding: 3px 10px; font-size: 12px; text-decoration: none; }
.chip:hover { border-color: var(--accent); text-decoration: none; }
.chip-on { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 8px; margin: 12px 0; }
.kpi { background: var(--panel); border: 1px solid var(--line); padding: 11px 13px; }
.kpi-l { color: var(--muted); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; }
.kpi-v { font-size: 23px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
.chart-h { font-size: 12px; margin: 22px 0 5px; color: var(--muted);
  text-transform: uppercase; letter-spacing: .06em; }
.chart-card { padding: 12px 14px; }
.chart-card svg { display: block; width: 100%; height: auto; }
.chart-legend { display: flex; gap: 14px; flex-wrap: wrap; color: var(--muted);
  font-size: 11.5px; margin-top: 7px; }
.chart-legend .sw { display: inline-block; width: 11px; height: 11px; border-radius: 2px;
  vertical-align: -1px; margin-right: 5px; }
/* A chart with no data says so. An empty grid reads as a broken chart. */
.chart-empty { color: var(--muted); font-size: 13px; margin: 26px 0; text-align: center; }

/* Batch review's Phones block. Collapsed on landing — a reviewer opens that
   page to work conflicts, and a thousand-row table above the fold is in the
   way of it; the count in the summary is what they need at a glance. */
.phones-block > summary { cursor: pointer; list-style: none; padding: 6px 0; }
.phones-block > summary::-webkit-details-marker { display: none; }
.phones-block > summary::before { content: "\25B8 "; color: #888; }
.phones-block[open] > summary::before { content: "\25BE "; }
.mini-summary td, .mini-summary th { padding: 3px 14px 3px 0; }
.mini-summary .strong-row td { font-weight: 600; }

/* Call screen — one row laid out vertically for a caller who is on the phone
   (see openCallForm in app.js). Full-height rather than a small dialog: the
   point is that a wide campaign's last custom field is reachable without
   scrolling sideways mid-call. */
.call-form-overlay, .modal-overlay { position: fixed; inset: 0; background: rgba(15,18,25,.45);
  z-index: 1000; display: flex; align-items: flex-start; justify-content: center;
  padding: 24px 16px; overflow-y: auto; }
/* Author `display: flex` beats the UA stylesheet's rule for [hidden], so
   without this the scrim is painted over the page from first load and never
   goes away — the attribute is set, and does nothing. Same fix, same reason,
   as .export-menu[hidden] above. */
.call-form-overlay[hidden], .modal-overlay[hidden] { display: none; }
body.call-form-open, body.modal-open { overflow: hidden; }
.call-form, .modal-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
  box-shadow: 0 12px 32px rgba(16,24,40,.18); width: 100%; max-width: 720px; }
.call-form-head { display: flex; align-items: flex-start; justify-content: space-between;
  gap: 16px; padding: 18px 22px; border-bottom: 1px solid var(--line);
  position: sticky; top: 0; background: var(--panel); border-radius: 12px 12px 0 0; }
.call-form-head h2 { margin: 0; font-size: 18px; }
.call-form-sub { margin: 4px 0 0; }
.call-form-head-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.call-form-attempts { display: flex; align-items: baseline; gap: 4px; }
.call-form-attempts [data-attempts] { font-size: 16px; font-weight: 700; }
/* The rule behind a category heading. The names have to be short and
   several are ambiguous on their own — "No number in common" does not say
   whether nobody has a number or whether no two records share one — so the
   whole rule is one click from every heading. */
.rule-dot { background: none; border: 0; padding: 0 0 0 4px; cursor: pointer;
  color: var(--muted); font-size: 13px; line-height: 1; vertical-align: 1px; }
.rule-dot:hover { color: var(--accent, #2563eb); }
.rule-card { max-width: 680px; }
.rule-card h4 { margin: 16px 0 6px; font-size: 12px; text-transform: uppercase;
  letter-spacing: .04em; }
.rule-card h4:first-child { margin-top: 0; }
.rule-card h4 span { text-transform: none; letter-spacing: 0; font-weight: 400; }
.rule-list { margin: 0; padding-left: 18px; font-size: 13px; line-height: 1.55; }
.rule-list li { margin-bottom: 3px; }
/* The two lists mean different things and are told apart in words rather
   than by a marker nobody would decode: every condition has to hold, versus
   the first one that applies wins. */
.rule-all::before { content: "All of:"; display: block; margin-left: -18px;
  font-size: 11px; color: var(--muted); text-transform: uppercase;
  letter-spacing: .03em; margin-bottom: 3px; }
.rule-first::before { content: "First that applies:"; display: block;
  margin-left: -18px; font-size: 11px; color: var(--muted);
  text-transform: uppercase; letter-spacing: .03em; margin-bottom: 3px; }
/* What differs sits in the page's own voice; what is identical everywhere
   is set back a step, so a reader opening a second modal can see at a glance
   which part is new. */
.rule-own { color: var(--text, inherit); }
.rule-own-body { border-left: 3px solid var(--accent, #2563eb); padding-left: 14px;
  margin-left: -1px; }
.rule-own-body::before { margin-left: -14px; }
.rule-shared-note { margin: 18px 0 0; font-size: 11px; color: var(--muted);
  text-transform: uppercase; letter-spacing: .04em; border-top: 1px solid var(--line);
  padding-top: 10px; }
.rule-shared { opacity: .82; }
.rule-shared h4 { color: var(--muted); }

.rule-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rule-table th { text-align: left; font-size: 11px; color: var(--muted);
  text-transform: uppercase; letter-spacing: .03em; padding: 2px 8px 4px 0; }
.rule-table td { padding: 4px 8px 4px 0; border-top: 1px solid var(--line);
  vertical-align: top; }
.rule-table td:last-child { font-weight: 600; }

/* A category nobody works. Struck through rather than absent: these groups
   exist and are the largest pile on the project, and a category simply
   missing from the page reads as one nobody thought about. */
h3.dd-settled { text-decoration: line-through; text-decoration-thickness: 1px;
  color: var(--muted); }
h3.dd-settled .rule-dot { text-decoration: none; }

/* One filter per category, beside its own rows. Links, not a <select>, so
   the state is a URL somebody can send. */
.dd-filter-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.dd-filter { display: inline-flex; border: 1px solid var(--line); border-radius: 6px;
  overflow: hidden; }
.dd-filter a { padding: 3px 10px; font-size: 12px; text-decoration: none;
  color: var(--muted); }
.dd-filter a:hover { background: var(--bg); }
.dd-filter a.on { background: var(--accent, #2563eb); color: #fff; font-weight: 600; }
/* Dimmed while its replacement is in flight, so a slow swap looks like
   waiting rather than like nothing having happened. */
.dd-loading { opacity: .45; pointer-events: none; }

/* Why a group will not merge, said on the row. Amber rather than red: a
   hold is the tool declining to guess, not a fault in the data. Wraps rather
   than truncates — the label is the whole point of it. */
.dd-hold { display: inline-block; font-size: 11px; font-weight: 600;
  color: #92400e; background: rgba(217,119,6,.14); border-radius: 4px;
  padding: 1px 6px; line-height: 1.4; }

/* Dedupe. One mark, one meaning: this value is not the same on both
   records. Everything else stays plain text.
   
   The first attempt gave each record a hue and filled a pill for every phone
   and every Filo roll number. It marked the values the records *share* —
   which is the background, not the finding — so the cells became two lines
   of boxes with nothing quiet to read them against, and the hues were not
   tellable apart down a column of dozens of groups.

   So: no per-record colours, no pills, and the thing that differs is the
   only thing coloured. */
.dd-differs { color: #92400e; border-bottom: 1px dotted #b45309; }
/* A Filo roll number belonging to no record in the group — rarer, and worth
   a different mark from "the records disagree". */
.dd-odd { color: var(--danger); border-bottom: 1px dotted var(--danger); }

/* A class, opened from the grid. The overlay and card come from the calling
   screen's modal — same shape, honest name, no second copy of the CSS. */
.modal-card { max-width: 640px; }
.modal-head { display: flex; align-items: flex-start; justify-content: space-between;
  gap: 16px; padding: 16px 20px; border-bottom: 1px solid var(--line); }
.modal-head h2 { margin: 0; font-size: 17px; }
.modal-body { padding: 16px 20px; }
.modal-facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px;
  margin: 0 0 14px; }
.modal-facts dt { font-size: 11px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .03em; }
.modal-facts dd { margin: 0; font-size: 13px; }
.modal-schools { max-height: 200px; overflow-y: auto; margin: 6px 0 0;
  padding-left: 18px; }
.modal-schools li { font-size: 12px; line-height: 1.6; }

/* The timetable grid. Time down the side, days across the top — the shape
   people already read timetables in. It scrolls in its own box rather than
   making the page scroll sideways. */
.sched-scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; }
.sched-grid { width: 100%; border-collapse: collapse; table-layout: fixed; }
.sched-grid th, .sched-grid td { border: 1px solid var(--line); vertical-align: top;
  padding: 4px; }
/* The time column stays put while the week scrolls; a grid you have to
   scroll back from to see what hour you are looking at is not a timetable. */
.sched-grid .sched-time, .sched-grid .sched-corner { position: sticky; left: 0;
  background: var(--panel); z-index: 1; width: 78px; font-variant-numeric: tabular-nums;
  font-size: 12px; text-align: right; }
.sched-grid thead th { position: sticky; top: 0; background: var(--panel); z-index: 2;
  font-size: 12px; text-align: left; white-space: nowrap; }
.sched-grid .sched-corner { z-index: 3; }
.sched-grid thead th span { display: block; font-weight: 400; }
.sched-today { box-shadow: inset 0 -2px 0 var(--accent, #2563eb); }
.sched-cell { min-width: 150px; }

/* One chip per class. Status is the fill, so a cancelled week is visible
   without reading a word of it. */
.sched-chip { display: flex; align-items: center; gap: 4px; font-size: 11px;
  line-height: 1.35; padding: 3px 5px; border-radius: 4px; margin-bottom: 3px;
  border-left: 3px solid transparent; }
.sched-chip:last-child { margin-bottom: 0; }
.sched-chip-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
.sched-chip-grade { flex: 0 0 auto; font-weight: 600; opacity: .75; }
.sched-chip-rec { flex: 0 0 auto; text-decoration: none; font-size: 10px; }
/* Hover lifts the chip out of its cell and lets the name wrap, so the full
   course can be read without opening anything. Absolute would escape the
   scroll box; a raised z-index and a shadow are enough. */
.sched-chip { cursor: pointer; }
.sched-chip:hover { position: relative; z-index: 5;
  box-shadow: 0 2px 10px rgba(16,24,40,.22); }
.sched-chip:hover .sched-chip-name { white-space: normal; overflow: visible; }
.sched-chip:focus-visible { outline: 2px solid var(--accent, #2563eb); outline-offset: 1px; }

.sched-completed { background: rgba(18,161,80,.10); border-left-color: var(--ok); }
.sched-upcoming  { background: rgba(217,119,6,.10); border-left-color: var(--warn); }
.sched-cancelled { background: rgba(220,38,38,.14); border-left-color: var(--danger);
  color: #7f1d1d; text-decoration: line-through; }
.sched-key { display: inline-flex; margin: 0 2px; vertical-align: middle; }

/* Class monitoring. Two collapsible sections, closed on arrival so the tab
   opens on the summary rather than on two long tables. */
.cm-section { margin: 14px 0; border: 1px solid var(--line); border-radius: 8px;
  padding: 10px 12px; }
.cm-section > summary { cursor: pointer; display: flex; align-items: baseline;
  gap: 8px; }
.cm-section[open] > summary { margin-bottom: 10px;
  border-bottom: 1px solid var(--line); padding-bottom: 8px; }

/* Four shades of one red, so the four failures are told apart at a glance
   and still read as one family. The order is severity, not frequency: a
   cancelled class never happened, a missing tutor may not have, a short one
   was cut, a late one merely started badly. Kept as tints rather than solid
   fills so the number on top stays the thing you read. --danger is #dc2626;
   these are its alpha steps, written out because a CSS variable cannot be
   faded without color-mix support we are not relying on. */
.cm-late    { background: rgba(220, 38, 38, .07); }
.cm-short   { background: rgba(220, 38, 38, .14); }
.cm-nojoin  { background: rgba(220, 38, 38, .22); }
.cm-cancel  { background: rgba(220, 38, 38, .32); }
.cm-nojoin, .cm-cancel { color: #7f1d1d; font-weight: 600; }

/* The schools behind one config issue. Closed by default because the point
   of grouping by course was to stop the page repeating itself. */
.cfg-schools summary { cursor: pointer; font-variant-numeric: tabular-nums; }
.cfg-schools ul { margin: 6px 0 0; padding-left: 18px; max-height: 260px;
  overflow-y: auto; }
.cfg-schools li { font-size: 12px; line-height: 1.6; }

/* One row's history. A list, not a table: the entries are of wildly different
   widths (a status change beside a paragraph of remarks) and a table would
   size every column to its worst case. */
.row-history-list { list-style: none; margin: 0; padding: 0 22px 18px; }
.row-history-entry { display: flex; gap: 16px; padding: 12px 0;
  border-bottom: 1px solid var(--line); }
.row-history-entry:last-child { border-bottom: 0; }
/* The timestamp column is fixed so the changes line up down the page and the
   eye can run along one edge rather than hunting. */
.row-history-when { flex: 0 0 150px; display: flex; flex-direction: column; gap: 2px; }
.row-history-when strong { font-size: 13px; font-variant-numeric: tabular-nums; }
.row-history-what { flex: 1 1 auto; min-width: 0; display: flex;
  flex-direction: column; gap: 4px; }
.row-history-field { font-size: 13px; overflow-wrap: anywhere; }
.row-history-label { font-size: 11px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .03em; margin-right: 6px; }
.row-history-old { text-decoration: line-through; color: var(--muted); }
.row-history-new { font-weight: 600; }
/* A dial is the one entry that is an action rather than an edit. */
.row-history-dial .row-history-when strong { color: var(--accent, inherit); }

/* The live account check. Sits between the header and the fields, indented
   by the same 22px gutter so it reads as part of the call screen rather than
   as a popup over it. */
.acct-status { margin: 0 22px; padding: 12px 14px; border: 1px solid var(--line);
  border-radius: 8px; background: var(--bg); }
.acct-status-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.acct-status-limited { margin: 14px 22px 0; padding: 12px 14px; border-radius: 8px;
  border: 1px solid var(--line); background: var(--bg); }
/* Label/value pairs rather than a table: two columns, and the values wrap
   under their own label instead of stretching the call screen sideways. */
.acct-status-facts { display: grid; grid-template-columns: max-content 1fr;
  gap: 6px 14px; margin: 0; }
.acct-status-facts dt { font-size: 12px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .03em; }
.acct-status-facts dd { margin: 0; font-size: 13px; }

/* One field per line, label above control: a caller reads down this list
   while talking, so nothing shares a row and nothing is half-width. */
.call-form-fields { display: flex; flex-direction: column; gap: 14px; padding: 18px 22px; }
.call-form-field { display: flex; flex-direction: column; gap: 5px; }
.call-form-field label { font-size: 12px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .03em; }
/* Bordered here, unlike in the grid. .ed-live is deliberately flat where a
   screen full of them should read as a table; this screen IS a form, and a
   caller filling it in while talking needs to see where each control begins
   without hunting for it by hover. */
.call-form-field .ed-live { width: 100%; font-size: 15px; padding: 9px 10px;
  border-color: var(--line); background: var(--panel); }
.call-form-field .ed-live:hover { border-color: var(--muted); }
.call-form-field.is-wide .ed-live { min-height: 76px; }
.call-form-ro { font-size: 14px; }
.call-form-foot { margin: 0; padding: 0 22px 18px; }
/* The Open control itself is styled by .enh-row-action, since the enhanced
   table moves it into the gutter. This is only the fallback shape for the
   moment before that runs. */
.call-open-btn { background: none; border: none; padding: 0; cursor: pointer; color: var(--muted); }

/* Link from a page to the API endpoint serving the same data (see
   _api_ref.html). Quiet by default — it is a signpost beside the real
   controls, not one of them. */
.api-ref { font-size: 12px; color: var(--muted); text-decoration: none;
  border: 1px solid var(--line); border-radius: 5px; padding: 2px 7px;
  white-space: nowrap; }
.api-ref::before { content: '{ }'; margin-right: 5px; opacity: .65; font-family: ui-monospace, monospace; }
.api-ref:hover { color: var(--accent); border-color: var(--accent); text-decoration: none; }

/* Chart header: a title with its own view switch on the right. */
.chart-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.chart-toggle { display: flex; gap: 6px; }

/* Topbar project switcher. The menu is `hidden`-toggled, and the rule below
   is why that works: an author `display:` on the menu would otherwise beat
   the attribute and leave it permanently open — the same bug the call form's
   scrim had. */
.crumb-switch { position: relative; display: inline-flex; align-items: center; gap: 2px; }
.crumb-switch-btn { border: 0; background: none; cursor: pointer; padding: 0 4px;
  color: inherit; font-size: 11px; line-height: 1; opacity: .75; }
.crumb-switch-btn:hover { opacity: 1; }
.crumb-switch-menu { position: absolute; top: 100%; left: 0; z-index: 60; min-width: 230px;
  background: var(--card, #fff); border: 1px solid var(--line, #dcdfe4); border-radius: 6px;
  box-shadow: 0 6px 20px rgba(16, 24, 40, .14); padding: 8px; margin-top: 6px; }
.crumb-switch-menu[hidden] { display: none !important; }
.crumb-switch-search { width: 100%; padding: 5px 8px; font-size: 12px; margin-bottom: 6px; }
.crumb-switch-list { max-height: 300px; overflow-y: auto; display: flex; flex-direction: column; }
.crumb-switch-list a { padding: 6px 8px; border-radius: 4px; font-size: 13px;
  color: var(--text, #1c2024); text-decoration: none; white-space: nowrap; }
.crumb-switch-list a:hover { background: var(--bg-soft, #f2f4f7); }
.crumb-switch-list a.current { font-weight: 600; }
.crumb-switch-list a[hidden] { display: none !important; }
.crumb-switch-head { font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--muted, #6b7280); padding: 4px 8px 2px; }
.crumb-switch-list a.recent + a:not(.recent) { border-top: 1px solid var(--line, #e5e7eb);
  margin-top: 4px; padding-top: 8px; }
.assign-to { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; }
.assign-to select { max-width: 230px; font-size: 12px; padding: 3px 6px; }

/* Dedupe: each group is a <tbody> of one row per record, so the records can
   be compared. The rule between groups is what keeps two groups from reading
   as one list of six unrelated students. */
tbody.dd-group + tbody.dd-group > tr > td { border-top: 2px solid var(--line); }
tbody.dd-group > tr:not(:first-child) > td { border-top: 0; }

/* Batch review activity — one <details> per decision type, its summary laid
   out on a grid so the counts and labels line up like the table it replaced.
   Same disclosure idiom as .phones-block above. */
.review-action > summary {
  cursor: pointer; list-style: none; padding: 5px 0;
  display: grid; grid-template-columns: 70px 200px 1fr auto auto;
  gap: 10px; align-items: baseline;
  border-bottom: 1px solid #eee;
}
.review-action > summary::-webkit-details-marker { display: none; }
.review-action > summary:hover { background: #fafafa; }
.review-action > summary::before { content: "\25B8"; color: #888; position: absolute; margin-left: -12px; }
.review-action[open] > summary::before { content: "\25BE"; }
.review-action .ra-count { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
.review-action > .table-wrap { margin: 6px 0 14px 70px; }
/* The one row state worth colouring: a decision the data contradicts. */
.review-action .ra-wrong td { background: rgba(220, 38, 38, .06); }
@media (max-width: 760px) {
  .review-action > summary { grid-template-columns: 56px 1fr; }
  .review-action > .table-wrap { margin-left: 0; }
}

/* A day where half the schedule was cancelled reads as a fact, not a
   formatting flourish — it is the one cell worth finding by eye. */
.cell-bad { color: var(--danger); font-weight: 600; }

/* Other roster records a review row could be — advisory, never a block.
   See app/roster_candidates.py for why one candidate was not enough. */
.roster-cands { margin: 10px 0 0; padding: 8px 10px; border-radius: 7px;
                background: #f7f7f8; border: 1px solid #ececef; }
.roster-cands-warn { background: rgba(220, 38, 38, .05); border-color: rgba(220, 38, 38, .25); }
.roster-cands .rc-head { margin-bottom: 6px; font-size: 13px; }
.roster-cands table { font-size: 12px; }
.roster-cands .rc-collide td { background: rgba(220, 38, 38, .08); }
.roster-cands .rc-shown td { font-weight: 600; }

/* "This extraction looks like one we already have" — advisory only. */
.dup-note { margin: 10px 0; padding: 9px 12px; border-radius: 7px; font-size: 13px;
            background: rgba(217, 119, 6, .07); border: 1px solid rgba(217, 119, 6, .3); }
.dup-note-exact { background: rgba(220, 38, 38, .06); border-color: rgba(220, 38, 38, .28); }

/* Shared-number rows on the provisioning Phones tab. The holders arrive as
   their own table inside a full-width cell, so it gets its own horizontal
   scroll rather than widening the page. */
button.ph-expand {
  background: none; border: none; cursor: pointer; padding: 0 6px 0 0;
  font: inherit; color: inherit;
}
tr.ph-holders > td { padding: 0 0 10px 26px; }
.ph-holders-wrap { overflow-x: auto; }
.ph-holders-table { margin: 0; }
td.num-cell { text-align: right; }
