/* starflow — same chrome as grantsheets and enrichment_tool. */
/* ** LIGHT MODE'S MUTED TEXT WAS TOO FAINT TO READ (2026-09-28). **
   Asked: "can we make the commemnt more easy to read when not in dark more".
   It was #6b6b73 — 4.81:1 on the page background, which passes WCAG AA by
   0.31 and only for text at least 14px. Every `.hint` in this app is 14px or
   smaller and there is a LOT of hint prose, so "just passes" read as faint.
   #55555e is 6.71:1. Measured, not eyeballed. Dark mode is untouched: its
   muted (#9aa0a6 on #202124) is already 7.5:1. */
:root{
  --bg:#f4f4f6;--surface:#fff;--line:#e3e3e8;--ink:#333;--muted:#55555e;
  --blue:#2c6fbb;--green:#3f9c4a;--green-bg:#e9f5ea;--red:#c9302c;--red-bg:#fbeaea;
  --purple:#8e44ad;--teal:#17796f;--orange:#c26100;--pink:#b0186a;--amber:#8a6400;--chip:#f2f1f5;--stripe:#faf9fb;
  --shadow:0 1px 2px 0 rgba(40,20,60,.12),0 1px 3px 1px rgba(40,20,60,.08);
  --brand:#5b2d8e;--brand-ink:#fff;--topbar-bg:var(--brand);--topbar-ink:#fff;
  --thead-bg:var(--brand);--thead-ink:#fff;
}
html.dark{
  --bg:#202124;--surface:#2a2b2e;--ink:#e8eaed;--muted:#9aa0a6;--line:#45494f;
  --blue:#8ab4f8;--green:#81c995;--green-bg:#1e3a29;--red:#f28b82;--red-bg:#3f2726;
  --purple:#c58af9;--teal:#78d9ec;--orange:#fcad70;--pink:#f79ac8;--amber:#e0c76a;--chip:#3c4043;--stripe:#36373b;
  --shadow:0 1px 2px 0 rgba(0,0,0,.6),0 1px 3px 1px rgba(0,0,0,.4);
  --brand:#c58af9;--topbar-bg:var(--surface);--topbar-ink:var(--ink);
  --thead-bg:var(--chip);--thead-ink:var(--muted);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
  font-family:'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px}
.px{font-weight:600;font-variant-numeric:tabular-nums}
/* ** A NUMBER COLUMN IS RIGHT-ALIGNED ALL THE WAY DOWN. **
   Reported 2026-09-28: *"the numbers in toals are a little off"* — and they were.
   `.px` carried `tabular-nums` but NO alignment, so a money cell in the body sat
   LEFT while `.roll tfoot tr.total th.px` was already `text-align:right`. Every
   column therefore had left-aligned figures with a right-aligned total beneath
   them, which is exactly the "little off" being described: the digits did not
   line up with the figure they add to.
   Scoped to CELLS inside `.roll`, because `.px` is also the class on 207 section
   headings (`<div class="px label">`) which must stay where they are. Heading,
   data and total all move together so the column reads as one. */
.roll td.px, .roll th.px, .table-scroll td.px, .table-scroll th.px, .table-scroll td.num, .table-scroll th.num { text-align: right; }
/* ** `.num` WAS A FIGURE CLASS NO RULE IN THIS FILE MATCHED (2026-10-13). **
   16 cells across `views/addons.js`, `views/payments.js` and `views/pnl.js`
   mark a money or count column `class="num"` instead of `.px`, and there was
   no `.num` rule here at all -- so the registration review, the "collected,
   but on no budget" panel and the projection report's own rate card drew
   their figures LEFT-aligned in proportional digits while every other table
   right-aligns them in tabular ones. Given the same treatment rather than
   left to match a class that does not exist. */
.table-scroll td.num, .table-scroll th.num { font-weight: 600; font-variant-numeric: tabular-nums; }
/* The leading label of a total row is prose, not a figure — it stays left. */
.roll tfoot tr.total th:first-child, .roll tr.total td:first-child, .table-scroll tr.total th:first-child, .table-scroll tr.total td:first-child { text-align: left; }
a{color:var(--blue)}
.topbar{display:flex;justify-content:space-between;align-items:center;padding:0 24px;min-height:56px;
  gap:8px;flex-wrap:wrap;background:var(--topbar-bg);border-bottom:1px solid var(--line)}
.wordmark{display:flex;align-items:center;gap:10px;font-size:18px;font-weight:700;
  color:var(--topbar-ink);text-decoration:none}
.dot{width:12px;height:12px;border-radius:50%;background:var(--topbar-ink);opacity:.9}
.link{background:none;border:0;color:var(--topbar-ink);cursor:pointer;font-size:14px;
  font-weight:600;padding:8px;text-decoration:none}
.link:hover{text-decoration:underline}
.topbar-actions{display:flex;align-items:center;gap:4px;flex-wrap:wrap}
.ribbon{background:#e7f0f9;color:#174ea6;padding:10px 24px;font-size:13px}
html.dark .ribbon{background:#1f3b5f;color:#8ab4f8}
/* The live bar is FIXED, not sticky: as the last element on the page a
   bottom-sticky bar only pins once you scroll to the very end. */
/* ** THE PAGE IS ELASTIC -- IT USES THE WHOLE WINDOW. ** Asked 2026-10-13:
   *"can we make the starflow app wider"*, then *"or mpre elestic"*. It was
   capped at 1440px, chosen 2026-09-08 to match grantsheets.

   ** MY FIRST ANSWER WAS A BIGGER NUMBER (1920) AND THAT WAS THE WRONG SHAPE. **
   A cap is a ceiling: on a 1440 screen it changes nothing at all, and on a
   2560 one it still leaves grey down both sides. "Elastic" is a different
   request from "wider", and it is the better one for this app -- nearly every
   page here is a TABLE, and a table uses every pixel it is given.

   ** NO PROSE CAP, DELIBERATELY. ** The obvious refinement is to hold the hint
   paragraphs to a readable measure while the tables stretch. It is not done,
   because it is MY idea and not what was asked -- and this session has already
   had an invented constraint ("never beside real figures") quietly cancel a
   feature the user wanted. If the sentences read too long on a very wide
   monitor, `.hint{max-width:90ch}` is the one line that fixes it, and that is
   the user's call to make.

   The 32px side padding is what stops the content touching the window edge, and
   `.topbar` already spans the full width -- so the two now line up instead of
   the bar being wider than everything under it. The print rule below drops the
   padding too; paper is not a window. */
/* ** SUPERSEDED 2026-11-06: ONE CENTRED COLUMN. ** The user: *"can you make the
   charts and the reports line up on the pages"*. Elastic meant the charts, cards
   and filter panels ran the whole 2560px window while every table stopped at its
   1400px ceiling below -- three different right edges on one page. Offered
   "one width, centred", "one width, left" and "everything full width", they chose
   centred. The column is the table ceiling + the 32px side padding, so a chart,
   a card and a table now end on the same edge; a 1440 laptop is unchanged. The
   1760px step for 10+ column tables below is held to this column too. */
.wrap{max-width:1464px;margin:0 auto;padding:28px 32px 132px}
.label{font-size:11px;font-weight:700;letter-spacing:.8px;color:var(--muted);
  margin-bottom:14px;text-transform:uppercase}
h1{font-size:28px;font-weight:400;margin:0 0 22px}
.hint{font-size:14px;color:var(--muted);margin:0 0 14px}
.muted{color:var(--muted)}
.good{color:var(--green)}.bad{color:var(--red)}
.ok{color:var(--green);font-weight:600}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:12px;
  padding:20px 24px;box-shadow:var(--shadow);margin-bottom:16px}
.panel>summary{cursor:pointer;list-style:none}
.panel>summary::-webkit-details-marker{display:none}
.roll{width:100%;border-collapse:collapse;font-size:14px}
/* ** A SORTABLE HEADING LOOKS SORTABLE, AND A SORTED ONE SAYS WHICH WAY. **
   Asked 2026-09-28: "alls table sort by clicking and show when one is be pushed
   and whar direction its going".
   The cursor is set by public/sort.js attaching `.sortable-th`, NEVER by a blanket
   rule on every th -- grantsheets had `cursor:pointer` on all of them with only
   one table actually sorting, so most headings looked clickable and did nothing,
   which is worse than no affordance at all. */
.roll thead th.sortable-th{cursor:pointer;user-select:none}
.roll thead th.sortable-th:hover{text-decoration:underline}
.roll thead th.sortable-th:focus-visible{outline:2px solid var(--brand);outline-offset:-2px}
.roll thead th .sort-arrow{font-size:10px;opacity:.9}
/* The sorted column is marked on the HEADING and down the COLUMN, because on a
   wide table the heading alone scrolls out of sight. */
.roll thead th.sorted{background:var(--brand);color:var(--brand-ink)}

/* ** THE COLUMN HEADERS FOLLOW YOU DOWN A LONG TABLE (2026-09-28). **
   Asked: "did we make th sction header in the table follow you as you scrool t
   the next section so you know what the feild means". Sticking to the top of the
   `.table-scroll` wrapper, which is what that wrapper being a scroll container
   makes possible -- see the max-height note on it below.
   The background MUST stay opaque or the rows show through it as they pass. */
.roll thead th{background:var(--thead-bg);color:var(--thead-ink);text-align:left;
  padding:10px;font-size:12px;font-weight:700;letter-spacing:.3px;
  position:sticky;top:0;z-index:2}
.roll tbody td{padding:8px 10px;border-bottom:1px solid var(--line)}

/* ** THE BUDGET FORM'S COLUMN WIDTHS. **
   Asked 2026-09-28: *"in the staff g1 form can we make the number of day feild
   much smalller and give more padding to staff name and room type"*. Every
   input in these tables is `.cell { width: 100% }`, so an auto-layout table gave
   a two-digit **Days** box the same room as a person's NAME, and sized the room
   **Kind** picker — which holds "Multi-purpose room" — like the one-letter
   weekday boxes beside it.
   Pinned on the narrow columns only, with Name and Kind left to take what is
   over. `table-layout` is deliberately NOT set to `fixed`: these widths are then
   preferences rather than commands, so a long room label still expands instead
   of being clipped, and a phone falls back to ordinary auto behaviour inside the
   `.table-scroll` wrapper it already has. */
/* ** THE NUMBER SPINNER ATE THE BOX, AND THE BOX THEN SHOWED HALF A FIGURE. **
   Reported 2026-10-11: *"all hours show 1 and I dont see any cost"*, after
   *"the days all show 1"*. Nothing was miscalculated — the Days box held **18**
   and rendered as **1**, because a `type="number"` input reserves room for its
   own up/down spinner INSIDE the content box. Measured in the live browser on
   a payroll-filled budget: the Days input had **40px of content and needed
   49px**, so the second digit was clipped off; `Hrs / day` had 52px and needed
   173px for `0.2222222222222222`, so it read `0.2`.
   ** SUPPRESSING THE SPINNER FIXES BOTH AT THE WIDTHS ALREADY AGREED. **
   Re-measured with the rule applied, same 62px column: Days needs 40px and has
   40px — not clipped — and the hours read `0.222`, `3.972`, `4.277` instead of
   one digit. That matters because the 2026-09-28 request was to make the Days
   field *"much smalller"*, and widening the column back would undo it; this
   gives the digits room without taking the width back.
   ** SCOPED TO THE BUDGET FORM'S TWO TABLES, which is where it was measured. **
   Typing is the normal path in a costing grid and the arrows are not what
   anybody reaches for; `appearance:textfield` is stated as well as the WebKit
   pseudo-elements, because neither alone covers every browser. */
.roll.st-cols input[type=number].cell { -moz-appearance: textfield; appearance: textfield; }
.roll.st-cols input[type=number].cell::-webkit-outer-spin-button,
.roll.st-cols input[type=number].cell::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

.roll.st-cols col.c-tiny  { width: 74px; }
.roll.st-cols col.c-days  { width: 62px; }   /* the one that was asked for */
.roll.st-cols col.c-wday  { width: 62px; }
.roll.st-cols col.c-rate  { width: 92px; }
.roll.st-cols col.c-runs  { width: 104px; }
.roll.st-cols col.c-group { width: 136px; }
.roll.st-cols col.c-kind  { width: 215px; }  /* measured: "Auditorium / MPR — $94.64/hr" is 28 chars */
.roll.st-cols col.c-pay   { width: 116px; }
.roll.st-cols col.c-x     { width: 46px; }
/* ** NAME TAKES THE SLACK, BUT NOT ALL OF IT. ** Measured at desktop width the
   staff Name column had grown to 616px — far more than a person's name needs, and
   it left the table looking lopsided with everything else crammed to the right.
   Capped, so the surplus goes back to the row rather than into one box. */
.roll.st-cols col.c-name  { width: auto; min-width: 190px; max-width: 340px; }
/* ** THE ROOM NAME IS ITS OWN COLUMN, WITH MORE ROOM. ** Asked 2026-09-28:
   *"can we use the of the name space to incarease the room name space"*. A room
   label is "Auditorium (upstairs)" or "Room 12 / MPR", routinely longer than a
   person's name, and it shares its table with five weekday boxes and a Kind
   picker — so it needs its minimum stated rather than taking whatever is left. */
.roll.st-cols col.c-roomname { width: auto; min-width: 260px; }

/* ** AND THE PADDING THEY ASKED FOR, ON THOSE TWO COLUMNS ONLY. **
   Width alone still leaves the text against the cell edge. The narrow numeric
   cells keep the tighter padding — they were not the complaint, and widening
   them would undo the point. */
   ** THE ROOM KIND IS THE THIRD CELL, NOT THE FIRST **, so a `:first-child`
   rule reaches the Name column and silently misses the picker that was half the
   request. Both are named by class on the cell instead. */
.roll.st-cols .pad-wide { padding-left: 14px; padding-right: 14px; }
.roll.st-cols .pad-wide .cell { padding-left: 11px; padding-right: 11px; }
.roll tbody tr:nth-child(even){background:var(--stripe)}
.roll tr.sub td{background:var(--chip);font-size:13px}
/* A section's own total, at the foot of its table. `th` as well as `td`: the
   footers were changed to header cells so they read as a total rather than as
   one more row of data. */
.roll tr.total td, .roll tr.total th,
.table-scroll tr.total td, .table-scroll tr.total th{border-top:3px solid var(--brand);font-size:16px;
  background:var(--chip);color:var(--ink);text-align:left;letter-spacing:0;font-weight:700;padding:12px 10px}
.roll tfoot tr.total th:first-child{text-transform:uppercase;font-size:12px;letter-spacing:.8px;color:var(--muted)}
/* ** THE FIGURE READS AS A TOTAL WHEREVER THE ROW WAS WRITTEN. ** This was
   `tfoot th` only, so `views/payroll-import.js`'s eight tbody total rows drew
   their figures at 16px under a page where every other total draws at 18. The
   LABEL is deliberately NOT unified: those rows carry a plain bold word where a
   tfoot carries a small-caps one, and shrinking eight existing labels to 12px
   muted is a cosmetic change nobody asked for. */
.roll tfoot tr.total th.px, .roll tr.total td.px, .roll tr.total th.px,
.table-scroll tr.total td.px, .table-scroll tr.total th.px,
.table-scroll tr.total td.num, .table-scroll tr.total th.num{text-align:right;font-size:18px}
.roll tfoot tr.total th.muted{font-weight:400;font-size:13px}
/* ** THE SIGN MUST SURVIVE THE TOTAL ROW (2026-10-15). **
   Reported: "the bottom number all get messed us and cant hard to tell if
   postive or neg".

   The rule above sets `color:var(--ink)` on `.roll tr.total th`, which is
   specificity (0,2,2) -- two classes, two types. `.good{color:var(--green)}`
   is (0,1,0). So the total row WON and repainted every figure in plain ink:
   the green and the red were dead in EVERY tr.total in the app, and the one
   number that most needs its sign -- the bottom line -- was the only one in
   the table without it. The body rows were coloured all along, which is what
   made it read as the footer being broken rather than as a missing rule.

   Measured at the time: 21 sign-coloured cells across six views, all ink --
   views/clusters.js, pnl.js, programmes.js, bulk-budgets.js, site-report.js.
   That includes /pnl's grand total, where this app's own "negatives red,
   positives green" decision is recorded.

   (0,3,1) here, so it beats (0,2,2) without needing !important -- and it is
   placed AFTER the rule it corrects, so it wins on order too even if somebody
   later changes either selector's weight. Both palettes come for free: the
   colours are variables and dark mode redefines them. */
.roll tr.total .good, .table-scroll tr.total .good{color:var(--green)}
.roll tr.total .bad,  .table-scroll tr.total .bad{color:var(--red)}
.px.out{text-align:right;color:var(--muted)}
.mid{text-align:center}
.cell{width:100%;padding:7px 9px;border:1px solid var(--line);border-radius:6px;
  background:var(--surface);color:var(--ink);font:inherit;font-size:14px}
.cell:focus{outline:2px solid var(--brand);outline-offset:1px}
.field{display:flex;flex-direction:column;gap:5px;font-size:13px;color:var(--muted);margin-bottom:12px}
.field small{font-size:12px}
.row3{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0 18px}
.rate-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:0 24px}
.rate-group{padding:4px 0 8px}
.field.rate{display:grid;grid-template-columns:1fr auto 92px;align-items:center;gap:8px;margin-bottom:8px}
.field.rate span:first-child{color:var(--ink);font-size:13px}
.unit{color:var(--muted);font-size:12px}
.tag{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700}
.tag.warn{background:#fdefe1;color:var(--orange)}
html.dark .tag.warn{background:#43301c}
/* The ledger's 'purge' row -- the one action it records that cannot be undone,
   so it reads differently from 'delete' at a glance. */
.tag.danger{background:var(--red-bg);color:var(--red)}
.btn{border:1px solid var(--brand);background:var(--surface);color:var(--brand);
  border-radius:8px;padding:9px 16px;font:inherit;font-weight:600;cursor:pointer;text-decoration:none;
  display:inline-block}
.btn:hover{background:var(--chip)}
.btn.primary{background:var(--brand);color:#fff}
.btn.tiny{padding:3px 9px;font-size:12px}
/* The bin's "Delete forever" -- 2026-09-16, the one destructive action
   in this app with no undo behind it. Red on purpose, matching --red used for
   negative figures everywhere else, so it reads as serious before it is read
   at all. */
.btn.danger{border-color:var(--red);background:var(--red-bg);color:var(--red)}
.btn.danger:hover{background:var(--red);color:#fff}
.actions{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}
/* ** WIDE TABLES SCROLL INSIDE THEIR OWN BOX, not sideways off the page. **
   `.table-scroll` is used in 25 views -- budgets, the P&L, payments, staff,
   rooms, camps, the imports -- and was defined NOWHERE until 2026-09-25, so
   every one of them overflowed at narrow widths. One rule, centrally: 25
   scoped copies would be 25 things to drift. */
/* ** `max-height` IS WHAT MAKES THE HEADERS STICK, AND IT IS NOT DECORATION. **
   `position:sticky` sticks to the nearest SCROLLING ANCESTOR, and
   `overflow-x:auto` already made this one -- CSS computes the other axis from
   `visible` to `auto` when either is not visible. Without a height the wrapper is
   exactly as tall as its table, so it never scrolls, so the header never sticks
   however the page moves. With one, a long table scrolls INSIDE the wrapper and
   its header stays put above the rows.
   A table shorter than this is untouched: it simply never reaches the height, so
   there is no scrollbar and nothing looks different. */
.table-scroll{overflow:auto;-webkit-overflow-scrolling:touch;max-width:100%;max-height:78vh}
.login{max-width:420px;margin:60px auto}
.livebar{position:fixed;left:0;right:0;bottom:0;display:flex;gap:36px;justify-content:center;
  align-items:center;padding:14px 24px;background:var(--surface);border-top:1px solid var(--line);
  box-shadow:0 -2px 10px rgba(0,0,0,.08);opacity:0;transition:opacity .15s}
.livebar.ready{opacity:1}
.livebar b{font-size:20px}
.lb-label{display:block;font-size:11px;color:var(--muted);font-weight:700;letter-spacing:.4px;
  text-transform:uppercase}
@media(max-width:720px){
  .wrap{padding:16px 14px 140px}
  .livebar{gap:18px;padding:10px 12px}
  .livebar b{font-size:16px}
}

/* ---- cluster cards ---- */
/* THREE TO A ROW once there is room for them (asked 2026-09-12). auto-fit put
   all six on one line on a wide screen, so "the bottom row" did not exist. */
.cluster-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin-bottom:18px}
@media (min-width:760px){.cluster-grid{grid-template-columns:repeat(3,1fr)}}
.cluster-card{display:block;background:var(--surface);border:1px solid var(--line);
  border-left:5px solid var(--brand);border-radius:12px;padding:18px 20px;box-shadow:var(--shadow);
  text-decoration:none;color:inherit}
.cluster-card:hover{background:var(--chip)}
.cluster-card.muted-card{border-left-color:var(--muted)}
.cluster-name{font-size:19px;margin-bottom:8px}
/* The cluster icon (asked 2026-09-24: "can we add icon to all clusters like
   nova"). It is its OWN element rather than part of the name, because the
   cluster tests enumerate `.cluster-name` and `<h1>` -- including two that
   assert a cluster is absent, which a decoration inside the text would make
   pass vacuously. */
.cluster-head{display:flex;align-items:baseline;gap:8px}
.cluster-head h1{margin-top:0}
.cluster-icon{font-size:19px;line-height:1;flex:none}
/* A LETTER MARK (the Dodgers' LA for Westchester) is set tight and heavy so it
   reads as a monogram beside the emoji, rather than as a word. */
.cluster-icon.mark{font-weight:800;letter-spacing:-.06em;font-size:18px}
/* A MARK SET ON A BALL (asked 2026-09-24: "can you put that on a ball"). Drawn
   here rather than as an image, so there is no file to host and nothing to go
   missing: a white disc, the monogram in its own colour, and two red stitch
   arcs made from one border edge each. The ball stays white in dark mode --
   a baseball is white. */
.cluster-icon.ball,.cluster-icon.circle{position:relative;display:inline-flex;align-items:center;
  justify-content:center;width:28px;height:28px;border-radius:50%;
  font-size:11px;letter-spacing:-.08em;overflow:hidden}
/* `circle` is an OUTLINE in the mark's own colour -- the ring takes currentColor
   so it can never disagree with the letters inside it. */
.cluster-icon.circle{border:2px solid currentColor}
.cluster-icon.long{font-size:8.5px;letter-spacing:-.04em}
/* `ball` is the filled, stitched version: a white disc with two red arcs. ONLY
   the ball is stitched. The stitches hug the RIM -- set further in they cross
   the monogram and the whole thing reads as a blob at 28px, which was checked
   in a browser rather than assumed. */
.cluster-icon.ball{background:#fff;box-shadow:inset 0 0 0 1px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.2)}
.cluster-icon.ball::before,.cluster-icon.ball::after{content:"";position:absolute;top:-16%;height:132%;
  width:52%;border-radius:50%;border:1.25px solid #c8102e;border-top-color:transparent;
  border-bottom-color:transparent;pointer-events:none}
.cluster-icon.ball::before{left:-38%;border-left-color:transparent}
.cluster-icon.ball::after{right:-38%;border-right-color:transparent}
/* Nova and Camps are drawn as cluster cards but are NOT clusters, so their
   name carries its own class -- the cluster tests enumerate `.cluster-name`
   and would otherwise count a programme as a cluster. Same look, different
   meaning. */
.prog-name{font-size:19px;margin-bottom:8px}
.cluster-meta{font-size:12px;color:var(--muted)}
/* ** ENROLLED AND ATTENDING, SIDE BY SIDE AND NEVER ADDED (2026-10-22). ** The
   card printed one blended figure -- `enrolled + attending`, which is a number
   no cluster holds -- and `/cluster/<name>` has shown the two apart all along.
   They need to read as TWO figures, so each gets its own line below 420px and
   they sit on one row above it; a single run of text is how they would be read
   back as one number again. ** A class with no rule draws as nothing ** -- seven
   recorded instances in this app, two of them today, so the rule ships with the
   markup. */
.cluster-kids{display:flex;flex-wrap:wrap;gap:4px 12px}
.cluster-kids > span{white-space:nowrap}
@media (max-width:420px){.cluster-kids{flex-direction:column;gap:2px}}
.back{color:var(--brand);font-weight:600;text-decoration:none;font-size:14px}
.back:hover{text-decoration:underline}
.tag.cl-blue{background:var(--blue);color:#fff}
.tag.cl-purple{background:var(--purple);color:#fff}
.tag.cl-green{background:var(--green);color:#fff}
.tag.cl-pink{background:var(--pink);color:#fff}

/* Staff are grouped: teachers, then enrichment, then admin, with a labelled bar
   before each. Teachers come first because they are the ONLY group that counts
   towards covering students; enrichment and admin are paid but are not cover,
   so both are tinted -- everything below the first bar reads as "not cover". */
.roll tbody tr.group-bar td{background:var(--chip);font-size:11px;font-weight:700;
  letter-spacing:.6px;color:var(--muted);padding:7px 10px;border-top:3px solid var(--line)}

/* ** A SUBTOTAL AT THE END OF EACH KIND OF STAFF ** (asked 2026-09-28). It reads
   as a summary, not as one more person: tinted, with a rule above it, and no
   bottom border so it sits with the group it closes rather than starting the
   next. The group's own colour is carried on the left edge, matching the bar
   that opened it. */
.roll tr.group-sub td { background: var(--chip); border-top: 2px solid var(--line);
  border-bottom: 0; font-size: 13px; }
/* ** THE SUBTOTAL'S FIGURES LINE UP WITH THE COLUMN THEY BELONG TO. **
   `.roll td.px` already right-aligns every number cell, but a subtotal row is
   BUILT BY live.js rather than rendered by the view — so it is worth stating here
   rather than relying on that one rule reaching a scripted row through the
   cascade. Measured 2026-09-28: the pay column's data cell and its total both sit
   at the same right edge, and the subtotal between them must too, or the row that
   exists to be compared with them is the one that does not line up. */
.roll tr.group-sub td.px { text-align: right; font-variant-numeric: tabular-nums; }
.roll tr.group-sub td:first-child { border-left: 3px solid var(--line); }
.roll tr.group-sub.g-teacher td:first-child { border-left-color: var(--blue); }
.roll tr.group-sub.g-academy td:first-child { border-left-color: var(--purple); }
.roll tr.group-sub.g-enrichment td:first-child { border-left-color: var(--purple); }
.roll tr.group-sub.g-admin td:first-child { border-left-color: var(--orange, #d97706); }
/* The striping counts rows, so a subtotal row would otherwise flip the colour of
   every row after it and the alternating pattern would look broken. */
.roll tbody tr.group-sub { background: none; }
.roll tbody tr.group-bar.g-teacher td{border-top-color:var(--green)}
.roll tbody tr.group-bar.g-enrichment td{border-top-color:var(--purple)}
.roll tbody tr.group-bar.g-admin td{border-top-color:var(--orange)}
/* SHORT HOURS -- who is barely on site, listed last in the payroll people panel.
   A bar with no rule still draws (the base .group-bar carries the background) but
   with no coloured edge, so it would not read as a block of its own. */
.roll tbody tr.group-bar.g-short td{border-top-color:var(--amber)}
/* The TOP band's bar. `--orange` and not `--amber`: the two bars sit on one
   screen and the short one is already amber, so sharing a token would make
   them read as the same group twice. Both tokens exist in BOTH palettes --
   a colour defined in only one is the `--amber` fault, where a programme tag
   drew as a transparent disc in dark mode. A class with no rule draws as
   NOTHING, recorded nine times in this repo. */
.roll tbody tr.group-bar.g-long td{border-top-color:var(--orange)}
/* CORE STAFF -- the middle band, named by the user on 2026-10-27 along with the
   other two. `--teal`, because `--amber` is part time and `--orange` is
   directors and three bars on one screen sharing two tokens would read as two
   groups. A class with no rule draws as NOTHING -- recorded nine times in this
   repo -- and this one leads the list, so it would be the first thing missing. */
.roll tbody tr.group-bar.g-core td{border-top-color:var(--teal)}
.chip-short{display:inline-block;margin-left:4px;padding:0 5px;border-radius:7px;
  background:var(--amber);color:#fff;font-size:10px;font-weight:700;line-height:15px;
  vertical-align:1px;white-space:nowrap}
.roll tbody tr.r-staff.g-enrichment,
.roll tbody tr.r-staff.g-admin{background:var(--stripe)}
.roll tfoot tr.enrich-note td{border-top:1px solid var(--line);font-size:12px;padding-top:8px}
.panel.notice{border-left:5px solid var(--orange)}
/* Which installments a school has submitted, grouped by school year. */
.submitted{display:inline-block;margin:0 10px 2px 0;font-size:13px;white-space:nowrap}
.submitted b{color:var(--muted);font-size:11px;letter-spacing:.3px}
.submitted a{text-decoration:none;padding:1px 5px;border-radius:4px;background:var(--chip)}
.submitted a:hover{background:var(--brand);color:#fff}

/* Default rooms, one row per site. */
.site-rooms{border-bottom:1px solid var(--line);padding:2px 0}
.site-rooms:last-child{border-bottom:0}
.site-rooms>summary{cursor:pointer;padding:9px 4px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;
  list-style:none}
.site-rooms>summary::-webkit-details-marker{display:none}
.site-rooms>summary:hover{background:var(--chip)}
.sr-summary{font-size:13px;color:var(--muted);flex:1}
.sr-cost{font-size:13px;color:var(--ink)}
.sr-form{padding:8px 4px 16px}
.sr-undo{padding:0 4px;align-items:center}

/* How far through the current month a cluster is. */
.prog{height:7px;border-radius:4px;background:var(--chip);overflow:hidden;margin:10px 0 6px}
.prog-fill{height:100%;background:var(--brand);border-radius:4px}
.prog.full .prog-fill{background:var(--green)}
.prog.none .prog-fill{background:var(--muted)}
.prog-label{font-size:12px}
.prog-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap;font-size:14px}
.prog-row .prog{flex:0 0 180px;margin:0}
.rate-box{width:110px;display:inline-block}
/* How many people are in each staff block, one line above the table. */
.staff-count{margin:0 0 12px;font-size:14px;color:var(--ink)}
.staff-count b{font-variant-numeric:tabular-nums}

/* Students by grade: a proportion bar and a card per band. */
.grade-bar{display:flex;height:14px;border-radius:8px;overflow:hidden;background:var(--chip);margin:0 0 16px}
.gb-seg{height:100%;transition:width .15s}
.gb-tkk{background:var(--teal)}
.gb-up{background:var(--purple)}
.gb-none{background:var(--muted);opacity:.5}
.grades{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
.grade-card{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px;
  background:var(--surface);border:1px solid var(--line);border-left:5px solid var(--line)}
.grade-card.g-tkk{border-left-color:var(--teal)}
.grade-card.g-up{border-left-color:var(--purple)}
.grade-card.g-need{border-left-color:var(--brand)}
.grade-card.g-none{border-left-color:var(--muted)}
.gc-face{font-size:30px;line-height:1}
.gc-num{font-size:26px;line-height:1.1}
.g-tkk .gc-num{color:var(--teal)}
.g-up .gc-num{color:var(--purple)}
.g-need .gc-num{color:var(--brand)}
.gc-label{font-size:12px;color:var(--muted);font-weight:600;letter-spacing:.2px}
.gc-staff{font-size:12px;color:var(--muted);margin-top:4px}
.gc-staff .good{color:var(--green);font-weight:700}
.gc-staff .bad{color:var(--red);font-weight:700}

/* Suggested against covering: the comparison somebody acts on. */
.need-chart{margin:16px 0 4px;padding:14px 16px;background:var(--stripe);border-radius:12px}
.nc-row{display:flex;align-items:center;gap:12px;margin-bottom:8px}
.nc-label{flex:0 0 190px;font-size:12px;color:var(--muted);font-weight:600}
.nc-track{flex:1;height:16px;background:var(--chip);border-radius:8px;overflow:hidden}
.nc-fill{display:block;height:100%;border-radius:8px;transition:width .15s}
.nc-need{background:var(--brand)}
.nc-have{background:var(--muted)}
.nc-have.ok{background:var(--green)}
.nc-have.short{background:var(--orange)}
.nc-row b{flex:0 0 34px;text-align:right;font-size:15px}
.nc-verdict{margin:6px 0 0;font-size:13px;color:var(--muted)}
@media(max-width:720px){.nc-label{flex:0 0 120px;font-size:11px}}

/* Uploaded reports: a preview of what arrived. */
.scroll-x{overflow-x:auto;max-width:100%}
.roll.tight tbody td{padding:4px 8px;font-size:12px;white-space:nowrap}
.rownum{text-align:right;width:34px;background:var(--stripe)}

/* Payment collection: a site's amounts are grouped, and a group opens to show
   who paid it. Collapsed by default -- twenty-four rows of the same number is
   not a report anybody reads. */
details.pay-site{border:1px solid var(--line);border-radius:8px;margin:8px 0;padding:6px 10px}
details.pay-site>summary{cursor:pointer;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:4px 0}
details.pay-site[open]>summary{border-bottom:1px solid var(--line);margin-bottom:8px}
.pay-site .sr-cost{margin-left:auto;font-weight:700}
.roll tr.pay-names>td{background:var(--stripe);padding:8px 10px}
.pay-name-list{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:13px}
.pay-name{white-space:nowrap}

/* A section you can shut. Asked 2026-09-10 -- the budget form is long, and
   most of it is settled once a site is set up. A CLOSED section still shows
   its total on the summary row, so shutting one never hides what it costs. */
details.panel.sec>summary{cursor:pointer;display:flex;align-items:center;gap:12px;
  list-style:none;margin:0 0 14px;padding:2px 0}
details.panel.sec>summary::-webkit-details-marker{display:none}
details.panel.sec>summary::before{content:'▾';color:var(--muted);font-size:13px;width:12px}
details.panel.sec:not([open])>summary{margin-bottom:0}
details.panel.sec:not([open])>summary::before{content:'▸'}
details.panel.sec>summary>.label{margin:0}
details.panel.sec>summary:hover>.label{color:var(--brand)}
.sec-peek{margin-left:auto;font-variant-numeric:tabular-nums;font-size:15px}

/* The working's two halves fold away. A folded half still shows its total on
   the header row -- hiding detail must never hide a figure. */
.roll tr.work-head{cursor:pointer}
.roll tr.work-head:hover td{background:var(--brand);color:#fff}
.roll tr.work-head:hover td .muted{color:#fff;opacity:.8}

/* The Uploads hub (/uploads-all) — asked 2026-09-11. Four cards, each naming
   what it CHANGES, because two nav items that read alike did different things. */
.imp-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }
.imp { display: flex; flex-direction: column; }
.imp .imp-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.imp .pill {
  font-size: 12px; padding: 2px 9px; border-radius: 999px;
  background: var(--chip, #eee); color: var(--muted); white-space: nowrap;
}
.imp .imp-changes { border-left: 3px solid var(--brand, #6b3fa0); padding-left: 10px; }
.imp .actions { margin-top: auto; padding-top: 10px; }
.imp.not-ready { opacity: .92; }
.imp.not-ready .imp-note { border-left: 3px solid #c9a227; padding-left: 10px; }
.btn.disabled { opacity: .5; cursor: not-allowed; pointer-events: none; }
.ribbon.bad { background: #b3261e; color: #fff; }
.ribbon.bad .link { color: #fff; text-decoration: underline; }

/* Academy staff — the second cover group, added 2026-09-11. It sits with the
   G1 block above the enrichment bar: both cover students, and a school runs
   one or the other, never both. */
.roll tbody tr.group-bar.g-academy td{border-top-color:var(--green)}

/* Google sign-in on the login page (asked 2026-09-11). It leads; the shared
   password sits under a divider until everyone has a work account. */
.login .btn.google { display: block; text-align: center; text-decoration: none; margin-bottom: 4px; }
.login .hint.or { display: flex; align-items: center; gap: 10px; margin: 14px 0 10px; color: var(--muted); }
.login .hint.or::before, .login .hint.or::after {
  content: ''; flex: 1; height: 1px; background: var(--line, #ddd);
}

/* Roles (asked 2026-09-11). A cluster leader sees a cut-down top bar, and the
   bar says which cluster -- otherwise "where did Settings go" is a mystery. */
.topbar-actions .who { opacity: .75; cursor: default; }
.tag.r-admin { background: #6b3fa0; color: #fff; }
.tag.r-cluster { background: #e8def8; color: #4a2d6b; }
.panel.notice.warn { border-left: 4px solid #b26b00; }

/* A rate nobody has typed in yet. The settings figure is used, and the box says
   so rather than showing it as though somebody had entered it. */
.cell.assumed::placeholder { color: var(--muted); opacity: .7; font-style: italic; }

/* The at-a-glance staff table: one line per site, so twenty-five panels do not
   have to be opened to compare them. */
.roll.stats tbody tr.ri-empty td { color: var(--muted); }
.roll.stats tfoot tr.total th { border-top: 3px solid var(--brand, #6b3fa0); font-weight: 700; }

/* Earlier versions of a site's staff, kept each time a report replaced it. */
.versions { margin-top: 14px; padding-top: 10px; border-top: 1px dashed var(--line, #ddd); }
.versions .v-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 3px 0; }

/* Staffing against the ratio: a bar per site, over in green and short in red.
   The widest sets the scale so one very short site does not flatten the rest. */
.sbar { display: inline-block; height: 12px; border-radius: 3px; min-width: 2px; }
.sbar.up { background: var(--green, #1e8e3e); }
.sbar.down { background: #b3261e; }
.tag.r-nomoney { background: #fff3e0; color: #8a4b00; }
.tag.r-site { background: #e3f2e8; color: #1e5a30; }

/* ---- the daily count form: filled in on a phone, standing up ----
   Big targets, one column, and totals that add up as you type. Every number is
   typed: the last count sits under the label as a quiet note, never in the box.
*/
.cnt-hero {
  background: var(--brand, #6b3fa0); color: #fff; border-radius: 12px;
  padding: 16px 18px; margin-bottom: 16px;
}
.cnt-site { font-size: 24px; font-weight: 700; line-height: 1.15; }
.cnt-day { font-size: 16px; opacity: .95; margin-top: 2px; }
.cnt-where { font-size: 13px; opacity: .8; margin-top: 4px; letter-spacing: .03em; }

.cnt-panel { padding: 16px; }
.cnt-h { font-size: 19px; margin: 0 0 2px; }
.cnt-sub { font-size: 13px; color: var(--muted); margin: 0 0 14px; line-height: 1.45; }

.cnt-block { border: 1px solid var(--line, #e4e0ea); border-radius: 10px; margin-bottom: 12px; }
.cnt-block-head {
  background: var(--chip, #f4f1f8); padding: 8px 14px; font-weight: 700; font-size: 13px;
  letter-spacing: .06em; text-transform: uppercase; border-radius: 9px 9px 0 0;
}
.cnt-row {
  display: flex; align-items: center; justify-content: space-between; gap: 14px;
  padding: 12px 14px; border-top: 1px solid var(--line, #efecf3);
}
.cnt-text { display: flex; flex-direction: column; min-width: 0; }
.cnt-label { font-size: 17px; line-height: 1.25; }
.cnt-last { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
/* 64px tall: a thumb target, not a desktop input. */
.cnt-box {
  width: 96px; min-height: 52px; font-size: 22px; font-weight: 600; text-align: center;
  border: 2px solid var(--line, #d8d2e0); border-radius: 10px; background: #fff;
  flex: 0 0 auto; padding: 4px;
}
.cnt-box:focus { border-color: var(--brand, #6b3fa0); outline: none; }
.cnt-sum {
  padding: 9px 14px; border-top: 1px solid var(--line, #efecf3);
  text-align: right; font-size: 13px; color: var(--muted);
}
.cnt-sum b { font-size: 17px; color: inherit; }

.cnt-foot { font-size: 13px; color: var(--muted); line-height: 1.5; margin: 4px 2px 0; }
/* Pinned to the bottom on a phone: the form is longer than a screen, and a save
   button you have to scroll to find is a form that does not get sent. */
.cnt-save { width: 100%; padding: 17px; font-size: 18px; margin-top: 16px; border-radius: 10px; }
.ok-panel { border-left: 4px solid var(--green, #1e8e3e); }

@media (max-width: 560px) {
  .cnt-panel { padding: 12px; }
  .cnt-label { font-size: 16px; }
  .cnt-box { width: 86px; }
  .cnt-save { position: sticky; bottom: 12px; box-shadow: 0 4px 16px rgba(0,0,0,.18); }
}

/* ---- the saved record, laid out like the LIC 500 forms ---- */
.panel.lic { background: #fff; color: #111; }
.lic-head { text-align: center; border-bottom: 2px solid #111; padding-bottom: 8px; margin-bottom: 12px; }
.lic-title { font-size: 19px; font-weight: 700; letter-spacing: .06em; }
.lic-sub { font-size: 11px; letter-spacing: .12em; color: #555; }
.lic-meta { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
.lic-meta th, .lic-meta td { border: 1px solid #999; padding: 6px 8px; font-size: 13px; text-align: left; }
.lic-meta th { background: #f2f2f2; font-weight: 600; width: 18%; white-space: nowrap; }
.lic-band {
  background: #111; color: #fff; font-size: 12px; font-weight: 700;
  letter-spacing: .08em; padding: 5px 8px; margin: 14px 0 0;
}
.lic-grid { width: 100%; border-collapse: collapse; }
.lic-grid th, .lic-grid td { border: 1px solid #999; padding: 7px 8px; font-size: 13px; text-align: center; }
.lic-grid thead th { background: #f2f2f2; }
.lic-grid tbody th { background: #fafafa; text-align: left; width: 26%; }
.lic-grid tr.lic-total td, .lic-grid tr.lic-total th { background: #f2f2f2; font-weight: 700; }
.lic-note { font-size: 11px; color: #555; margin: 8px 0 14px; }
.lic-sign th { width: 18%; }

@media print {
  .no-print, .topbar, .help { display: none !important; }
  .wrap { padding: 0; max-width: none; }
  .panel.lic { border: 0; box-shadow: none; padding: 0; }
  /* ** ⚠ A CAPPED SCROLL BOX PRINTS AS A CLIPPED TABLE, AND SILENTLY. **
     The LIC 500 staff form and the daily count record are both PRINTED from this
     app, and a printed page that quietly loses rows is the worst failure it has.
     The cap and the sticky header are both undone here: a paper page has no
     scroll and does not need a header that follows. */
  .table-scroll { max-height: none !important; overflow: visible !important; }
  .roll thead th { position: static !important; }
  /* ⚠⚠ NOT ON ANYTHING PRINTED. The LIC 500 staff form and the daily count
     record go to the district ON PAPER, and a cat on an official staffing
     document undermines the document. One rule, everywhere the cat can ever
     be used -- it does not depend on which page it is on. */
  .site-cat { display: none !important; }
}


/* "Checked" on a staff row: somebody looked at it and says it is right. Most of
   these were read out of reports, several of which parsed only partly, so read
   and confirmed must never look alike. */
.roll tr.ok-row td { background: rgba(30,142,62,.05); }
.tick { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
.tick span { color: var(--green, #1e8e3e); font-weight: 700; }
.ok-tag { color: var(--green, #1e8e3e); font-size: 12px; font-weight: 600; }
.lic-people th, .lic-people td { font-size: 12px; padding: 5px 6px; }
.lic-people .lic-name { text-align: left; font-weight: 600; }
.lic-people tbody th { width: auto; }
/* A row nobody has ticked: shaded so it is easy to find before sending. */
.lic-unchecked td { background: #fff8e1; }

/* Asking the sites for a form: pick which, copy the message. */
.pick { display: flex; gap: 10px; align-items: flex-start; padding: 9px 0; }
.pick input { margin-top: 3px; }
.ask-msg { font-family: ui-monospace, Menlo, monospace; font-size: 13px; line-height: 1.5; width: 100%; }

/* THE TOP BAR'S DROPDOWNS (2026-09-12). Plain <details>, no JavaScript: the nav
   is how you reach every page, so it must work when a script does not. */
.navgroup{position:relative;display:inline-block}
.navgroup>summary{list-style:none;cursor:pointer;user-select:none}
.navgroup>summary::-webkit-details-marker{display:none}
.navgroup .caret{opacity:.6;font-size:11px}
.navgroup[open]>summary{background:rgba(255,255,255,.18);border-radius:6px}
.navmenu{position:absolute;top:100%;left:0;z-index:40;min-width:220px;
  background:var(--surface);border:1px solid var(--line);border-radius:10px;
  box-shadow:0 10px 30px rgba(0,0,0,.18);padding:6px;margin-top:6px}
.navmenu a{display:block;padding:9px 12px;border-radius:7px;color:var(--ink);
  text-decoration:none;font-size:14px;white-space:nowrap}
.navmenu a:hover{background:var(--stripe)}
.navmenu .navsub{padding:10px 12px 4px;font-size:11px;font-weight:700;letter-spacing:.06em;
  text-transform:uppercase;color:var(--muted);border-top:1px solid var(--line);margin-top:4px}
.navmenu > .navsub:first-child{border-top:0;margin-top:0;padding-top:4px}
.navmenu .navfold>summary{list-style:none;cursor:pointer;user-select:none;border-radius:7px}
.navmenu .navfold>summary::-webkit-details-marker{display:none}
.navmenu .navfold>summary::after{content:' \25B8';opacity:.6}
.navmenu .navfold[open]>summary::after{content:' \25BE'}
.navmenu .navfold>summary:hover{background:var(--stripe)}
.navmenu .navfold:first-child>summary{border-top:0;margin-top:0;padding-top:4px}
.navmenu .navcount{font-weight:600;opacity:.7;letter-spacing:0}
.navmenu .navfold>a{padding-left:20px}
/* On a phone the bar wraps, so a dropdown pinned to the viewport edge would run
   off it: the OPEN group takes the full width and its menu sits in the flow.
   ** CLOSED GROUPS SIT SIDE BY SIDE (2026-11-06). ** Every group full width
   stacked seven rows -- 296px of menu before any content on a 390px phone. */
@media (max-width:720px){
  .navgroup{display:block;width:auto}
  .navgroup[open]{width:100%}
  .navmenu{position:static;box-shadow:none;min-width:0}
}

/* The settings page splits into ELOP and G1/Academy (2026-09-12). The heading
   has to read as a divider between two different kinds of money, not as one
   more panel label. */
.area-head{font-size:15px;margin:28px 0 6px;padding-top:14px;border-top:3px solid var(--brand)}
.area-head:first-of-type{margin-top:8px}

/* The ELOP freshness panel (2026-09-12): a label/value table, not a data grid.
   Its <th> are row headings in the body, so they must not pick up the brand
   header bar that `.roll thead th` carries. */
.freshness th{text-align:left;width:210px;color:var(--muted);font-weight:600;
  background:none;white-space:nowrap}
@media (max-width:560px){.freshness th{width:auto}}

/* The ELOP look-back picker (2026-09-12): an installment or two dates, on one
   line where there is room and stacked on a phone. */
.lookback{display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap}
.lookback .field{flex:1 1 180px;margin:0}
.lookback button{flex:0 0 auto}

/* "whole row" under the Pay / Cost headers (2026-09-12): a row of three rooms
   costs three rooms, and the figure never said so. */
.th-note{font-weight:400;font-size:10px;opacity:.75;text-transform:none;letter-spacing:0}

/* The ELOP attendance summary (2026-09-12): what attended, by grade group, for
   the period -- rendered by the server so it is there before any script runs. */
.elop-totals .cell.big{font-size:26px;font-weight:700;background:var(--chip);line-height:1.2}
.elop-totals .cell.big small{font-size:12px;font-weight:400;color:var(--muted);display:block}

/* The programme switch on a budget for a site that runs two (2026-09-13). They
   are separate budgets, so this is a link, never a control that looks like it
   changes a setting on the one you are editing. */
.prog-switch{background:var(--chip);padding:10px 14px;border-radius:10px}
.prog-on{font-weight:700;background:var(--brand);color:#fff;padding:2px 10px;border-radius:20px}
.prog-off{padding:2px 10px;border-radius:20px;border:1px solid var(--line);text-decoration:none}

/* Subtotals per kind of room, under the room table (asked 2026-09-16). Quieter
   than the table above it -- it summarises those rows, it does not compete. */
/* ** ONE LITTLE BUDGET PER NOVA CLASS. **
   Asked 2026-09-29: each class as its own micro budget -- its hours with the
   children in each, the teacher across the block, then the room. Built by
   live.js as NODES (it has no esc(), and a subject is free text off a portal
   export), so every class here is a class that file sets.

   ** A GRID OF CARDS, NOT A TABLE. ** A table row cannot hold two hours each
   with their own enrolment, which is the whole reason this replaced one. It
   wraps rather than scrolling sideways: on a phone one card fills the width,
   and NOTHING gets a min-width wider than the screen. */
/* ** THE BLOCK / CLASS-HOUR TOGGLE (2026-10-01). ** Above the cards, because it
   says what you are about to read. It is an ordinary checkbox and label with no
   `name`, so it never reaches the posted body; the SWITCHING is JavaScript, which
   is sound here because the cards are painted by JavaScript anyway -- with no
   script there are no cards at all, so there is nothing for CSS to switch
   between. That is deliberately NOT the pay mask's pure-CSS pattern, which has to
   survive a dead script because a figure you cannot reveal is one you cannot
   edit. */
.nova-view{margin:0 0 8px;font-size:13px}
.nova-view label{display:inline-flex;align-items:center;gap:6px;cursor:pointer}
.nova-micro{display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-top:4px}
.nova-micro .micro{border:1px solid var(--line);border-radius:8px;background:var(--surface);padding:10px 12px}
.nova-micro .micro-head{font-size:14px;margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid var(--line)}
/* Label left, figure right. `gap` rather than a fixed label width: a subject can
   be "Cooking: Small Bites, Big Flavors!" and must wrap rather than push the
   figure off the card. */
.nova-micro .micro-line{display:flex;justify-content:space-between;gap:10px;font-size:13px;padding:2px 0}
/* The site summary at the end of the class cards (2026-10-01): every class at
   this school, the teachers and rooms it uses, and what they come to. Styled as
   a card but NOT given the .micro class, because a test counts those to assert
   one micro-budget per class and a summary is not a class. */
.nova-micro .micro-summary{border:2px solid var(--brand);border-radius:8px;background:var(--stripe);
  padding:10px 12px;grid-column:1/-1}
.nova-micro .micro-summary .micro-head{font-weight:700}
/* The editable rate on a class card: narrow, right-aligned, digits lined up, so
   a column of them can be read down. */
.nova-micro .micro-rate-box{width:7.5em;text-align:right;font-variant-numeric:tabular-nums;
  padding:2px 6px;font-size:13px}
.nova-micro .micro-label{color:var(--muted)}
.nova-micro .micro-value{text-align:right;white-space:nowrap}
/* The hours are what the class IS; the teacher and room are what it costs. A
   rule between the two says which half you are reading without a heading. */
.nova-micro .micro-staff{border-top:1px solid var(--line);margin-top:4px;padding-top:6px}
.nova-micro .micro-total{border-top:2px solid var(--brand);margin-top:4px;padding-top:6px}
.nova-micro .micro-total .micro-label{color:var(--ink);font-weight:600}
/* ** THE INCOME SITS ABOVE THE COSTS AND IS MARKED OFF FROM THEM. ** What came
   in and what it cost are two different questions, and the card answers them in
   that order. */
.nova-micro .micro-income{border-top:1px solid var(--line);margin-top:4px;padding-top:6px}
.nova-micro .micro-income .micro-label{color:var(--ink);font-weight:600}
/* ** THE CLASS'S OWN RESULT, which is the line somebody acts on. ** It is red
   or green by SIGN -- the app's standing rule -- and the .good/.bad colouring
   above already does that; this only gives it the weight of a conclusion. */
.nova-micro .micro-result .micro-label{color:var(--ink);font-weight:600}
/* ** WHAT THE CARDS COME TO, BEFORE THE REMAINDER IS ADDED. ** A sub-total
   inside the summary, so the column above the whole-budget figure reads as an
   addition rather than as two competing totals. */
.nova-micro .micro-cards{border-top:1px solid var(--line);margin-top:4px;padding-top:6px}
/* ** THE REMAINDER, AND WHY IT EXISTS. ** `micro-apart` is money that belongs to
   no class -- it is not an error, so it reads as an answer; `micro-why` carries
   the names `unclaimed()` recorded for it, which wrap because they are
   sentences rather than figures. */
.nova-micro .micro-apart .micro-label{color:var(--ink);font-weight:600}
.nova-micro .micro-why{font-size:12px;padding:0 0 2px}
.nova-micro .micro-why .micro-value{text-align:left;white-space:normal;color:var(--muted)}
/* A shared room, and a room whose rent is in an annual fee, are NOT errors --
   they are answers, so they read as quiet rather than as warnings. */
.nova-micro .micro-line.muted .micro-value{color:var(--muted);white-space:normal}
.nova-micro .micro-line.good .micro-value{color:var(--green)}
.nova-micro .micro-line.bad .micro-value{color:var(--red)}
/* A capped scroll box prints as a card cut off with nothing to say so, and this
   app is printed. Paper does not scroll: one column, no card split across a
   page. */
@media print{.nova-micro{grid-template-columns:1fr}.nova-micro .micro{break-inside:avoid}}

.room-groups { margin-top: 10px; }
.room-groups .rg { font-size: 13px; }
.room-groups .rg th { font-weight: 600; }

/* ** SETTINGS, SIDE BY SIDE (2026-09-16). **
   Asked: "make the same options exactly for both elop and g1/a, maybe show side
   by side". Comparing the two columns IS the job of that page -- the whole
   reason ELOP has its own figures is that they should be LOWER -- so the eye
   has to be able to tell at a glance which box belongs to which programme. */
.roll.side th:nth-child(2),.roll.side td:nth-child(2){border-left:2px solid var(--line)}
.roll.side th:nth-child(3),.roll.side td:nth-child(3){border-left:2px solid var(--brand)}
.roll.side th:nth-child(4),.roll.side td:nth-child(4){border-left:2px solid var(--line)}
.roll.side .rate-box{width:96px}
.roll.side td:first-child{min-width:170px}

/* ** A BUTTON THAT STARTS A PROGRAMME WEARS THAT PROGRAMME'S COLOUR. **
   Asked 2026-09-16: "make the buttons in budget the same as the green and blue
   you used to mark the program". The tags beside a school were already blue for
   G1, purple for Academy and green for ELOP while the buttons that start those
   budgets were all one colour — so the thing you press to make an ELOP budget
   looked nothing like the thing that says ELOP. Same three variables the tags
   use, so they cannot drift apart. */
.btn.p-g1{border-color:var(--blue);color:var(--blue)}
.btn.p-academy{border-color:var(--purple);color:var(--purple)}
.btn.p-elop{border-color:var(--green);color:var(--green)}
.btn.p-g1:hover,.btn.primary.p-g1{background:var(--blue);color:#fff}
.btn.p-academy:hover,.btn.primary.p-academy{background:var(--purple);color:#fff}
.btn.p-elop:hover,.btn.primary.p-elop{background:var(--green);color:#fff}
/* Nova enrichment — its registry tag is cl-pink, and these must agree or a
   programme reads as one colour on a tag and another on its button. */
.btn.p-nova{border-color:var(--pink);color:var(--pink)}
.btn.p-nova:hover,.btn.primary.p-nova{background:var(--pink);color:#fff}

/* The programme switch on a budget form, for a site that runs two. The one you
   are editing is filled in its own colour rather than the generic brand. */
.prog-on.p-nova{background:var(--pink)}
.prog-on.p-g1{background:var(--blue)}
.prog-on.p-academy{background:var(--purple)}
.prog-on.p-elop{background:var(--green)}
.prog-off.p-g1{color:var(--blue);border-color:var(--blue)}
.prog-off.p-academy{color:var(--purple);border-color:var(--purple)}
.prog-off.p-elop{color:var(--green);border-color:var(--green)}

/* A PAY RATE IS HIDDEN UNTIL ASKED FOR (2026-09-17: "start by default as xxx
   then click view toggle if needed"). This page lists a whole cluster at once,
   and a rate on screen is a rate over somebody's shoulder.

   ** PURE CSS, ON PURPOSE. ** A rate you cannot reveal is a rate you cannot
   edit, so a failed script must not make this page read-only for pay. The
   checkbox carries no `name`, so it posts nothing; the real input is only
   hidden, and a hidden input still submits -- leaving a rate masked can never
   lose it. Same reasoning that made the nav plain <details>. */
.rate-cell { white-space: nowrap; }
.rate-peek {
  display: inline-block; cursor: pointer; letter-spacing: .12em;
  padding: .3rem .5rem; border: 1px dashed var(--line); border-radius: 6px;
  color: var(--muted); font-family: inherit; user-select: none;
}
.rate-peek:hover { border-style: solid; color: var(--ink); }
.rate-toggle:checked ~ .rate-peek { display: none; }
.rate-toggle:not(:checked) ~ .rate-real { display: none; }
/* ** ONE CONTROL THAT SHOWS EVERY RATE ON /staff AT ONCE (2026-10-30). **
   Asked for directly. Every rate was already revealable one click at a time --
   572 of them on the live page -- and the figures are already in the page for
   anybody the control renders for, so this reveals nothing new.

   ** IT MUST COME AFTER THE TWO RULES ABOVE, and that is not a style
   preference. ** `.rate-toggle:not(:checked) ~ .rate-real{display:none}` is
   specificity (0,3,0); these are (0,4,0), so they win on specificity -- but
   writing them ABOVE would still be fragile the day somebody tightens the other
   rule. The ordering fault that made today's programme colours never draw was
   exactly this, one file over, and it cost a deploy to notice.

   ** `revert` RATHER THAN A DISPLAY VALUE TYPED HERE: ** the real input is an
   `input.cell` and the per-cell toggle shows it by simply not hiding it, so it
   has no display of its own to copy. Typing one in would make the master reveal
   lay the field out differently from the per-cell one. */
.sf-reveal-box:checked ~ .panel .rate-peek { display: none; }
.sf-reveal-box:checked ~ .panel .rate-real { display: revert; }
.sf-reveal {
  display: inline-block; margin: 0 16px 12px; cursor: pointer; user-select: none;
  padding: .45rem .7rem; border: 1px dashed var(--line); border-radius: 8px;
  font-size: 13px; font-weight: 700; color: var(--muted);
}
.sf-reveal:hover { border-style: solid; color: var(--ink); }
/* The label says what the NEXT press does, so it is never a control whose
   wording describes the state you are already in. */
.sf-reveal-off { display: none; }
.sf-reveal-box:checked ~ .sf-reveal .sf-reveal-on { display: none; }
.sf-reveal-box:checked ~ .sf-reveal .sf-reveal-off { display: inline; }
/* ** AND IT IS NOT PRINTED. ** Nothing on paper can be pressed, and the pay it
   reveals has no business being printed by accident. */
@media print { .sf-reveal { display: none; } }
/* The masked cell for somebody who may not see the figure at all: no toggle,
   because there is nothing behind it to reveal. */
.rate-masked { color: var(--muted); letter-spacing: .12em; text-align: center; }

/* THE BUDGET FORM'S RATES AND PAY, hidden until asked for (2026-09-17).
   Rate AND pay together: pay is hours x rate x days, so masking only the rate
   lets anybody work it back out. Hours and days stay visible -- asked for by
   name -- and every total stays visible, which is the point of the form.

   Driven by one checkbox that is a SIBLING of the sections, so a plain `~`
   reaches them with no JavaScript. A figure you cannot reveal is a figure you
   cannot edit, and a dead script must not make a budget uncostable. */
.pay-mask { color: var(--muted); letter-spacing: .12em; }
#show-pay:not(:checked) ~ .sec .pay-real { display: none; }
#show-pay:checked ~ .sec .pay-mask { display: none; }
.pay-peek-btn {
  display: inline-block; cursor: pointer; margin-right: .5rem;
  padding: .2rem .55rem; border: 1px solid var(--line); border-radius: 6px;
  user-select: none; font-weight: 600;
}
.pay-peek-btn:hover { border-color: var(--brand); }

/* WHO IS SWITCHED ON, AND PICKING SEVERAL AT ONCE (asked 2026-09-22: "allow me
   to turn on or off there account... turn off multi account at once by placing
   check marks first then hitting suspend").

   A switched-off row must read as switched off from across the room -- the
   whole reason for the feature is somebody scanning the list for who can get
   in. Faded and struck through says it without a legend. */
.tag.ok { background: var(--green-bg, #e6f4ea); color: var(--green, #1e8e3e); }
html.dark .tag.ok { background: #1c3323; }
tr.off-row > td { opacity: .55; }
tr.off-row > td:first-child, tr.off-row > td:last-child { opacity: 1; }
tr.off-row b { text-decoration: line-through; }

/* The bulk bar sits under the list, and the tick boxes above post into it
   through form="bulk-switch" -- see views/users.js for why it is not a form
   wrapped around the table. */
.bulkbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.bulkbar .hint { margin: 0; }
.bulk-tick, .bulk-all { width: 16px; height: 16px; cursor: pointer; }

/* WHAT ONE PERSON IS PAID, HIDDEN ON A REPORT (asked 2026-09-22: "xxx out the
   salery on all report unless an admin clicks on show view"). It must read as
   deliberately withheld rather than as a missing figure or a bug -- a blank
   cell looks like the budget forgot somebody. */
.pay-x { letter-spacing: 2px; color: var(--muted); font-weight: 700; cursor: help; }

/* THE WEEK GRID ON A CAMP (asked 2026-09-23: "week 1 will have mon and tue and
   week 2 would have mon-fri"). The day boxes have to be tappable on a phone and
   readable as a row of five, which a bare checkbox is not. */
.daybox { display: inline-flex; flex-direction: column; align-items: center; gap: 2px; cursor: pointer; }
.daybox input { width: 18px; height: 18px; cursor: pointer; }
.daybox span { font-size: 11px; font-weight: 700; color: var(--muted); }

/* ============================================================================
   /waiting -- BEGIN owned block (routes/waiting.js, views/waiting.js).

   Asked 2026-09-24, three times over: "a cool graphics that move showing
   whats done and whats waiting"; then "show where you pull data... like an
   info web with icons and moving stuff"; then, sharpened, "want to see lots
   of moving stuff showing progress and when something needs my attention" --
   for imports as well as approvals.

   ** A SHARED STATE VOCABULARY, MEANT TO BE REUSED. ** Four classes, applied
   directly to whatever element should read as that state -- a ball, a card, a
   pipeline stage, a dot. The import pages are expected to adopt these same
   four names later rather than inventing their own:
     state-waiting  something here needs a PRESS. The loudest thing on the
                    page, on purpose -- an expanding outline ring.
     state-running  in progress / used recently. A gentle scale pulse --
                    calmer than waiting, so the two are never confused.
     state-done     settled. No animation. Finished is not the same as idle,
                    but both read as "nothing to do", which is the point.
     state-off      not configured at all. No animation, and dimmed, so a
                    source that was never set up cannot be mistaken for one
                    that simply has nothing to report right now.
   A fifth, `flow-wire.live`, is not a state itself -- it is the "something is
   travelling along this connection" indicator, added to a wire when the node
   it leads to is state-running OR state-waiting.

   TWO KEYFRAMES ONLY. `sf-flow` is a dashed line travelling the way data
   flows -- toward the app. `sf-glow` is the loud attention ring. `sf-pulse` is
   the calmer "in progress" breathing scale. Nothing here is decoration: a
   still element is a true "nothing is moving here", never a missed
   opportunity to animate something. */
@keyframes sf-flow { to { background-position: -24px 0; } }
@keyframes sf-glow {
  0%, 100% { outline: 3px solid var(--brand); outline-offset: 1px; opacity: 1; }
  50% { outline: 3px solid var(--brand); outline-offset: 6px; opacity: .45; }
}
@keyframes sf-pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.1); opacity: .8; } }

.flow-wire { height: 3px; border-radius: 2px; background: var(--line); margin: 6px 0; flex: 1; min-width: 20px; }
.flow-wire.live {
  background-image: repeating-linear-gradient(90deg, var(--brand) 0 6px, transparent 6px 14px);
  background-size: 24px 100%;
  animation: sf-flow 1s linear infinite;
}

/* THE STATE CLASSES THEMSELVES. Applied to a panel, a ball or a dot alike --
   the glow ring scales to any box size, and the pulse is reserved for the
   small round elements (a ball, a dot, a badge) where a scale change reads as
   "alive" rather than as the page wobbling.

   ** EVERY STATE ALSO READS WITHOUT MOTION. ** `outline` is set as a plain
   property here, not only inside the `sf-glow` keyframe -- so turning the
   animation off (prefers-reduced-motion, below) leaves a solid ring behind
   rather than nothing at all. Colour and border do the same job the motion
   does, permanently, for the one rule that must never depend on movement to
   be understood. */
.state-waiting { animation: sf-glow 1.6s ease-in-out infinite; outline: 3px solid var(--brand); outline-offset: 2px; }
.status-ball.state-running, .stage-dot.state-running, .trunk-dot.state-running {
  animation: sf-pulse 2.2s ease-in-out infinite;
}
.state-done, .state-off { animation: none; }
.status-ball.state-waiting { border: 3px solid var(--brand); outline: none; }
.status-ball.state-running { border: 2px solid var(--brand); }
.status-ball.state-off { border: 2px dashed var(--line); opacity: .5; filter: grayscale(1); }
.status-ball.state-done { border: 2px solid var(--line); opacity: .85; }

/* THE PIPELINE: Pulled -> Waiting for you -> Approved. Each stage reads
   differently depending on whether it is finished, current, or not yet
   reached -- and only the wire feeding the CURRENT stage may ever travel. */
.waiting-headline .pipeline { display: flex; align-items: center; gap: 6px; margin-top: 14px; flex-wrap: wrap; }
.stage { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 90px; text-align: center; }
.stage-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--line); border: 2px solid var(--line); }
.stage.done .stage-dot { background: var(--green); border-color: var(--green); }
.stage.state-waiting .stage-dot { background: var(--brand); border-color: var(--brand); }
.stage.pending .stage-dot { background: transparent; opacity: .5; }
.stage-label { font-size: 12px; font-weight: 700; color: var(--muted); }
.stage.state-waiting .stage-label, .stage.done .stage-label { color: var(--ink); }
.stage.pending .stage-label { opacity: .6; }
/* ** THE PIPELINE IS A COMPACT STRIP, NOT A FULL-WIDTH BAR. ** Reported by
   the user 2026-09-25: the three stages were pushed to the edges of the page
   with the wires stretched between them, so a strip carrying three short words
   ate the whole width of the headline panel. `.flow-wire` is `flex: 1` because
   in a status NODE it is a connector that should fill its column; here it is a
   short hop between two labels, so the growth is taken off it and a real
   length given instead. The wire is still drawn and still animates when it is
   live -- shrinking it to nothing would take away the one thing on this strip
   that says something is arriving. */
.pipeline .flow-wire { margin: 0; flex: 0 1 44px; }

/* THE WAITING CARDS. `state-waiting` is on the whole panel -- the glow ring
   around the card itself, not just a badge -- because the contrast against
   the calm cards elsewhere on the page is the one thing this screen most
   needs to say at a glance. */
.waiting-card { border-left: 5px solid var(--brand); }
.waiting-card .waiting-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
.waiting-icon { font-size: 28px; line-height: 1; flex: none; }
.waiting-head h2 { margin: 0; font-size: 17px; }
.waiting-count {
  margin-left: auto; font-size: 20px; font-weight: 800; color: var(--brand);
  background: var(--chip); border-radius: 999px; min-width: 34px; height: 34px;
  display: inline-flex; align-items: center; justify-content: center; padding: 0 8px; flex: none;
}
.waiting-card.state-waiting .waiting-count { animation: sf-pulse 2.2s ease-in-out infinite; }

/* THE DONE LIST. Plain rows, no motion at all -- this is a record of what
   already happened, and animating the past would be exactly the decoration
   the brief ruled out. */
.done-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.done-row { display: flex; align-items: baseline; gap: 8px; font-size: 13px; color: var(--muted); }
.done-row b { color: var(--ink); }
.done-row.bad { color: var(--red); }
.done-icon { flex: none; }

/* THE STATUS GRID: shared by the imports strip AND the data web below it, so
   the two sections read as one visual language rather than two. */
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin: 14px 0; }
.status-node { display: flex; gap: 12px; background: var(--chip); border-radius: 10px; padding: 14px; border: 1px solid var(--line); }
.status-ball {
  width: 40px; height: 40px; border-radius: 50%; flex: none; box-sizing: border-box;
  display: flex; align-items: center; justify-content: center; font-size: 19px;
  background: var(--surface); border: 2px solid var(--line);
}
.status-body { flex: 1; min-width: 0; }
.status-label a { font-weight: 700; font-size: 13px; text-decoration: none; }
.status-label a:hover { text-decoration: underline; }
.status-headline { font-size: 12px; color: var(--muted); margin-top: 1px; }
.status-detail { font-size: 11px; margin-top: 2px; }
.status-node .flow-wire { margin: 8px 0 6px; }
.status-endpoints { margin-top: 2px; }
.status-endpoints summary { font-size: 11px; color: var(--brand); cursor: pointer; }
.status-endpoints ul { margin: 6px 0 0; padding-left: 18px; font-size: 11px; color: var(--muted); }

/* ---- THE SOLAR SYSTEM (asked 2026-09-24: "sun stars etc... planets how they
   connect", then "moon", then "can be star theme... this is good"). STAR
   Education's own name is why this is the organising idea for the page, not
   a panel on it.

   FIVE FIXED ANGLES, not computed at runtime -- a design constant, the same
   as any other layout number in this stylesheet. `--angle` (per spoke),
   `--dur` and `--delay` (how fast, and how far out of phase) arrive as inline
   custom properties from `views/waiting.js`; everything else lives here.

   THE MATH, IN WORDS: `.spoke` sets the fixed angle for its slot and never
   moves. `.spin` is its child, matching it in size, and is the thing that
   actually rotates (only while its source is running or waiting) -- so a
   still spoke and a spinning one are the same markup, one animation
   different. `.orbit-body` places a source or a planet at a fixed distance
   from the centre by folding the offset into ONE translate (`calc(-50% -
   var(--r))`), which sidesteps the usual headache of percentage vs pixel
   transforms disagreeing about what they are a percentage OF. `.icon` cancels
   BOTH the spoke's fixed angle and the spin's live rotation, in one
   keyframe that reads `--angle` at compute time -- so the emoji inside always
   renders upright, whatever the spoke around it is doing. */
/* ** THE BOX HUGS THE ORBITS. ** Asked 2026-09-25: "how can we use less
   padding on that cell -- lots of blank space." It reserved 64px of margin
   around the outer orbit for bodies that are at most 28px across, so the
   panel was mostly empty. 36px clears the largest body and its label with
   room to breathe, and the whole diagram sits closer together. */
.solar {
  position: relative; margin: 14px auto;
  /* ** TIGHTER AGAIN, 2026-09-25: "make box smaller". ** The outer ring came
     down 190 -> 150 and the inner 105 -> 82, which takes the whole square from
     416px to 336px — a fifth off the height of the tallest panel on the page.
     The RATIO is kept close to the original so the source still sits visibly
     between the sun and its planet; shrinking only the outer ring would have
     crowded the two together. Checked on screen at this size, not reasoned
     about from the numbers: five labels at 72 degrees apart still clear each
     other on a 150px radius. */
  /* ** THE INNER RING IS BACK WHERE IT WAS, BECAUSE THE SPOKES ARE. ** It was
     pushed out to 106px while the five spokes were 45deg apart across a half
     circle: that spacing left only ~64px of arc between one source icon and
     the next, and a 40px icon with a word under it needs more, so "uploads",
     "portal" and "grantsheets" overlapped near the sun. At 72deg round the
     whole circle — where they are again — 82px gives ~103px of arc, which is
     what this radius was sized for. The arithmetic is in
     `test/waiting-planet.test.js` so it keeps up if the ring is ever
     re-spaced again. */
  --r-outer: clamp(92px, 26vw, 150px); --r-inner: clamp(50px, 14vw, 82px);
  width: calc(var(--r-outer) * 2 + 36px); height: calc(var(--r-outer) * 2 + 36px); max-width: 96vw;
}
/* A HINT OF DEPTH, NOT A LIGHT SHOW. ** Asked for, then walked back to "but
   clean" -- one quiet, static layer of a handful of points rather than a
   field of twinkling dots. It never moves and never means anything on its
   own; it is the calm the rest of the page is read against. */
.solar::before {
  content: ''; position: absolute; inset: -18px; z-index: 0; pointer-events: none; border-radius: 50%;
  background-image:
    radial-gradient(1px 1px at 14% 20%, var(--muted) 0, transparent 60%),
    radial-gradient(1px 1px at 80% 16%, var(--muted) 0, transparent 60%),
    radial-gradient(1px 1px at 30% 84%, var(--muted) 0, transparent 60%),
    radial-gradient(1px 1px at 86% 70%, var(--muted) 0, transparent 60%),
    radial-gradient(1.5px 1.5px at 58% 94%, var(--muted) 0, transparent 60%);
  opacity: .3;
}
/* ** A STAR AT THE CENTRE, NOT A DISC. ** Asked 2026-09-25: "can we put more
   of a star in the middle, like the movement." STAR Education is the company
   name, so the centre carries it. Four rays drawn from one element's two
   pseudo-elements -- no extra markup, so `views/waiting.js` is untouched --
   turning slowly behind the face. The turn is the only motion that runs when
   nothing is waiting: gentle, and it means "the app is here", which is why it
   is slow enough to be noticed rather than watched. */
.sun {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  width: 56px; height: 56px; border-radius: 50%; z-index: 2;
  display: flex; align-items: center; justify-content: center; font-size: 28px;
  /* The star's own colour, so the face and the rays behind it are one mark
     rather than two things that happen to overlap. */
  /* ** A SUN IS YELLOW. ** Asked 2026-09-25 ("can we make the star more
     yellow"). A warm gold that reads as a star on both themes, with the rays
     behind it in the same gold so the mark is one thing, not a gold face on
     purple spikes. */
  color: #f5b301; line-height: 1;
  background: var(--chip); border: 2px solid var(--line); box-shadow: var(--shadow);
  transition: transform .2s cubic-bezier(.34, 1.56, .64, 1);
}
.sun::before, .sun::after {
  content: ''; position: absolute; left: 50%; top: 50%; width: 92px; height: 92px;
  margin: -46px 0 0 -46px; z-index: -1; pointer-events: none;
  background: linear-gradient(#f5b301, #f5b301) center/6px 100% no-repeat,
    linear-gradient(#f5b301, #f5b301) center/100% 6px no-repeat;
  -webkit-mask: radial-gradient(closest-side, transparent 30%, #000 34%, #000 100%);
  mask: radial-gradient(closest-side, transparent 30%, #000 34%, #000 100%);
  opacity: .5; animation: sf-star-turn 36s linear infinite;
}
.sun::after { transform: rotate(45deg); opacity: .26; animation-duration: 54s; }
@keyframes sf-star-turn { to { transform: rotate(360deg); } }
.sun::after { animation-name: sf-star-turn-offset; }
@keyframes sf-star-turn-offset { from { transform: rotate(45deg); } to { transform: rotate(405deg); } }
.sun.state-running { border-color: var(--brand); }
/* THE SUN GLOWS ONLY WHEN SOMETHING ACTUALLY NEEDS A PERSON. ** Calm is the
   baseline everywhere on this page; the one bloom that runs constantly is
   reserved for the one state that matters most. */
.sun.state-waiting { animation: sf-bloom 3.2s ease-in-out infinite; }
@keyframes sf-bloom {
  0%, 100% { box-shadow: 0 0 0 0 transparent, var(--shadow); }
  50% { box-shadow: 0 0 24px 6px var(--brand), var(--shadow); }
}
.spoke { position: absolute; inset: 0; transform: rotate(var(--angle)); pointer-events: none; }
.spin { position: absolute; inset: 0; pointer-events: none; }
/* WEIGHTED EASING, NOT A METRONOME. ** A loop that eases through its turn has
   a near side and a far side -- the same pair of curves on the spin and its
   cancellation, so the two stay perfectly matched and the icon never seems to
   slip. */
.spin.spin-on { animation: sf-orbit var(--dur) cubic-bezier(.45, 0, .15, 1) infinite; animation-delay: var(--delay); }
@keyframes sf-orbit { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

.orbit-body {
  position: absolute; top: 50%; left: 50%; pointer-events: auto;
  transform: translate(-50%, calc(-50% - var(--r)));
  transition: transform .18s cubic-bezier(.34, 1.56, .64, 1);
}
.source-pos { --r: var(--r-inner); }
.planet-pos { --r: var(--r-outer); z-index: 1; }
/* A PLANET LEANS TOWARD A LOOKING-CLOSER. ** Optional, on purpose -- nothing
   here is discoverable ONLY by hovering; a touch-screen visitor sees the same
   facts in the tooltip title and the plain-list fallback below the diagram. */
.orbit-body:hover { transform: translate(-50%, calc(-50% - var(--r))) scale(1.14); }
/* THE LINE LIGHTS ALONG ITS WHOLE LENGTH when either end is being looked at
   -- `:has()` reaches back up from the hovered body to its own connection,
   without needing the invisible full-size wrapper to catch the hover itself
   (it does not: see `pointer-events: none` above). */
.spin:has(.orbit-body:hover) .spoke-line { background-color: var(--brand); }

.spoke-line {
  position: absolute; top: 50%; left: 50%; width: 3px; border-radius: 2px; background: var(--line);
  transform: translate(-50%, calc(-1 * var(--r-outer)));
  height: calc(var(--r-outer) - var(--r-inner));
}
.spoke-line.state-running, .spoke-line.state-waiting {
  background-color: transparent;
  background-image: repeating-linear-gradient(180deg, var(--brand) 0 6px, transparent 6px 14px);
  background-size: 100% 24px;
  animation: sf-flow-v 1s linear infinite;
}
@keyframes sf-flow-v { to { background-position: 0 -24px; } }

/* THE ICON: cancels both the spoke's fixed angle (always, via the plain
   `transform`) and the spin's live rotation (only while spinning, via the
   keyframe -- which starts from that same cancelled angle, so there is no
   jump the instant the animation takes over). */
.icon {
  transform: rotate(calc(-1 * var(--angle))); position: relative;
  transition: filter .15s;
}
.spin-on .icon {
  animation: sf-orbit-cancel var(--dur) cubic-bezier(.45, 0, .15, 1) infinite; animation-delay: var(--delay);
}
@keyframes sf-orbit-cancel {
  from { transform: rotate(calc(-1 * var(--angle))); }
  to { transform: rotate(calc(-1 * var(--angle) - 360deg)); }
}
.orbit-body:hover .icon { filter: brightness(1.15); }
/* ONE faint trailing echo, only while actually orbiting -- a hint of motion
   having a direction, never a flock of copies. */
.spin-on .icon::before {
  content: ''; position: absolute; inset: 0; border-radius: inherit; background: inherit;
  opacity: .28; filter: blur(3px); z-index: -1;
  animation: sf-orbit-cancel var(--dur) cubic-bezier(.45, 0, .15, 1) infinite;
  animation-delay: calc(var(--delay) - (var(--dur) * 0.035));
}

.icon.planet {
  position: relative;
  border-radius: 50%; display: flex; align-items: center; justify-content: center;
  color: #fff; font-size: 9px; font-weight: 800; text-align: center; line-height: 1.1;
  box-shadow: var(--shadow); padding: 2px;
}
/* ** THE NAME SITS BELOW THE PLANET, NOT INSIDE IT. ** It was set in 9px white
   INSIDE a 34px disc, so "Academy" did not fit: it spilled past the circle and
   collided with its neighbour, which is the opposite of "I can see everything
   well and super organized". Seen on screen 2026-09-25, not reasoned about.
   It stays a child of the disc so it inherits the counter-rotation that keeps
   everything upright, and is simply placed underneath. */
.planet-label {
  position: absolute; top: calc(100% + 5px); left: 50%; transform: translateX(-50%);
  font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap;
  color: var(--ink); background: var(--surface);
  padding: 2px 6px; border-radius: 7px; box-shadow: 0 1px 3px rgba(0, 0, 0, .14);
  pointer-events: none;
}
.icon.planet.size-sm { width: 34px; height: 34px; opacity: .68; }
.icon.planet.size-md { width: 44px; height: 44px; }
.icon.planet.size-lg { width: 56px; height: 56px; font-size: 10px; }
/* A PLANET WITH A MOON ON IT IS THE PAGE'S WHOLE JOB, SO IT GLOWS. Calm is
   the baseline (see the media query below); this is the one other bloom that
   runs constantly, and only here. */
.icon.planet.has-moons { animation: sf-bloom-planet 2.6s ease-in-out infinite; }
@keyframes sf-bloom-planet {
  0%, 100% { box-shadow: 0 0 0 0 transparent, var(--shadow); }
  50% { box-shadow: 0 0 16px 4px var(--brand), var(--shadow); }
}
/* THE SAME FIVE COLOURS `.tag.cl-*` ALREADY USES -- never a second mapping.
   A parallel selector rather than reusing `.tag` itself: that class is built
   for a small text pill (padding, a pill radius) and fights a fixed-size
   circle, but the colour source is identical. `.tag.cl-orange` did not exist
   before this page needed it (Camp's own colour, used in `views/camps.js`
   already) -- added once here, for both. */
.icon.planet.cl-blue { background: var(--blue); }
.icon.planet.cl-purple { background: var(--purple); }
.icon.planet.cl-green { background: var(--green); }
.icon.planet.cl-orange { background: var(--orange); }
.icon.planet.cl-pink { background: var(--pink); }
.tag.cl-orange { background: var(--orange); color: #fff; }

.moons { position: absolute; inset: 0; pointer-events: none; }
.moon {
  position: absolute; width: 9px; height: 9px; border-radius: 50%; background: var(--brand);
  display: block; pointer-events: auto; transition: transform .15s cubic-bezier(.34, 1.56, .64, 1);
}
/* A moon comes forward on a closer look -- transform/opacity only, and it is
   never the only way to learn what it is: the same title text is what a
   screen reader and a touch tap both already have. */
.moon:hover, .moon:focus-visible { transform: scale(1.7); }
.moon:nth-child(1) { top: -3px; right: -3px; }
.moon:nth-child(2) { bottom: -3px; right: -3px; }
.moon:nth-child(3) { top: -3px; left: -3px; }
.moon:nth-child(4) { bottom: -3px; left: -3px; }
.moon-more {
  position: absolute; bottom: -6px; left: 50%; transform: translateX(-50%);
  font-size: 9px; font-weight: 700; background: var(--chip); color: var(--muted);
  border-radius: 999px; padding: 0 4px; white-space: nowrap;
}
/* ** CLICKING A PLANET DRAWS LINES to the text already on the page (the source
   card below and its row in the list on the right) rather than popping up a
   card that repeats it. Asked 2026-09-25. ** The click target is a full-disc
   label; solar-links.js lights that set and dims the rest, and the page still
   works with no script because every fact is already visible in the list. */
.planet-hit {
  position: absolute; inset: 0; border-radius: 50%; cursor: pointer;
  /* Explicit, though DOM order alone would do it: a moon must stay clickable.
     Stated rather than relied upon, because "it happens to stack right" is not
     a rule anybody can see when they next move this markup. */
  z-index: 1;
}
.moons { z-index: 2; }
/* The lit planet keeps a ring while its set is shown (solar-links.js adds
   .sl-lit); the checkbox is still the no-JS-safe open/closed flag the script
   reads. */
.planet-toggle:checked ~ .planet-hit { outline: 3px solid #f5b301; outline-offset: 2px; border-radius: 50%; }
/* ** THE ORBIT STOPS WHILE A PLANET IS OPEN OR HOVERED. ** A spoke spins
   whenever its source is running or waiting -- exactly the busy morning
   somebody needs to read it -- so a moving target would be worst when it
   matters most. Pausing is NOT an animation, so it stays out of the
   reduced-motion block: with motion already off there is nothing to pause. */
.spin:has(.orbit-body:hover), .spin:has(.orbit-body:hover) .icon, .spin:has(.orbit-body:hover) .icon::before,
.spin:has(.planet-toggle:checked), .spin:has(.planet-toggle:checked) .icon,
.spin:has(.planet-toggle:checked) .icon::before {
  animation-play-state: paused;
}
/* The lit planet leans forward, held still, while its lines are shown. */
.orbit-body:has(.planet-toggle:checked) { transform: translate(-50%, calc(-50% - var(--r))) scale(1.14); }

.solar-fallback { margin-top: 10px; }
.solar-fallback summary { font-size: 12px; color: var(--brand); cursor: pointer; }

/* A CARD GIVES SLIGHTLY ON PRESS. Scoped to buttons ON THIS PAGE'S OWN cards,
   never to `.btn` globally -- every other screen's buttons are untouched. */
.waiting-card .btn:active { transform: translateY(1px); }

/* ** THE METAPHOR DOES NOT SURVIVE A NARROW SCREEN, SO IT DOES NOT TRY TO. **
   The diagram's labels sit at fixed points around a circle whose radius
   shrinks with the viewport, so below a certain width they start landing on
   top of each other -- "portal" over "grantsheets", "Camp" over its own
   planet. Two labels on top of each other are worse than no diagram at all.
   Seen on screen at 500px on 2026-09-25, having been raised from 479px:
   480 was set by how small a moon gets to TAP, and this is the earlier limit
   -- the labels collide before the moons get fiddly.
   ** NOTHING IS LOST BY HIDING IT. ** The breakdown beside it now says
   everything the diagram says, in sentences, and is always visible -- so on a
   narrow screen the list simply becomes the whole answer. The information
   wins over the picture. Both are always in the markup; only one is ever
   shown, so there is nothing to keep in sync by hand. */
.solar-phone-list { display: none; }
@media (max-width: 719px) {
  .solar { display: none; }
  .solar-phone-list { display: flex; }
}

/* ** MOTION OFF, WHOLESALE, WHEN ASKED FOR. ** Every animated class used on
   this page (and meant for reuse by the import pages) is named here --
   adding a new animated class without adding it to this block is the mutant
   this file's test looks for. */
/* ** THE CONNECTOR IS A SPOTLIGHT, NOT A LINE (public/solar-links.js). **
   Reported 2026-09-26 after four attempts at routing one: "the line still hit
   tiles or cards and cross planets... you can change the type of link or the
   design, just want it clean". A path between two points on a panel holding a
   sun, five planets, five source icons, ten labels, five sentences and six
   cards has to miss thirty-one other things, and on a dense panel no clean
   route EXISTS — so the router drew the least-bad one, which is what kept
   being reported. Nothing is drawn between the lit things now, so nothing can
   cross anything.

   Press a planet and the three things that belong together — the planet, its
   row in the list, and the source card that feeds it — stay lit while
   everything else dims. The ROW and the CARD take the planet's own colour
   (`cl-blue` and the rest, the classes this app already colours a programme
   with), so the three read as one thing rather than as three highlighted
   ones. */
.sl-focus .icon:not(.sl-lit),
.sl-focus .spoke-line,
.sl-focus .sun,
.sl-focus .solar-list .done-row:not(.sl-lit),
.sl-focus .status-node:not(.sl-lit) { opacity: .26; }
/* A label lives INSIDE its icon, so it dims with it — no rule of its own, and
   none wanted: a separate one would have to exclude the lit planet's own. */
.solar .icon, .spoke-line, .sun, .solar-list .done-row, .status-node {
  transition: opacity .18s ease;
}

/* ** THE LINK ITSELF — it runs in the ring of empty margin AROUND the
   diagram, never through it, so it has nothing to obscure and is drawn on
   top where it can be followed to its end. White over a translucent dark
   casing, because white alone is invisible on a light panel; the casing is
   drawn first and both carry `sl-line`, so one selector clears them. A link
   takes the planet's own colour where it has one. */
.solar-links .sl-line {
  fill: none;
  stroke: #fff; stroke-width: 1.9;
  stroke-linejoin: round; stroke-linecap: round;
}
.solar-links .sl-case { stroke: rgba(17, 24, 39, .32); stroke-width: 5; }
html.dark .solar-links .sl-case { stroke: rgba(0, 0, 0, .45); }
.solar-links .sl-line.cl-blue { stroke: var(--blue); }
.solar-links .sl-line.cl-purple { stroke: var(--purple); }
.solar-links .sl-line.cl-green { stroke: var(--green); }
.solar-links .sl-line.cl-orange { stroke: var(--orange); }
.solar-links .sl-line.cl-pink { stroke: var(--pink); }

.sl-lit { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 8px; }
.icon.sl-lit { outline-offset: 4px; border-radius: 50%; }
/* The planet's own colour, carried onto the two things it is lit with. */
.sl-lit.cl-blue { outline-color: var(--blue); }
.sl-lit.cl-purple { outline-color: var(--purple); }
.sl-lit.cl-green { outline-color: var(--green); }
.sl-lit.cl-orange { outline-color: var(--orange); }
.sl-lit.cl-pink { outline-color: var(--pink); }

/* A source card invites the click, once the script is there to answer it. */
/* ** ONLY THE SOLAR PANEL'S CARDS ANSWER A CLICK. ** `statusNode()` is reused
   by the IMPORTS row, which carries `data-source` too — and BOTH rows are a
   `.status-grid`, so the grid is no use as a scope. The connector's listener is
   bound to the panel that holds the diagram, so the selector says exactly that:
   `:has(.solar)`, the same mechanism the planet cards already rely on. An
   unscoped rule put a hand cursor on three cards that do nothing when pressed,
   and a pointer is a promise. */
.panel:has(.solar) .status-node[data-source] { cursor: pointer; }
/* ** THE `sl-frozen` FREEZE IS GONE WITH THE LINE THAT NEEDED IT. ** It
   paused every orbit so a line was not drawn to a moving target. Nothing
   rotates and nothing is drawn, so no rule here sets or honours it. */

@media (prefers-reduced-motion: reduce) {
  .state-waiting,
  .status-ball.state-running, .stage-dot.state-running, .trunk-dot.state-running,
  .waiting-card.state-waiting .waiting-count,
  .flow-wire.live,
  .spin.spin-on, .spin-on .icon, .spin-on .icon::before,
  .spoke-line.state-running, .spoke-line.state-waiting,
  .sun::before, .sun::after,
  /* The connector draws no line at all now -- it lights a set and dims the
     rest (see the spotlight block above) -- so there is nothing of its own
     here to turn off. The dim itself is a 180ms opacity transition, which is
     a fade rather than motion; it is left on deliberately, because removing
     it would make the panel change state with no signal that it did. */
  .sun.state-waiting, .icon.planet.has-moons {
    animation: none !important;
    background-position: 0 0;
  }
  /* A still spoke keeps its FIXED angle so the diagram stays intact -- only
     the continuous spin stops. Each icon still needs its static counter-
     rotation (the plain, non-animated `.icon` rule above already supplies
     it), so nothing needs restating here for that part. */
  /* The glow ring stays, motionless -- `state-waiting` must still read as
     "needs a press" with the animation off; only the movement is the part
     this rule turns off. */
  .state-waiting { outline: 3px solid var(--brand); outline-offset: 2px; }
  /* THE SUN AND A MOONED PLANET'S GLOW LIVE ONLY INSIDE `sf-bloom` /
     `sf-bloom-planet` -- disabling the animation above would otherwise erase
     the signal completely rather than just its movement, which is exactly
     the mistake the `.state-waiting` rule above this one already exists to
     avoid. A still, permanent glow says the same thing without moving. */
  .sun.state-waiting { box-shadow: 0 0 18px 4px var(--brand), var(--shadow); }
  .icon.planet.has-moons { box-shadow: 0 0 14px 3px var(--brand), var(--shadow); }
}
/* /waiting -- END owned block. */

/* ** BOOSTER'S COLOUR (2026-10-09), DELIBERATELY BELOW THE PINNED SPAN. **
   The user: "boooster is it own progrm". `budget/programs.js` gives the new
   programme the tag `cl-teal`, and a tag with no rule here draws as nothing.

   ** IT IS HERE AND NOT BESIDE `.tag.cl-orange` WHERE IT BELONGS. ** That line
   sits inside the `---- THE SOLAR SYSTEM` .. `END owned block` span, which
   `test/cat.test.js` pins BYTE-IDENTICAL by sha256 -- "a precedent to copy,
   never to edit". Same four rules, same cascade (no selector below competes
   with them), outside the hash.

   ** NOT `cl-grey`, WHICH WAS THE OBVIOUS CHOICE. ** It is `tagFor()`'s
   fallback for a programme this app does not know, it has no rule at all, and
   nine neutral pills already ride on it -- so colouring it would repaint them
   and still leave Booster reading as "unknown". `--teal` was free: declared in
   both the light and the dark `:root`, used by no programme.

   All four, because a colour given to only some of them is how a planet gets
   drawn and its spoke does not: `.icon.planet` sets `color:#fff` and NO
   background, so a missing rule is a transparent disc with white text on it. */
.tag.cl-teal { background: var(--teal); color: #fff; }
.icon.planet.cl-teal { background: var(--teal); }
.solar-links .sl-line.cl-teal { stroke: var(--teal); }
.sl-lit.cl-teal { outline-color: var(--teal); }
/* ** THE SUPPLEMENTAL GRANT (2026-10-13). ** Four selectors, exactly as cl-teal
   needed when Booster landed: a programme tag with no rule here draws as
   NOTHING, and `/waiting`'s planet becomes a transparent disc with white text
   on it. `cl-grey` was not reused -- it is `tagFor()`'s fallback for a
   programme this app has never heard of, so a real one wearing it would be
   indistinguishable from "unknown". `--amber` is new in both palettes and no
   other programme uses it, so these are purely additive. */
.tag.cl-amber { background: var(--amber); color: #fff; }
.icon.planet.cl-amber { background: var(--amber); }
.solar-links .sl-line.cl-amber { stroke: var(--amber); }
.sl-lit.cl-amber { outline-color: var(--amber); }

/* ** THE NOVA APPROVAL BAR (views/nova-approve.js) — SHARED BY /nova AND
   /waiting, WHICH IS WHY IT IS HERE AND NOT IN EITHER PAGE'S OWN STYLE BLOCK.**
   Asked 2026-09-25: the buttons *"all on the top"*, and *"just make the
   graphics work"*. Save / Review / Discard sit in a row above everything they
   act on, and the checking list beneath goes quiet a school at a time.

   ** NOT ONE LINE OF THIS NEEDS JAVASCRIPT. ** Every reaction to a tick is
   `:has()`, which this stylesheet already uses for the planets' cards. A
   reading aid that needs a script is one that disappears the day the script
   throws — and this app has shipped a browser-only ReferenceError eight
   times. */
/* ** THE PARSER'S OBJECTIONS, LOUD AND ABOVE THE BUTTON. ** Not a `hint`:
   this is the one thing on the panel that must be read before a press, and a
   muted grey line under three paragraphs is not read. Warm, bordered, and it
   sits between the figures and the Save button so it cannot be scrolled past
   on the way to pressing. */
.nv-warn {
  background: var(--chip); border-left: 3px solid var(--orange, #cc7a00);
  border-radius: 0 8px 8px 0; padding: 10px 14px; margin: 0 0 12px;
}
.nv-warn ul { margin: 6px 0 0; padding-left: 18px; }
.nv-warn li { margin: 2px 0; }

.nv-bar {
  display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
  margin: 0 0 6px;
}
.nv-bar form { margin: 0; }
.nv-note { margin: 0 0 10px; }
/* ** THESE TWO TAKE A WHOLE LINE WHEREVER THEY LAND. ** On /nova the bar sits
   in ordinary block flow and this changes nothing. On /waiting it is dropped
   inside `.wt-decide`, which is `display:flex` — so the warning panel and the
   note became FLEX ITEMS wedged between the buttons and the "Open the full
   review" link, rendering as narrow vertical walls of text. `flex-basis:100%`
   puts each on its own row there and is inert everywhere else, which is why
   it is here rather than as a second copy of the bar's layout. */
.nv-warn, .nv-note { flex: 1 0 100%; }

.nv-howto .nv-steps { margin: 6px 0 0; padding-left: 22px; line-height: 1.5; }
.nv-howto .nv-steps li { margin-bottom: 6px; }

.nv-check { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }

/* ** REVIEW OPENS THIS LIST, AND WITHOUT THAT THE BUTTON LOOKED DEAD. **
   Reported 2026-09-28: the header's Review was an anchor pointing at a list
   already fully visible beneath it, so pressing it moved the page a few pixels
   and nothing else. The list is shut until Review is pressed.
   Pure CSS — a hidden checkbox and a <label for>, the same no-JS idiom the
   rate cells and the nav use — because a control that needs a script is a
   control that is dead when the script is. */
.nv-reviewtoggle:not(:checked) + .nv-checkbody { display: none; }
/* The button reads as pressed while the list is open, so it is plainly the
   thing that opened it and plainly the thing that shuts it again. */
.nv-check:has(.nv-reviewtoggle:checked) { border-top-color: var(--brand, #6b46c1); }
.nv-row {
  border: 1px solid var(--line); border-radius: 8px;
  padding: 6px 10px; margin-bottom: 6px; background: var(--card);
}
.nv-row > summary {
  display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
  cursor: pointer; list-style: none;
}
.nv-row > summary::-webkit-details-marker { display: none; }
/* ** THE TICK MUST NOT OPEN THE DETAILS, AND THE DETAILS MUST NOT SWALLOW THE
   TICK. ** A click anywhere in a <summary> toggles it, so the label carries the
   checkbox in its own stacking context and the browser's native label-to-input
   click still lands. `display:flex` on the wrapper keeps it from stretching. */
.nv-tickwrap { display: inline-flex; align-items: center; }
.nv-tick { width: 18px; height: 18px; margin: 0; cursor: pointer; }
.nv-box { display: none; }

.nv-classes { margin: 8px 0 2px; padding-left: 18px; }
.nv-classes li { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; margin-bottom: 2px; }
.nv-when { font-variant-numeric: tabular-nums; min-width: 132px; color: var(--muted); }

/* A CHECKED SCHOOL GOES QUIET — read, and out of the way. It is never hidden:
   a row that vanishes when ticked cannot be un-ticked by somebody who ticked
   it by mistake. */
.nv-row:has(.nv-tick:checked) { opacity: .55; border-color: var(--green, #2e9e5b); }
.nv-row:has(.nv-tick:checked) > summary > b { text-decoration: line-through; }

/* ** THE ONLY THING CSS CAN COUNT IS "all of them". ** True exactly when no box
   is left unticked — and an empty list must not claim it, which is what the
   `:has(.nv-tick)` half is for. */
.nv-all { display: none; }
.nv-check:has(.nv-tick):not(:has(.nv-tick:not(:checked))) .nv-all {
  display: block; margin: 8px 0 0; font-weight: 700; color: var(--green, #2e9e5b);
}



/* ---- THE CAT (task 10, nova-mini-budgets, 2026-10-01; the drawing replaced by
   the user's own GIF 2026-10-12). Asked: "how can we make the site look more
   fun...?", then "can we add cats?", then option C -- a cat that reacts to
   state -- then "lots of animations... with cats... you know I like moving
   things" and "cute cats".

   ⚠⚠ THE HAND-DRAWN SVG IS GONE, AND THAT IS THE USER'S OWN REVERSAL
   (2026-10-12): *"thats a crazy sad lookng cat lol really.. like the one in th
   banner that i upload can we use that instaed of your drawing"*, then *"it
   needs to move"*. So `cat-breathe`, `cat-tail`, `cat-blink` and their three
   @keyframes are DELETED rather than parked -- nothing draws an ellipse or a
   tail to animate any more, and dead CSS beside a live rule is how the next
   reader comes to believe the motion still lives here.

   ⚠⚠ AND THE GIF ITSELF IS NO LONGER WHAT THE PAGE LOADS (2026-10-12, later the
   same day): *"can you make me more gif from the one i uploaded like the look
   just want more poses"*. Every pose is a SPRITE SHEET -- one row of square
   cells, stepped by the keyframe below -- rather than a GIF, because CSS can
   neither pause nor reduce-motion a GIF.

   ⚠⚠ SIX NEW GIFS, AND ALL FOUR OLD SHEETS ARE REPLACED (2026-10-13): *"5 new
   animated gifs to upade what we have"*. The old sheets were cut from one
   800x600 OPAQUE GIF and were 8-bit greyscale; the six new sources are 400x400,
   16 frames at 90ms, and TRANSPARENT. So every sheet below is 16 cells and
   1.44s, and there are SIX of them -- `playful` and `lurking` are new, and they
   exist so that two pages open at the same moment do not show the same cat (see
   views/cat.js's POOLS). The sources stay in public/ as `cat-<pose>.src.gif`
   beside the original `cat.gif`; no page asks for any of them.

   WHERE: the topbar on every page (22px, any pose), plus an empty state or an
   earned celebration for the big ones (56px). Never printed (see the print
   block above) and never on a payment screen.

   ⚠⚠ THE TILE IS WHITE, AND THE NOTE THAT USED TO STAND HERE IS BACKWARDS NOW
   (2026-10-13). It said the artwork's background "is not transparent... so the
   cat sits in a deliberate dark ROUNDED tile" whose #161616 was the artwork's
   own. The new artwork IS transparent and the cat is a BLACK SILHOUETTE, so
   that dark tile would have drawn a black square with two white eyes in it.
   Checked in a browser on all four backgrounds this app has -- the light page
   (#f4f4f6), the dark page (#202124), the light-mode topbar (#5b2d8e brand
   purple) and the dark-mode topbar (#2a2b2e) -- with no tile at all the cat is
   invisible on three of the four. White reads on every one of them.
   ⚠ IT IS A LITERAL #fff AND NOT `var(--surface)`: --surface follows the theme
   and goes to #2a2b2e in dark mode, which is the black-cat-on-black failure
   this note exists to record. The tile must NOT follow the theme, because the
   thing inside it does not. Pinned by test/cat.test.js, because nothing else
   would notice: the whole suite was green with the cats invisible.

   ⚠⚠ THE TILE MUST STAY SQUARE AND MUST KEEP AN EXPLICIT HEIGHT. The cells are
   square, so a tile that is not square stretches the cat; and `height: 100%` on
   the strip needs a height to resolve against, so neither side may become
   `auto` or an `aspect-ratio`. Verified at 56px and at 22px.

   ⚠ THE CROP IS NO LONGER CSS. It used to be `width: 400%` / `top: -75%`, a
   window on the middle of the 800x600 picture, because nothing here could
   re-cut an animated GIF. That framed the AWAKE cat and nothing else: by frame
   60 the head has dropped to y 224-376 and the window held two ears and no
   face, and the z's rise to y 129, above its top edge. Each pose is cropped at
   DECODE time now, from the measured bounding box of its own frames, which is
   also most of the size saving -- 222KB of GIF on every page became a 9KB sheet.

   ⚠ REDUCED MOTION STOPS THE STRIP AND NOTHING ELSE -- see the block at the end
   of this section. It is DELIBERATELY SEPARATE from the planets' reduced-motion
   block above: they are a precedent to COPY, never to share a rule with, or one
   feature's regression lands inside the other's blast radius on the screen
   somebody approves money on. */
/* ** ONE KEYFRAME FOR ALL FOUR POSES. ** The sheet is one row of SQUARE cells,
   so sliding it by its own full width steps through every cell exactly once:
   `steps(N)` lands on 0, -100/N%, ... -(N-1)/N of the IMAGE's width, and a
   percentage translate resolves against the element's own width, never the
   tile's. The never-shown `to` value would be cell N, which does not exist.
   ** transform ONLY. ** Animating width/height/top/left re-lays out the page on
   every frame; a transform is composited and touches nothing else. */
@keyframes cat-strip { from { transform: translateX(0); } to { transform: translateX(-100%); } }

/* ** NO TILE. THE BACKGROUND IS CLEAR. ** Asked 2026-10-13: *"can use the clear
   background fro the cats..."*, then *"not the white"*.

   ** THE WHITE TILE WAS SOLVING A REAL PROBLEM AND THE FIX MOVED INTO THE
   ARTWORK. ** 86-88% of each cat's visible pixels are dark (Rec.709 luma < 80),
   and `--topbar-bg` is DARK IN BOTH THEMES -- brand purple #5b2d8e in light
   mode, #2a2b2e in dark -- so a clear background on its own loses the topbar
   cat on every page, in both themes. The user's own answer: *"you can outine the
   cat if to dark"*. `tools/outline-cats.js` bakes a 1px light rim around each
   cat, clipped to its own sprite cell, so the cat reads on the light page, the
   purple bar and both dark surfaces with no tile and no theme branching.

   ** THE RIM IS IN THE PNGs, NOT A `filter: drop-shadow` HERE, and that is
   forced. ** A CSS filter applies to the whole 16-cell strip; `cat-playful` and
   `cat-peering` have a narrowest clear margin of 0px -- their pixels touch the
   cell edge -- so each cell's rim would bleed into its neighbour and the tile
   clips to exactly one cell. You would see a sliver of the next frame's outline
   down the side of every cat. */
.site-cat { position: relative; overflow: hidden; width: 56px; height: 56px;
  display: block; margin: 0 auto 6px; border-radius: 14px; background: transparent;
  flex: none; }
/* The strip. `height: 100%` with `width: auto` makes the image exactly N tiles
   wide, so every step lands on a whole pixel at 56px and at 22px alike --
   measured, both exact. `max-width: none` because a stylesheet that ever grows
   a global `img { max-width: 100% }` would otherwise squash all N cells into
   one tile and show a sliver of each. */
.site-cat img { position: absolute; top: 0; left: 0; height: 100%; width: auto;
  max-width: none; }

/* ** EACH POSE'S OWN CELL COUNT AND ITS OWN LENGTH. ** The count must match the
   sheet (its width divided by its height) and `CELLS` in views/cat.js, or the
   strip stops between two cells -- test/cat.test.js reads all three and refuses
   if they disagree.
   ⚠ ALL SIX ARE steps(16) AT 1.44s NOW (2026-10-13), and that is not a
   simplification -- it is the new sources' own speed. Each of the six GIFs is
   16 frames at 90ms, so 16 x 90ms = 1.44s is the artwork played at the rate it
   was drawn at. The old mixed counts (12/12/11) were subsamples of one
   111-frame GIF at 40ms and went with it. Six rules rather than one shared
   `[class*=cat-]`, because the per-pose rule is what test/cat.test.js walks to
   check each sheet against its own `steps()`. */
.cat-asleep img { animation: cat-strip 1.44s steps(16) infinite; }
.cat-pleased img { animation: cat-strip 1.44s steps(16) infinite; }
.cat-peering img { animation: cat-strip 1.44s steps(16) infinite; }
.cat-playful img { animation: cat-strip 1.44s steps(16) infinite; }
.cat-lurking img { animation: cat-strip 1.44s steps(16) infinite; }

/* ** THE TOPBAR CAT IS FURNITURE, NOT AN ILLUSTRATION. ** The rule above is
   for the three big poses: 56px, its own block, centred. All of that is
   wrong inside the wordmark, and because the rule matches on `.site-cat` it
   must be overridden here -- a 56px tile in the bar breaks it onto two lines.
   `.cat-perched` therefore has to stay AFTER `.site-cat` to win at equal
   specificity, which `test/cat.test.js` pins. */
.cat-perched { width: 22px; height: 22px; display: inline-block; margin: 0;
  vertical-align: middle; border-radius: 7px; flex: none; }
.cat-perched img { animation: cat-strip 1.44s steps(16) infinite; }

/* ** AND THE TOPBAR'S GEOMETRY BELONGS TO THE PLACE, NOT TO ONE POSE
   (2026-10-12). ** The bar used to be able to hold only `perched`, so the 22px
   override above was written onto that pose's own class. The pose the topbar
   asks for VARIES now -- reported as *"hiw to see the other cats"* and *"also
   smae cats everywhere boo"*, measured as `perched` on every page -- so the
   moment any of the other five rides up here it matches `.site-cat`
   and NOTHING else, and that rule is the one for the big drawings: 56px,
   its own block, centred, 6px of margin beneath it. CLAUDE.md records exactly
   what that looks like, because it shipped once: **a 56px tile breaks the top
   bar onto two lines.**

   ** TWO CLASSES, SO IT WINS ON SPECIFICITY AND NOT ON ORDER. ** `.topbar
   .site-cat` is (0,2,0) against `.site-cat`'s (0,1,0), so it beats the big-three
   rule wherever it sits -- unlike `.cat-perched`, which ties at (0,1,0) and has
   to stay below it. It is still placed after `.site-cat` so the file reads in
   the order the cascade resolves, and so the constraint holds if either
   selector is ever simplified.

   ** NO NEW ANIMATION RULE IS NEEDED, and that is the reason the strip is sized
   the way it is. ** `.site-cat img` is `height: 100%; width: auto`, so the sheet
   is exactly N tiles wide at ANY tile size and `translateX(-100%)` resolves
   against the image's own width -- 22px steps land on whole pixels just as 56px
   do (both measured). So each pose keeps its own `steps()` and its own length
   from the rules above. Setting a width here instead would land the strip
   between two cells and judder.
   ⚠ THE OLD WORDING NAMED `peering`'s ELEVEN CELLS AS THE REASON (struck
   2026-10-13): all six sheets are 16 cells now, so no pose differs today. The
   rule stands anyway and is the same rule -- the strip must be sized off the
   TILE and never given a width here, or the first sheet that is not 16 cells
   judders silently. A constraint that happens to be slack is still a
   constraint; test/cat-pose-varies.test.js now pins the no-width property
   itself rather than the cell count that used to stand in for it.

   ** `.cat-perched` IS LEFT EXACTLY AS IT WAS. ** It is pinned by
   test/cat.test.js (it must come after `.site-cat`), it still carries perched's
   own 1.44s timing, and deleting it to save a duplicated rule would be trading
   a mutation-proven guard for four lines. */
.topbar .site-cat { width: 22px; height: 22px; display: inline-block; margin: 0;
  vertical-align: middle; border-radius: 7px; flex: none; }

@media (prefers-reduced-motion: reduce) {
  /* ⚠ THE CAT STAYS ON THE PAGE -- only the MOTION goes. Stopping the strip
     leaves it at translateX(0), which is cell 0 -- a real frame of the user's
     own cat, chosen per pose to read on its own. So there is nothing to hide
     and nothing to swap in, and the guard in test/cat.test.js refuses a
     display:none, an opacity:0 or a visibility:hidden in this block.
     ** `.site-cat .cat-strip` (two classes) beats `.cat-perched img` (one class
     and an element) on specificity, so this wins wherever it is put -- it does
     not depend on staying last in the file. */
  .site-cat .cat-strip { animation: none; }
}


/* ===========================================================================
   ** THE PAGE IS ELASTIC; A TABLE IS NOT (2026-10-13). **

   Reported: *"loving the new wider look but can we fix some of the alligment in
   the tabes ... and fix some of the total ux on all the tables"*.

   `.wrap` became `max-width:none` an hour earlier and MUST STAY THAT WAY -- it
   is the thing the user likes. But `.roll{width:100%}`, so every table went
   elastic with the page. The widest a table could draw before that was

       1440 (.wrap cap) - 64 (.wrap padding) - 48 (.panel padding) = 1328px

   and on a 2560 monitor a seven-column table now draws at about 2448px: 350px
   a column against the 190 the user was happy with. A row's label and its
   figures end up far enough apart that the eye cannot carry one to the other,
   which is what "the alignment" means and why it appeared the moment the page
   was widened.

   ** THE CEILING IS ON THE TABLE, SO THE PANELS, CARDS AND CHARTS KEEP THE WHOLE
   WINDOW. ** 1400px is deliberately a little MORE than the 1328 tables drew at
   before -- 200px a column at the seven-column shape 140 of this app's 183
   tables have -- so the page still reads wider than it did rather than snapping
   back. A 1440px laptop never reaches the cap at all and is unchanged.

   ** `width:auto` WAS CONSIDERED AND REJECTED. ** It would size each table to
   its own content, which is the better answer for the handful of two- and
   four-column tables that are stretched across the window for no reason. But
   every input in the budget form is `.cell{width:100%}`, and a percentage width
   inside an auto-layout table is resolved against a width that now depends on
   the content -- which can collapse those boxes. That needs a browser to settle
   and nobody can open one here, so it is left as the next step rather than
   guessed at.
   =========================================================================== */
.roll, .table-scroll > table{max-width:1400px}

/* ** A GENUINELY WIDE TABLE IS GIVEN MORE ROOM, MEASURED PER COLUMN. ** Three
   tables carry 10 columns and three carry 11; one flat ceiling would hold an
   11-column row to the same width as a two-column one. At 1760px those draw at
   160-176px a column, comfortably above the 120-132 they had before the page was
   widened.
   ** THE BODY IS COUNTED AS WELL AS THE HEAD ** -- half this app's tables have no
   `<thead>` at all (every table on `/settings`), and a column count taken off the
   head alone reports nothing for them.
   ** AND IT IS CHILD-SCOPED, BECAUSE THIS APP HAS A NESTED TABLE: **
   `views/clusters.js` draws `.wl-table` inside a `<td colspan>` of a `.roll`
   table, and a descendant `:has(td:nth-child(10))` would let the inner table
   promote its parent's width.
   ** `.st-cols` IS EXCLUDED, AND THAT IS THE POINT OF MEASURING PER COLUMN. ** The
   budget form's staff and rooms tables are 10 and 11 columns wide and PIN their
   own figure columns (`c-rate` 92px, `c-days` 62px, `c-pay` 116px), so every
   extra pixel goes to the one `width:auto` label column -- it buys no room for a
   figure and adds the distance the eye has to carry a name to a Pay cell, which
   is the complaint. The step up is for a dense REPORT table, whose columns are
   all content-sized. */
.roll:not(.st-cols):has(> thead > tr > th:nth-child(10)),
.roll:not(.st-cols):has(> tbody > tr > td:nth-child(10)){max-width:1760px}

/* ** A TABLE WRITTEN WITHOUT `.roll` IS STILL ONE OF OUR TABLES. **
   `views/addons.js:565`, `views/payments.js:567` and `views/pnl.js:578` open a
   bare `<table>` inside a `.table-scroll`, so every `.roll ...` rule in this file
   missed them: no collapsed borders, no width, and a `tr.total` that drew as one
   more row of data. ** THE REAL FIX IS `class="roll"` in those three views ** --
   one word each, in files this change does not own. This is the stopgap, and the
   declaration is pinned to `.roll`'s own by a test so the copy cannot drift. */
.table-scroll > table:not(.roll){width:100%;border-collapse:collapse;font-size:14px}

/* ** THE TOTAL IS ANCHORED WHILE THE ROWS SCROLL UNDER IT. ** `.table-scroll`
   caps at 78vh with a sticky header, so on a long table the headings follow you
   down and the TOTAL -- the figure every row is checked against -- scrolls out of
   sight. This is the other half of that pattern.

   ** `:only-child`, NEVER `:last-child`. ** `views/camps.js` has a tfoot carrying
   THREE total rows (Income, Expenses, Income left) and `views/programmes.js` one
   carrying two; pinning the last would slide the others under its opaque
   background and a reader would lose them until the very end. The budget form's
   staff tfoot ends `tr.total` then `tr.enrich-note`, where a pinned total would
   cover the coverage line. `:only-child` declines to pin in exactly the cases
   where pinning hides something.

   ** THE BRAND RULE IS PAINTED BY THE CELL, NOT BY THE TABLE. ** Under
   `border-collapse:collapse` a collapsed border belongs to the table, so it
   scrolls away while the stuck cell stays -- and the total row would lose its
   3px mark exactly while it is doing its job. An inset shadow belongs to the
   cell and travels with it; the collapsed border is dropped so the two do not
   double up. The background is already an opaque `var(--chip)`, which is what
   stops the rows showing through. Both are undone for print below. */
.table-scroll tfoot > tr.total:only-child > th{position:sticky;bottom:0;z-index:2;
  border-top:0;box-shadow:inset 0 3px 0 0 var(--brand)}

/* ** A ROW'S FIGURES STAY CONNECTED TO ITS LABEL. ** The ceiling above is the
   fix; this is what makes a wide row readable at any width. The zebra stripe is
   static and two rows deep, so on a seven-column row the eye still has to carry
   a label the width of the table to a figure.

   ** ON THE `tr`, NEVER ON THE CELL ** -- every row that carries its own meaning
   paints its own `td` (a total, a group bar, a group subtotal, an expanded
   payment-name row), and a cell background covers the row behind it. So those
   rows are untouched by construction rather than by a `:not()` list that would
   go stale the next time a row kind is added.

   ** AND ONLY WHERE HOVERING IS A REAL THING: ** on a touchscreen `:hover`
   latches after a tap and leaves a row marked with nothing to clear it. */
@media (hover: hover) {
  .table-scroll tbody tr:hover{background:var(--chip)}
}

@media print {
  /* ** A PINNED TOTAL ON PAPER IS A ROW SITTING OVER THE ROWS IT WAS PINNED
     ABOVE. ** The same reasoning as the sticky header and the height cap, which
     are undone further up. The brand rule is restored as a real BORDER because a
     browser may drop backgrounds and shadows from a print unless the reader has
     asked for them -- so the inset mark would vanish and the total row would be
     left with nothing saying it is a total. */
  .table-scroll tfoot > tr.total:only-child > th { position: static !important; box-shadow: none !important; border-top: 3px solid var(--brand) !important; }
}

/* ---- THE DIRECTOR'S ROOM FORM (`/rooms/day`) -- START owned block. ---------
   ** THE NINE `rd-*` CLASSES HAD NO RULE ANYWHERE UNTIL 2026-10-22. ** Found by
   reading the DEPLOYED stylesheet, not the source: `rd-field`, `rd-tick`,
   `rd-room`, `rd-room-off`, `rd-all`, `rd-some`, `rd-day-on`, `rd-back` and
   `rd-pick` were all written into the markup and styled by nothing, so the page
   leaned on `.panel`, `.btn` and `.hint` and the tick rows and their two fields
   had no arrangement of their own. A class with no rule draws as NOTHING -- this
   repo's own fault, recorded against `cl-amber`, `.ac-figs`, an inert `stack`
   and a dead `:first-of-type`. It is the concrete cause of "super easy" being an
   open item on this page.

   ** IT FOLLOWS `.cnt-*` (the daily count) BECAUSE IT IS THE SAME PAGE SHAPE: **
   a site director, on a phone, one column, thumb targets, nothing to scroll
   sideways. Every colour is a token that exists in BOTH palettes, or the rule
   draws wrongly in dark mode -- the `--amber` fault.

   ** AND NEITHER SUBMIT BUTTON IS STICKY, DELIBERATELY. ** `.cnt-save` pins
   itself under 560px because that page has exactly ONE save and a button you
   have to scroll to find is a form that does not get sent. This page has TWO
   sibling forms, each with its own primary button -- "all rooms were available"
   and "send what was not available" -- and pinning either would float it over
   the other form's rooms, which is the one choice a director has to see whole.
   The all-clear panel is first and short, so it is reachable without scrolling.
*/

/* A room that was there for PART of the day -- the sentence that tells a
   director a `No` plus some hours is "ran short", not "missing". Muted and
   tight to the Yes/No above it, because it explains that control. A class with
   no rule draws as NOTHING, which this repo has recorded eight times. */
.rd-part{margin:2px 0 8px;font-size:12px}

/* WHICH DAY. The day being reported is a chip, not bold text: the picker is a
   run of links and the current one needs to read as a position, not emphasis. */
.rd-day-on {
  display: inline-block; padding: 2px 10px; border-radius: 999px;
  background: var(--chip); border: 1px solid var(--line); font-weight: 700;
}
.rd-back, .rd-pick {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px; margin-top: 12px;
}
.rd-back .rd-field, .rd-pick .rd-field { flex: 1 1 200px; }
.rd-back .btn, .rd-pick .btn { flex: 0 0 auto; min-height: 46px; }

/* ONE ROOM. Tighter than a `.panel` of prose, and the tick sits above its two
   fields so the question ("was this room there?") is read before the figures. */
.rd-room { padding: 12px 14px; margin-bottom: 10px; display: grid; gap: 10px; }
.rd-room-off { background: var(--stripe); color: var(--muted); }
.rd-room-off .hint { margin: 0; }

/* 44px of tappable row, and the box is 22px square -- a default checkbox is
   about 13px, which is not a thumb target on the page this is opened on. */
.rd-tick {
  display: flex; align-items: flex-start; gap: 12px; min-height: 44px;
  padding: 2px; cursor: pointer;
}
.rd-tick input[type="checkbox"] {
  width: 22px; height: 22px; flex: 0 0 auto; margin-top: 2px; accent-color: var(--brand);
}
.rd-tick > span { min-width: 0; line-height: 1.3; }

/* ** YES OR NO, NOT A TICK (2026-10-22). ** The user: *"can we make the check
   off yes or no for the room"*. A tick has two states and only one is an
   answer; these are two answers, and a room left blank is refused by name.
   44px of tappable row each, side by side, because the pair is one question. */
/* ** BIG BUTTONS, READ AT ARM'S LENGTH, FILLED IN EVERY DAY (2026-10-27). **
   The user, about this form: "people need to tonthis every day and dont have a
   lit of time.... small font is bad", "maybe more buttons more frindly",
   "phone findly", "low tech frinedly".

   ** THE THREE ANSWERS ARE A COLUMN OF FULL-WIDTH BUTTONS ON A PHONE. ** They
   were inline pills that wrapped wherever they happened to -- two on one line
   and one below, in a different place on every card, which is the layout you
   mis-press when you are doing this at the end of a day. Full width means the
   target is the whole row and the three always sit the same way. They go side
   by side only once there is room for all three at once.

   ** AND THE QUESTION IS A QUESTION, NOT A CAPTION. ** It was 12.5px muted, the
   smallest text on the card, above the thing it asks about. */
.rd-ask {
  display: grid; grid-template-columns: 1fr; gap: 8px;
  border: 0; margin: 0; padding: 0; min-width: 0;
}
.rd-ask-q { font-size: 15px; color: var(--ink); font-weight: 700; padding: 0 0 2px; }
/* ** TWO BY TWO ONCE THERE IS ROOM, NOT FOUR ACROSS (2026-10-27). ** A fourth
   answer arrived ("A different room"), and four in a row inside a card that
   itself sits two-up from 720px would be four cramped targets. Two by two
   keeps every button wide enough to hit with a thumb, and keeps them in the
   same place on every card -- which is the layout you mis-press when you are
   doing this at the end of a day. Full width on a phone, as before. */
@media (min-width: 420px) {
  .rd-ask { grid-template-columns: repeat(2, 1fr); align-items: stretch; }
  .rd-ask-q { grid-column: 1 / -1; }
}
.rd-opt {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  min-height: 52px; text-align: center;
  padding: 0 12px; border: 2px solid var(--line); border-radius: 10px;
  background: var(--surface); font-size: 17px; cursor: pointer;
}
.rd-opt input[type="radio"] { width: 20px; height: 20px; margin: 0; accent-color: var(--brand); }
/* The chosen answer is marked by the BOX, not only by the dot: a 20px radio on
   a phone is read at arm's length, and this page is read at arm's length. */
.rd-opt:has(input:checked) { border-color: var(--brand); background: var(--chip); font-weight: 700; }
/* ** THE CHOSEN ANSWER TAKES ITS OWN COLOUR (2026-10-27). ** Asked for a form
   that looks "fun... and not scary" for people who "wont use" it if it is hard.
   Green, amber and red say what was answered from across a room, which is how
   somebody checks they tapped the right one.

   ** COLOUR IS NEVER THE ONLY SIGNAL. ** The word, the face and the bold
   border all say it too -- a form that relies on red-versus-green is one that
   about one man in twelve cannot read. */
.rd-opt .rd-face { font-size: 22px; line-height: 1; }
.rd-opt { border-radius: 14px; }
.rd-yes:has(input:checked) { border-color: var(--green); background: color-mix(in srgb, var(--green) 12%, transparent); }
.rd-part:has(input:checked) { border-color: var(--amber); background: color-mix(in srgb, var(--amber) 14%, transparent); }
.rd-none:has(input:checked) { border-color: var(--red); background: color-mix(in srgb, var(--red) 12%, transparent); }
/* A swap is its own answer and takes its own colour: not green (they did not
   get what the licence says), not red (they were not left without a room). */
.rd-swap:has(input:checked) { border-color: var(--brand); background: color-mix(in srgb, var(--brand) 12%, transparent); }
/* The all-clear is the press most days end with, so it reads as the happy
   path: full width, tall, and the only button of its colour on the page. */
.rd-allclear { width: 100%; min-height: 58px; font-size: 18px; border-radius: 14px; }
.rd-opt:focus-within { border-color: var(--brand); }
/* "if No" / "optional" beside a field label -- a qualifier, not a second label. */
.rd-only { font-weight: 400; opacity: .75; margin-left: 6px; }
.rd-only::before { content: "("; }
.rd-only::after { content: ")"; }

/* THE TWO FIELDS SIDE BY SIDE WHERE THERE IS ROOM, stacked on a phone (below).
   The label is above its box, never beside it: "Hours lost" and "Why" differ in
   width, and a beside-label layout puts the two boxes at two different places
   down the page. */
.rd-room .rd-field { display: block; }
.rd-field > span {
  display: block; font-size: 12.5px; color: var(--muted);
  letter-spacing: .03em; margin-bottom: 4px;
}
.rd-field input {
  width: 100%; min-height: 46px; font-size: 17px; padding: 6px 10px;
  border: 2px solid var(--line); border-radius: 10px;
  background: var(--surface); color: var(--ink);
}
.rd-field input:focus { border-color: var(--brand); outline: none; }

/* THE TWO ANSWERS. `rd-all` is the one-press all-clear and `rd-some` is the
   list; the gap between the forms is what keeps them reading as two answers to
   one question rather than one long form. */
/* ** TWO ROOMS ACROSS ON A WIDE SCREEN (2026-10-27). ** Asked for as "want to
   compact the room day layout can we put 2 rooms in a xtx to each outher": a
   school with eight rooms was eight full-width cards to answer one day.

   ** ONE COLUMN ON A PHONE. ** Each card holds a Yes/No pair and two fields,
   and two of those at phone width is a column of mis-pressable controls --
   the opposite of compact. `align-items: start` so a card with a long Why
   does not stretch the one beside it. */
/* ** EACH ROOM READS AS A BOX ON A MENU (2026-10-27). ** Asked for as "frame
   the table to make look more boxy or a menu" and "maek it look more frendly".
   The card was a plain panel and its name ran into the question below it; the
   name and the booked hours now sit on their own tinted band across the top,
   so a school scanning eight of them sees eight boxes rather than one long
   column of text.

   The negative margins pull the band out to the card's own edges -- `.rd-room`
   carries 12px/14px of padding -- and the radius matches `.panel`'s 12px less
   the 1px border, so the band sits inside the frame rather than over it. */
.rd-head {
  margin: -12px -14px 2px; padding: 10px 14px;
  background: var(--chip); border-bottom: 1px solid var(--line);
  border-radius: 11px 11px 0 0; align-items: center;
}
.rd-icon { font-size: 24px; line-height: 1; flex: none; }
/* ** THE CARD'S OWN SMALL PRINT IS NOT SMALL PRINT. ** "Booked 4 hours on
   Tuesday" is the fact the whole card is about, and it was rendering at the
   page's muted hint size. Same for the field labels: this form is filled in
   quickly, at arm's length, on a phone. */
.rd-head .hint { font-size: 15px; }
.rd-head b { font-size: 17px; }
.rd-field > span { font-size: 15px; }
/* A little lift under a real pointer, so a card reads as something you work
   through rather than something printed. transform and shadow only, and off
   entirely where motion is unwelcome. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .rd-room { transition: transform .12s ease, box-shadow .12s ease; }
  .rd-room:hover { transform: translateY(-1px); box-shadow: var(--shadow); }
}

/* ** THE TWO "if No" FIELDS ONLY TAKE SPACE ONCE THEY ARE NEEDED (2026-10-27). **
   The user: "class tiles need to be opiomised bad use if space... bad ux". On a
   day when every room was fine -- which is most days -- a school was scrolling
   past two empty boxes per room to reach the next question.

   ** THE REVEAL IS NATIVE AND NEEDS NO SCRIPT. ** Same mechanism the waiting
   list already uses here: `:has()` with an `@supports` guard. In a browser
   WITHOUT `:has()` the fields are simply always visible, which is exactly the
   form as it was -- never a control somebody cannot reach, which is this app's
   standing rule for anything that depends on the browser. */
.rd-when-no { display: grid; gap: 10px; }
@media (min-width: 560px) {
  .rd-room:not(.rd-room-off) .rd-when-no {
    grid-column: 1 / -1; grid-template-columns: 120px minmax(160px, 1fr); align-items: end;
  }
}
@supports selector(:has(*)) {
  .rd-room .rd-when-no { display: none; }
  /* ** "part" AND "none" BOTH REVEAL IT. ** A school that lost a room all day
     still has something to say about why; only the hours box is about "part",
     and two separate reveals on one card would be fiddlier than the space it
     saves. */
  .rd-room:has(input[type="radio"][value="part"]:checked) .rd-when-no,
  .rd-room:has(input[type="radio"][value="none"]:checked) .rd-when-no { display: grid; }
  /* ** THE SWAP BOX IS ITS OWN REVEAL, on the one answer that asks for it. **
     "Hours you had" means nothing when the booked room went entirely, and
     "Why" is about why THIS room was gone -- so a swap shows neither, and
     every other answer shows no swap box. Without `:has()` all of them are
     simply visible, which is the form as it was: never a box somebody cannot
     reach. */
  .rd-room .rd-when-swap { display: none; }
  .rd-room:has(input[type="radio"][value="swap"]:checked) .rd-when-swap { display: grid; }
}
.rd-when-swap { display: grid; gap: 10px; }
@media (min-width: 560px) {
  .rd-room:not(.rd-room-off) .rd-when-swap { grid-column: 1 / -1; }
}

.rd-rooms { display: grid; gap: 10px; align-items: start; }
@media (min-width: 720px) { .rd-rooms { grid-template-columns: 1fr 1fr; } }
.rd-rooms .panel { margin-bottom: 0; }

.rd-all { margin-bottom: 18px; }
.rd-some { margin-bottom: 18px; }
.rd-all .btn, .rd-some .btn { width: 100%; min-height: 52px; font-size: 17px; }

@media (min-width: 560px) {
  /* TWO COLUMNS FOR TWO FIELDS, and the tick spans both above them. Hours is a
     number and Why is free text, so Why takes the slack. ** The columns are on
     `:not(.rd-room-off)` because an unbooked room's card holds a NAME and a
     SENTENCE, not two fields -- two columns there would set the explanation
     beside the room name instead of under it. ** */
  .rd-room:not(.rd-room-off) { grid-template-columns: 120px minmax(160px, 1fr); align-items: end; }
  .rd-room:not(.rd-room-off) .rd-tick,
  .rd-room:not(.rd-room-off) .rd-ask { grid-column: 1 / -1; }
  .rd-all .btn, .rd-some .btn { width: auto; min-width: 260px; }
}
/* ** THE MORNING CALL-OUT, ABOVE THE ROOMS (2026-10-27). ** The user: *"at the
   top of the form you can put in if any staff has called out"*, and *"they will
   need to send the staff call out in the morning and then later the room
   availblry"*. So it is its own form with its own button, a SIBLING of the two
   room forms, and it reuses their `.rd-ask` / `.rd-opt` answer tiles so the two
   questions on one page read as one app.

   ** IT IS INSIDE THIS OWNED BLOCK ON PURPOSE. **
   `test/room-day-page-has-css.test.js` asserts the block carries no literal
   colour and pins nothing; rules written outside it are covered by neither. */
.rd-callout { margin-bottom: 18px; }
.rd-callout .btn { width: 100%; min-height: 52px; font-size: 17px; }

/* THE TWO BIG BUTTONS. Green for "everyone's in" and amber for "someone called
   out" -- the same two tokens the rooms' own Yes and Part answers use, so a
   school answering both questions is reading one colour language. ** BOTH
   TOKENS EXIST IN BOTH PALETTES ** or the rule draws wrongly in dark mode,
   which is the `--amber` fault that once drew a programme tag as a transparent
   disc. */
.rd-allin:has(input:checked) { border-color: var(--green); background: color-mix(in srgb, var(--green) 12%, transparent); }
.rd-someout:has(input:checked) { border-color: var(--amber); background: color-mix(in srgb, var(--amber) 14%, transparent); }

/* ** THE TICKS ONLY TAKE SPACE ONCE SOMEBODY SAYS SOMEBODY IS OUT. ** Most days
   nobody is, so a school would otherwise scroll past its whole staff list to
   reach the send button. Same mechanism as the rooms' own "if No" fields:
   `:has()` behind an `@supports` guard, so a browser WITHOUT `:has()` simply
   shows the ticks always -- the form with nothing hidden, never a control
   somebody cannot reach. There is no script on this page at all. */
.rd-who { display: grid; gap: 8px; }
@supports selector(:has(*)) {
  .rd-callout .rd-who { display: none; }
  .rd-callout:has(input[type="radio"][value="someout"]:checked) .rd-who { display: grid; }
}
/* ONE TICK PER PERSON, wrapping. A school with twenty staff gets a block of
   thumb-sized targets rather than twenty full-width rows. */
.rd-whos { display: flex; flex-wrap: wrap; gap: 8px; }
.rd-whos .rd-opt { flex: 1 1 200px; }
.rd-whos .rd-opt input[type="checkbox"] { width: 20px; height: 20px; margin: 0; accent-color: var(--brand); }

/* ** GROUPED BY BAND, AND A REASON PER PERSON (2026-10-28). ** Asked: "can we
   use band when allowing sites to report staff is calling out", and "can you
   allow mw to make why they called out... may be sick peronal or ther with a
   note". The user chose HEADINGS over a tag beside each name -- a director
   calling out is what decides whether cover is needed.

   The heading is the page's own .label voice, so a band reads like every other
   section label in this app rather than inventing a third kind of heading. */
.rd-band { margin: 0 0 10px; }
.rd-band-h { margin: 10px 0 6px; }
.rd-band:first-of-type .rd-band-h { margin-top: 0; }
.rd-one { margin: 0 0 6px; }
.rd-person { width: 100%; justify-content: flex-start; text-align: left; }
.rd-person input[type="checkbox"] { width: 20px; height: 20px; margin: 0; accent-color: var(--brand); }

/* ** THE REASON APPEARS ONLY ONCE SOMEBODY IS TICKED. ** Twenty-six people
   times three buttons and a note box, all open, is the wall this panel exists
   to avoid -- and the common case is that NOBODY is out. Same mechanism as the
   ticks above and the rooms' own "if No" fields: :has() behind an @supports
   guard, so a browser without :has() simply shows every reason box. That is the
   form with nothing hidden, never a control somebody cannot reach, and there is
   no script on this page at all. */
.rd-why { display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  margin: 6px 0 0 10px; padding: 6px 0 2px 10px; border-left: 2px solid var(--line); }
.rd-why-q { font-size: 13px; color: var(--muted); margin-right: 2px; }
.rd-why .rd-opt { min-height: 40px; font-size: 15px; padding: 0 10px; flex: 0 1 auto; }
.rd-why .rd-face { font-size: 17px; }
.rd-note { flex: 1 1 180px; min-width: 150px; }
@supports selector(:has(*)) {
  .rd-one .rd-why { display: none; }
  .rd-one:has(input[type="checkbox"]:checked) .rd-why { display: flex; }
}
/* The chosen reason takes its own colour, as the room answers do. Never the
   only signal: the word and the face are both there, and both palettes define
   all three variables -- a colour defined in one draws as nothing in the other,
   which is the --amber fault that once rendered a programme tag as a
   transparent disc. */
.rd-why-sick:has(input:checked) { border-color: var(--red); background: color-mix(in srgb, var(--red) 12%, transparent); }
.rd-why-personal:has(input:checked) { border-color: var(--brand); background: color-mix(in srgb, var(--brand) 12%, transparent); }
.rd-why-other:has(input:checked) { border-color: var(--muted); background: var(--chip); }

/* ITS OWN SEND BUTTON, full width on a phone like the other two. ** NOT PINNED:
   this page now has THREE sibling forms, each with its own primary button, and
   pinning any of them would float it over another form's question. */
.rd-callsend { margin-top: 4px; }
@media (min-width: 560px) {
  .rd-callout .btn { width: auto; min-width: 260px; }
}
/* ---- THE DIRECTOR'S ROOM FORM -- END owned block. ---- */

/* ** ON A PHONE, NOTHING RUNS OFF THE RIGHT EDGE (2026-11-06). ** The user:
   *"some items are getting cut off"*. Measured live at 390px: tables sitting
   straight in a section (/payroll, /admin-costs, /booster, the budget form's
   rooms, /rooms) slid the page sideways and cut the right-hand columns and the
   panel edges. A section now scrolls sideways INSIDE itself, the way
   `.table-scroll` already does. The cost, said out loud: a table header inside
   such a section sticks to the section, not the screen, on a phone only.
   The room tables get a floor so their boxes stay typeable ("Ya" was "Yard"),
   a picker and its label are held to the screen (/indeed's chart picker), and
   a live total never breaks its minus sign onto a line of its own. */
@media (max-width:720px){
  .panel{overflow-x:auto;-webkit-overflow-scrolling:touch}
  .sr-form table.roll, .room-groups table.roll{min-width:560px}
  .wrap select, .wrap label:has(select){max-width:100%}
  .livebar b{white-space:nowrap}
}

/* A long hint, folded to its first sentence (views/layout.js foldLongHints, 2026-11-11). */
details.hint-more > summary { list-style: none; cursor: pointer; }
details.hint-more > summary::-webkit-details-marker { display: none; }
details.hint-more > summary .more-link { color: var(--brand); font-weight: 600; white-space: nowrap; }
details.hint-more[open] > summary { display: none; }

/* The money first: a cluster card's income left, and a cluster page's headline (2026-11-11). */
.cluster-money { font-size: 15px; }
.cluster-money b { font-size: 18px; font-variant-numeric: tabular-nums; }
.cx-headline { font-size: 16px; margin: 4px 0 12px; }
.cx-headline b { font-variant-numeric: tabular-nums; }

/* A month's Delete on a school page: still there, small and grey, away from the eye (the user, 2026-11-11). */
.btn.del-quiet { font-size: 11px; padding: 2px 6px; opacity: .55; background: transparent; color: var(--muted); border-color: var(--line); box-shadow: none; }
.btn.del-quiet:hover, .btn.del-quiet:focus { opacity: 1; color: var(--red); border-color: var(--red); }
