/* ══════════════════════════════════════════════════════════════════════════════
   Planbook — app shell stylesheet.

   Lifted from Roll Call!'s design/starter-template.html and design/style-guide.md
   (see CLAUDE.md → "Reference implementation"). This is a lift, not a design: every
   value below is quoted from the style guide. If a value here disagrees with the
   guide, the guide wins.

   Three things read like mistakes and are not:

     1. Colors are declared inline, literally, and never as CSS custom properties.
        Suite convention. A custom property for the navy is not an improvement, it is a
        divergence — the whole suite would have to migrate at once. Don't tidy it.

     2. There is no dark mode: no theme-preference media query, no theme attribute
        selector, no dark variant of anything anywhere in the suite. The navy header IS
        the light theme, not a dark skin of it. (The tokens are spelled out in CLAUDE.md
        and src/README.md; they are deliberately not spelled out in any stylesheet, so
        that grepping a stylesheet for them returns nothing.)

     3. The stylesheet is one file with banner-commented sections and its responsive
        blocks last, in the order design/style-guide.md §6 names: (pointer: coarse),
        then 1024px, then 640px. That order is load-bearing — the touch pass has to be
        able to override the base rules it re-states.

   Convention for the work orders that follow this one: a screen that needs styles of
   its own gets `src/<screen>.css`, loaded after this file, styling only its own class
   names, with its own `@media (pointer: coarse)` block at its end. Two stylesheets must
   never style the same class — that is the only way per-file touch passes stay safe from
   the cascade.
   ══════════════════════════════════════════════════════════════════════════════ */

/* ── BASE ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
  font-family: 'Segoe UI', system-ui, sans-serif;
  background: #f0f2f5;
  color: #1a1a2e;
  min-height: 100vh;
  font-size: 14px;
  /* Stops the whole page rubber-banding behind the header on iOS. */
  overscroll-behavior-y: contain;
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Controls inherit the body font rather than the platform's form-control font. */
button, input, select, textarea { font-family: inherit; }

/* THE ONE PLACE THIS APP TELLS WEBKIT TO STOP DRAWING A CONTROL ITSELF (WO-2.23). Read this before
   adding an eighth date field, and before adding a second `appearance` reset anywhere.

   iOS Safari paints `<input type="date">` as a NATIVE control, and while that painting is in force
   the author's box model is advisory — which is why `min-height: 44px` reaches the text field 10px
   away and does nothing at all to the date field beside it. The owner photographed the result on
   2026-08-10: six date fields across three screens at about half the height of their neighbours,
   every one of them already declared 44px in a coarse block. `appearance: none` hands the box back.
   It is not a restyle of anything; it is what makes rules already written reach the glass.

   WHY ONE ELEMENT RULE RATHER THAN A CLASS PER SHEET. Three classes wear this element today, over
   seven fields on four screens — `.term-date` (the term editor and the days-off form),
   `.assign-field-date` (the assignment editor) and `.student-date` (the plan review date on the
   student editor) — and the reasons not to name them are two, the second deciding:
     · This file may never style a class another sheet owns; that is the rule in the header above,
       and it is what keeps a per-file touch pass safe from the cascade. `.assign-field-date` is
       src/assignments.css's. A grouped selector over all three classes would have to break that
       rule in order to exist. Keyed to the element it does not have to — exactly as the
       `font-family` line above states an app-wide fact about controls without naming anyone's class.
     · A rule keyed to a class is a rule someone must REMEMBER for every date field added after this
       one, and this is the one defect class here that no machine in this repo can catch: the 44px
       sweep skips fields inside `.hidden` dialogs, and desktop Chromium honours `min-height` on a
       date input, so a check written for this goes green on the broken tree. (TESTING.md
       § WO-2.23 records that limit, which is why no check was booked for it.) Keyed to the element
       there is nothing left to remember, and a date field added by a screen that does not exist yet
       arrives fixed.

   WHAT THE RESET COSTS, AND WHERE THAT IS PAID. The native painting takes the native intrinsic
   WIDTH with it, and an empty date field on iOS draws no placeholder to hold the box open. A field
   with no width of its own can therefore collapse onto whatever floor it carries: that is
   `.term-date`, and its coarse rule below sets a legible-date minimum for it. The other two are
   `width: 100%` inside panels that size them.

   TYPE `date` AND NOTHING ELSE, and not `appearance: textfield` either. Every other control in this
   app is drawn natively on purpose — the <select>s are iPadOS's own wheel over the whole screen
   (the `.class-preset-row` comment below and src/days-off.js both say why they are avoided rather
   than restyled), and `.backup-file` keeps its native button, which has its own 44px rule. */
input[type="date"] { -webkit-appearance: none; appearance: none; }

/* One grouped selector for every tappable class in the app. Add each new control
   here in the same pass that adds it — same rule as the (pointer: coarse) block. */
button, .cls-tab, .q-btn, .pill, .modal-close, .hdr-icon-btn, .batch-btn, .class-action-btn,
.install-banner-dismiss, .hdr-year-btn, .year-row, .backup-nag-btn, .loading-error-btn,
.toggle-btn, .support-dot, .hdr-mode-btn, .presentation-strip-btn {
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
}

/* A consistent keyboard focus ring on every interactive element. :focus-visible only shows
   for keyboard and AT users, so a pointer click never draws it — which is why nothing in
   this repo ever needs to suppress an outline, and why nothing in this repo does. The
   suppressing declaration is not written out even inside a comment, so that grepping the
   stylesheet for it returns nothing. Never remove this ring, and never style bare :focus. */
:focus-visible { outline: 2px solid #5b6fcc; outline-offset: 2px; }

.sr-only {
  position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* The show/hide mechanism everywhere in the app. */
.hidden { display: none !important; }

/* ── LOADING ── */
/* Covers the app until boot finishes. Today boot has nothing to wait for and hides it
   immediately; WO-1.4 hides it once the year document is out of IndexedDB. */
#loadingScreen {
  position: fixed; inset: 0; background: #0d2137;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  z-index: 999; gap: 16px;
}
#loadingScreen h2 { color: #fff; font-size: 20px; font-weight: 700; }
#loadingScreen p  { color: rgba(255,255,255,0.5); font-size: 13px; }
/* The storage-failure copy, shown in place of the spinner when boot cannot open the year
   document. Brighter than the "Loading…" line because it is the only thing left on screen,
   and centered at a readable measure rather than running the width of an iPad. */
.loading-error { max-width: 460px; text-align: center; padding: 0 24px; }
#loadingScreen .loading-error p { color: rgba(255,255,255,0.85); line-height: 1.6; }
#loadingScreen .loading-error p + p { margin-top: 10px; }
#loadingScreen .loading-error-detail { color: rgba(255,255,255,0.45); font-size: 11px; }
/* The exit from the boot-failure screen (WO-1.5). On-dark grammar, because this button lives on
   the solid navy overlay and not on a panel — same wash and hairline as `.hdr-icon-btn`. */
.loading-error-btn {
  margin-top: 16px; padding: 10px 18px; border-radius: 8px;
  background: rgba(255,255,255,0.08); border: 1.5px solid rgba(255,255,255,0.25);
  color: #fff; font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.15s;
}
.loading-error-btn:hover { background: rgba(255,255,255,0.18); border-color: rgba(255,255,255,0.4); }
.spinner {
  width: 36px; height: 36px; border: 3px solid rgba(255,255,255,0.15);
  border-top-color: #27ae60; border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ── HEADER ── */
/* Two rows: identity + actions on top, class navigation below. The gradient is the
   suite's identity; the bottom row's black wash is what separates the two rows without
   a border. `padding-top` clears the iOS status bar in a home-screen install — the
   gradient still runs edge to edge, only the content is inset. */
.header {
  background: linear-gradient(135deg, #0d2137 0%, #1a3c5e 60%, #2a2a6e 100%);
  display: flex; flex-direction: column;
  box-shadow: 0 2px 12px rgba(0,0,0,0.3);
  padding-top: env(safe-area-inset-top, 0px);
}
.header-top {
  display: flex; align-items: center; justify-content: space-between;
  min-height: 52px; gap: 12px;
  padding-left: max(20px, env(safe-area-inset-left, 0px));
  padding-right: max(20px, env(safe-area-inset-right, 0px));
}
.header-bottom {
  display: flex; align-items: center; gap: 4px;
  min-height: 48px;
  background: rgba(0,0,0,0.15);
  padding-left: max(16px, env(safe-area-inset-left, 0px));
  padding-right: max(16px, env(safe-area-inset-right, 0px));
}
.header-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
.header-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.header-logo {
  width: 34px; height: 34px; background: rgba(255,255,255,0.12);
  border-radius: 8px; display: flex; align-items: center; justify-content: center;
  font-size: 16px; flex-shrink: 0;
}
.header-title h1 { color: #fff; font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.header-title p  { color: rgba(255,255,255,0.5); font-size: 11px; margin-top: 1px; }
.hdr-icon-btn {
  width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,0.08); border: 1.5px solid rgba(255,255,255,0.15);
  border-radius: 7px; color: rgba(255,255,255,0.6);
  cursor: pointer; transition: all 0.15s; text-decoration: none; flex-shrink: 0;
}
.hdr-icon-btn:hover { background: rgba(255,255,255,0.18); color: #fff; border-color: rgba(255,255,255,0.3); }

/* The open school year, in the header actions. Same on-dark grammar as `.hdr-icon-btn`
   (style guide §1, "On-dark controls") but text-shaped, because the year is the label — a
   glyph for "2026-2027" does not exist. `flex-shrink: 0` keeps it whole when the title beside
   it runs out of room; the title is the thing that should give way, since it says the same
   word on every screen. */
.hdr-year-btn {
  display: flex; align-items: center; gap: 6px;
  padding: 5px 12px; border-radius: 7px;
  background: rgba(255,255,255,0.08); border: 1.5px solid rgba(255,255,255,0.15);
  color: rgba(255,255,255,0.85); font-size: 13px; font-weight: 700;
  cursor: pointer; transition: all 0.15s; white-space: nowrap; flex-shrink: 0;
}
.hdr-year-btn:hover { background: rgba(255,255,255,0.18); color: #fff; border-color: rgba(255,255,255,0.3); }

/* Presentation mode's header control (WO-1.9). It wears `.hdr-icon-btn` for its shape and this
   class for one thing only: the ON state.

   Solid white with the header's own navy inside it — the same inversion `.q-btn.active` uses, and
   design/style-guide.md §5's rule for an active control on a dark surface. It is the loudest thing
   available in a 32px square, which is what the work order's "obviously on when it's on" needs from
   a control with no room for a word beside it. The strip under the header is the rest of that
   answer.

   The hover rule is restated rather than inherited: `.hdr-icon-btn:hover` brightens a translucent
   white wash, and applied on top of a solid white fill it does nothing visible, which reads as a
   button that has stopped responding. */
.hdr-mode-btn.active {
  background: #fff; border-color: #fff; color: #1a3c5e;
}
.hdr-mode-btn.active:hover {
  background: #fff; border-color: #fff; color: #0d2137;
}

/* The class tab row. Empty until WO-1.6 creates classes; `.hdr-empty` is what stands
   in, because a blank navy strip reads as a bug and "No classes yet." is the truth. */
/*
  `flex: 1 1 auto` and a floor, not `flex: 1`, and the difference is the whole class bar at phone
  width. `flex: 1` means a flex-BASIS of 0, and when a flex row is over-full the browser distributes
  the shrinking in proportion to each item's basis — so a basis of 0 shrinks by nothing and simply
  stays at 0. Beside a term nav whose basis is its content, that is exactly what happened: at 390px
  the strip holding every class measured zero pixels wide, tabs and all, and the row looked like a
  term nav with nothing to its left. Measured by tools/verify-shell.mjs; not visible on an iPad,
  because an iPad in portrait is wider than the width where the strip runs out.

  With both strips sized from their content, the over-full row shrinks both of them in proportion
  and each scrolls its own overflow. `min-width` is the floor that keeps either from being crushed
  to nothing by the other — one class named at length, or eight terms, cannot take the row.
*/
.hdr-class-tabs {
  display: flex; gap: 2px; align-items: center;
  flex: 1 1 auto; min-width: 96px;
  overflow-x: auto; -webkit-overflow-scrolling: touch;
}
/* The strip's own voice, for the two states where it holds no tabs: "No classes yet." on a fresh
   install, and "Your classes" on the home view, where WO-1.13 took the class tabs off entirely
   (src/classes.js's refreshClassBar). Not a control and styled so it cannot be read as one — muted,
   small, and no background at all, where every tab on this row has one. `nowrap` because this strip
   scrolls: at 390px it measures ~109px beside a term nav, and a caption that wrapped would push the
   header down over the screen rather than being cut. */
.hdr-empty { color: rgba(255,255,255,0.45); font-size: 12px; white-space: nowrap; }
/*
  `flex-shrink: 0` and `white-space: nowrap` are the fix for a defect found on the iPad, and they
  belong to `.q-btn` below for the same reason. Both of these sit in a strip that scrolls
  (`.hdr-class-tabs`, `.hdr-term-nav`), and a flex item shrinks by default — so with six classes on
  a 390px screen the browser compressed each button to below the width of its own label, and the
  label, still free to wrap, was laid out across the rounded background and past its edge. Names
  spilling over borders is what that looks like; it is not a font-size problem, and it appears at
  whatever name length happens to exhaust the row, which is why landscape showed it too.

  Refusing to shrink puts the overflow where it was always supposed to go — into the strip's own
  horizontal scroll — and `max-width` with an ellipsis caps the pathological single name, so one
  class called "AP Biology Honors Section 4B" cannot push every other tab out of reach.
*/
.cls-tab {
  padding: 4px 14px; border-radius: 6px; border: none; cursor: pointer;
  font-size: 13px; font-weight: 700; transition: all 0.15s;
  background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.55);
  flex-shrink: 0; white-space: nowrap;
  max-width: 40vw; overflow: hidden; text-overflow: ellipsis;
}
.cls-tab:hover { background: rgba(255,255,255,0.16); color: #fff; }
.cls-tab.active { background: #1a1a2e; color: #fff; }
/* "All classes" — the way back to the class grid, at the head of the row (WO-1.13). It is a
   `.cls-tab` and it takes `.active` the same way, because exactly one tab on this strip is where
   you are and the grid is one of the places the strip goes. This class adds one thing: the gap that
   separates the VIEW from the classes in it, so the row reads as "here, then your five classes"
   rather than as six things of one kind. No second colour and no glyph — the words are what tell it
   apart from a class tab (src/classes.js's homeTab()), and a sixth on-dark treatment in a 48px strip
   is a thing to learn rather than a thing to read. */
.cls-tab-home { margin-right: 10px; }
/* The way into the class manager, at the end of the tab strip: a "+" once there are classes, and
   the words "Add a class" when there are none, because a lone + on a fresh install is a puzzle.
   Dashed rather than filled so it reads as a slot instead of as a class named "+". */
.cls-tab-add {
  background: transparent; border: 1.5px dashed rgba(255,255,255,0.3);
  color: rgba(255,255,255,0.6); flex-shrink: 0;
}
.cls-tab-add:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.5); color: #fff; }
/* The right-hand term nav and its divider — styled at WO-1.2, instanced at WO-1.6. */
.hdr-divider { width: 1px; height: 20px; background: rgba(255,255,255,0.18); margin: 0 8px; flex-shrink: 0; }
.hdr-right-controls { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
/* The term nav is its own scroller and a sibling of `.hdr-right-controls` rather than a child of
   it, because that container is `flex-shrink: 0` by declaration and the terms are the one thing in
   this row whose width is teacher-typed: four terms called "Quarter 1"…"Quarter 4" are ~360px of
   buttons, and on a 390px screen an unshrinkable nav puts the whole page into horizontal overflow.
   Both strips shrink and both scroll, so the row cannot overflow whatever is in it.

   `min-width` is the floor added alongside `.hdr-class-tabs`'s — see the comment there for what a
   strip with no floor beside a strip with content does at phone width. It replaces `min-width: 0`,
   which was doing nothing that `overflow-x: auto` was not already doing for it. */
.hdr-term-nav {
  display: flex; align-items: center; gap: 4px;
  flex: 0 1 auto; min-width: 96px; overflow-x: auto; -webkit-overflow-scrolling: touch;
}
/* Same shrink-and-spill fix as `.cls-tab`, and term labels are teacher-typed too: "Quarter 1" is
   the seed, "1st Quarter (progress reports 10/9)" is what someone will eventually type. */
.q-btn {
  padding: 5px 14px; border-radius: 6px; border: none; cursor: pointer;
  font-size: 13px; font-weight: 700; transition: all 0.15s;
  background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
  flex-shrink: 0; white-space: nowrap;
  max-width: 40vw; overflow: hidden; text-overflow: ellipsis;
}
.q-btn:hover:not(:disabled) { background: rgba(255,255,255,0.18); color: #fff; }
.q-btn.active { background: #fff; color: #1a3c5e; }

/* ── PRESENTATION STRIP ── */
/* Shown only while presentation mode is on. Same place in the flow and the same shape as the two
   caution strips below it — in normal flow under the header rather than fixed, for the reasons the
   install banner's comment gives — and written out separately rather than sharing their class,
   which is the decision `.backup-nag` already made: they are components that happen to look alike,
   and the day one of them changes is the day sharing a class name costs more than it saved.

   Purple, from design/style-guide.md §1's "special state" row, and that is the one thing here that
   is not copied from the strips below. Amber in this app means "act on this" — an app that is not
   installed, a year that has never been backed up — and this is not something to act on: it is a
   state the teacher deliberately chose and can see the effect of. Red would be worse still.

   One line rather than the paragraph the other two carry, and `align-items: center` rather than
   `flex-start` because there is nothing to align to the top of. This strip is on screen exactly
   while a class is looking at the screen, so its height is lesson space. */
.presentation-strip {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  flex-wrap: wrap;
  background: #f3eaff; color: #7c3aed; border-bottom: 1px solid #c8a0f0;
  padding: 8px 20px; font-size: 12.5px; line-height: 1.55;
  padding-left: max(20px, env(safe-area-inset-left, 0px));
  padding-right: max(20px, env(safe-area-inset-right, 0px));
}
/* `flex: 1 1 320px`, as on both strips below: under about 380px of usable width the button drops
   to its own line instead of squeezing the sentence into a column two words wide. */
.presentation-strip-text { flex: 1 1 320px; min-width: 0; }
/* Solid rather than an outline, like `.backup-nag-btn`: this is the action, not a dismissal —
   there is no dismissing a mode, only leaving it. */
.presentation-strip-btn {
  border: 1.5px solid #7c3aed; background: #7c3aed; color: #fff;
  border-radius: 6px; padding: 4px 12px; font-size: 12px; font-weight: 700;
  cursor: pointer; transition: background 0.15s; flex-shrink: 0; white-space: nowrap;
}
.presentation-strip-btn:hover { background: #6d28d9; border-color: #6d28d9; }

/* ── INSTALL BANNER ── */
/* The eviction warning, styled as the style guide's caution banner (§1, "Caution banner
   (offline/stale)") and lifted from Roll Call!'s `#staleBanner` — same amber, same border,
   same outline button — because it is the same kind of message: a standing condition the
   teacher should act on, not an error that just happened.

   In normal flow under the header rather than fixed. A fixed strip would have to fight the
   safe-area inset the header already owns, and it would cover content on an iPad in landscape
   where vertical space is the scarce thing. It is shown on every launch until Planbook is
   installed, which is what makes scrolling past it harmless. */
.install-banner {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
  flex-wrap: wrap;
  background: #fff8e6; color: #8a6d1a; border-bottom: 1px solid #f0dfa8;
  padding: 10px 20px; font-size: 12.5px; line-height: 1.55;
  padding-left: max(20px, env(safe-area-inset-left, 0px));
  padding-right: max(20px, env(safe-area-inset-right, 0px));
}
/* `flex: 1 1 320px` rather than a fixed width: below ~380px of usable width the button drops
   to its own line instead of squeezing the prose into a column two words wide. */
.install-banner-text { flex: 1 1 320px; min-width: 0; }
.install-banner p + p { margin-top: 6px; }
.install-banner-dismiss {
  border: 1.5px solid #c9a83f; background: transparent; color: #8a6d1a;
  border-radius: 6px; padding: 4px 12px; font-size: 12px; font-weight: 700;
  cursor: pointer; transition: background 0.15s; flex-shrink: 0; white-space: nowrap;
}
.install-banner-dismiss:hover { background: #f5e8bd; }

/* ── BACKUP NAG ── */
/* The "your last backup was N days ago" strip. Same amber caution styling as the install banner
   above, same place in the flow, and written out separately rather than sharing that class:
   they are two components that happen to look alike, and the day one of them changes is the day
   sharing a class name costs more than it saved.

   No dismiss button, unlike the banner above — src/backup.js says why. */
.backup-nag {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
  flex-wrap: wrap;
  background: #fff8e6; color: #8a6d1a; border-bottom: 1px solid #f0dfa8;
  padding: 10px 20px; font-size: 12.5px; line-height: 1.55;
  padding-left: max(20px, env(safe-area-inset-left, 0px));
  padding-right: max(20px, env(safe-area-inset-right, 0px));
}
.backup-nag-text { flex: 1 1 320px; min-width: 0; }
.backup-nag p + p { margin-top: 6px; }
/* Solid rather than the banner's outline button: this one is the action, not a dismissal. */
.backup-nag-btn {
  border: 1.5px solid #c9a83f; background: #c9a83f; color: #fff;
  border-radius: 6px; padding: 4px 12px; font-size: 12px; font-weight: 700;
  cursor: pointer; transition: background 0.15s; flex-shrink: 0; white-space: nowrap;
}
/* Hover darkens to the caution row's own text color rather than to a new amber. Every hex in
   this file appears in design/style-guide.md §1, and inventing a shade to darken by one step is
   how that stops being true. */
.backup-nag-btn:hover { background: #8a6d1a; border-color: #8a6d1a; }

/* ── MAIN / PANEL ── */
.main {
  padding: 20px; max-width: 1300px; margin: 0 auto;
  padding-left: max(20px, env(safe-area-inset-left, 0px));
  padding-right: max(20px, env(safe-area-inset-right, 0px));
}
.panel { background: #fff; border-radius: 14px; box-shadow: 0 1px 4px rgba(0,0,0,0.07); overflow: hidden; }
.panel-header { padding: 16px 20px 0; }
.panel-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
/* THE BUTTON IN THIS ROW DOES NOT SHRINK, and that is a bug fix rather than a preference. Every
   `.class-action-btn` is `white-space: nowrap`, so a shrunk one does not get shorter — its label
   runs straight out through its own border. On a fine pointer it never shrinks, because a flex item
   without an explicit `min-width` gets `min-width: auto` and refuses to go under its content. The
   coarse block below gives it `min-width: 44px` for the touch floor, and that REPLACES the auto —
   which hands the button permission to shrink to 44px with a two-word label still inside it.

   The owner found it on the iPad on 2026-08-08: "Days off" spilling over its border on the home
   screen, and nowhere on the laptop. The floor is right and stays; what was missing is the refusal
   to shrink that `min-width: auto` had been providing for free. Any nowrap control that takes a
   `min-width` in the coarse block needs this alongside it. */
.panel-title-row > .class-action-btn { flex: 0 0 auto; }
.panel-title h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.panel-title p  { font-size: 11px; color: #8a9bb0; margin-top: 2px; }
.search-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; position: relative; }
.search-box {
  flex: 0 0 360px; display: flex; align-items: center; gap: 7px;
  border: 1.5px solid #e0e4ea; border-radius: 8px; padding: 7px 11px;
}
/* The source template suppresses the focus outline on this input. That is dropped rather
   than lifted: the box draws no focus affordance of its own, so losing the ring would leave
   a keyboard user with no idea where they are. */
.search-box input { border: none; font-size: 13px; width: 100%; background: transparent; color: #1a1a2e; }
.search-box input::placeholder { color: #a0aab8; }
.pills { display: flex; gap: 5px; position: absolute; left: 50%; transform: translateX(-50%); }
.pill {
  padding: 5px 12px; border-radius: 20px; font-size: 11px; font-weight: 600;
  border: 1.5px solid #e0e4ea; background: #fff; color: #6b7a8d; cursor: pointer; white-space: nowrap;
}
.pill.active { background: #27ae60; border-color: #27ae60; color: #fff; }
.empty-state { padding: 48px 20px; text-align: center; color: #a0aab8; font-size: 13px; }

/* ── INSET TOOLBAR ── */
/* portable-components.md §4's `.date-batch-bar`: the gray inset strip under a panel
   header, hairlined top and bottom, that holds wash-colored `.batch-btn` chips.

   IT STILL HAS NO INSTANCE IN THE APP. The shelf was the only one, and the save chip it used to
   hold is pinned to the viewport corner instead (see `#saveIndicator` below), because every edit
   in this app happens inside a modal that covers a panel. WO-1.10 kept it on the expectation that
   WO-2.1's marking screen would fill it with a date; WO-2.1 shipped a registry grid instead, where
   the dates are column heads and the day being edited is named in a strip of that screen's own —
   so this is still a lift waiting for its first use. Kept rather than deleted, for the reason it
   was kept before: re-lifting a component from Roll Call! is more work than leaving the lift in
   place. `.search-box` above is no longer in that position — WO-2.1's registry is the first screen
   with something to search, and it wears it as it sits. */
.date-batch-bar {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 8px 20px; background: #f8f9fb;
  border-top: 1px solid #eef0f4; border-bottom: 1px solid #eef0f4; font-size: 12px;
  flex-wrap: wrap;
}

/* ── BUTTONS ── */
/* Two shapes, both from the style guide's wash/strong grammar: `.class-action-btn` is
   the outline button (solid fill only for a primary action), `.batch-btn` is the wash
   chip. Solid strong-color fills are reserved for active states and primary CTAs. */
.class-action-btn {
  padding: 4px 12px; border-radius: 6px; font-size: 11px; font-weight: 700;
  border: 1.5px solid #e0e4ea; background: #fff; color: #6b7a8d;
  cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.class-action-btn:hover:not(:disabled) { border-color: #5b6fcc; color: #5b6fcc; }
.class-action-btn.primary { background: #5b6fcc; border-color: #5b6fcc; color: #fff; }
.class-action-btn.primary:hover:not(:disabled) { background: #4a5fbb; border-color: #4a5fbb; color: #fff; }
/* A solid fill is reserved for a primary action, and the one destructive button in the app is
   one: it is the confirm on a restore, which overwrites a school year. Danger red rather than
   the interactive indigo, so the button that cannot be undone does not look like every other
   "OK". Style guide §1, danger. */
.class-action-btn.danger { background: #e74c3c; border-color: #e74c3c; color: #fff; }
.class-action-btn.danger:hover:not(:disabled) { background: #c0392b; border-color: #c0392b; color: #fff; }
/* Per-action hover tints, lifted from Roll Call! (dashboard.html ~641-643) so that in a row of
   five buttons the destructive one never looks like the other four: archive takes caution amber,
   restore takes positive green, delete takes danger red. Hover only — the resting row stays one
   visual family, because colouring five buttons five ways is a row nobody can scan. */
.class-action-btn.archive:hover:not(:disabled) { border-color: #e67e22; color: #e67e22; }
.class-action-btn.restore:hover:not(:disabled) { border-color: #27ae60; color: #27ae60; }
.class-action-btn.delete:hover:not(:disabled)  { border-color: #e74c3c; color: #e74c3c; background: #fdeaea; }
/* The reorder arrows. One glyph wide, so the padding narrows and the glyph grows — an ↑ at the
   button's 11px reads as a speck rather than as a direction. */
.class-action-btn.move { padding: 4px 10px; font-size: 13px; }
/* "Nothing open to back up", which is what the download button says when boot() refused a
   document and the panel was opened from the loading screen. Also both reorder arrows at the ends
   of the list, which is the case a teacher meets first: the top class cannot move up. */
.class-action-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.batch-btn {
  border: none; border-radius: 6px; padding: 4px 11px;
  font-size: 11px; font-weight: 700; cursor: pointer; transition: opacity 0.1s;
}
.batch-btn:hover { opacity: 0.8; }
.batch-btn.p { background: #eafaf1; color: #27ae60; }
.batch-btn.a { background: #fdeaea; color: #e74c3c; }
.batch-btn.i { background: #eef2ff; color: #5b6fcc; }
.batch-btn.w { background: #fef5ea; color: #e67e22; }

/* The seven `.shelf-*` / `.swatch` rules that used to sit here went at WO-1.10, with the
   component shelf they styled — the rule above them said they would, and the home screen is
   what replaced it. The palette proof row went with them: design/style-guide.md §1 is still
   diffable against this file by grepping it for hexes, which is what that row was for.
   Nothing else in the app ever used one of them. */

/* ── SAVE INDICATOR ── */
/* Five states, per the style guide: saving and retry orange, saved green, error red,
   syncing indigo. Roll Call!'s sixth state (`queued`) is deliberately absent — it means
   "waiting on the Apps Script outbox", and Planbook has no outbox to wait on. */
.save-indicator {
  font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 10px;
  transition: all 0.2s; opacity: 0; white-space: nowrap;
}
/* The one live indicator floats above the modal layer. `#saveIndicator` rather than
   `.save-indicator` because the shelf used to show all five states as static samples inline;
   they went at WO-1.10 and the id stays, so that a second chip on a later screen is styled by
   choosing it rather than by inheriting a fixed position it did not ask for.
   Found on the WO-1.7 iPad sitting: every edit to a student or a guardian happens inside a
   modal, and a modal covers the header. The teacher changed a guardian's email, closed the
   panel, and had nothing telling her it landed. The save was real — the store flushes on
   visibility change — but an app holding a term of grades cannot answer "did that save?"
   with silence. `pointer-events: none` because at rest this is an invisible chip
   (`opacity: 0`) sitting over the top-right corner, and it must never eat a tap. */
#saveIndicator {
  position: fixed; z-index: 1050; pointer-events: none;
  top: calc(env(safe-area-inset-top, 0px) + 10px);
  right: calc(env(safe-area-inset-right, 0px) + 12px);
}
.save-indicator.saving  { background: #fef5ea; color: #e67e22; opacity: 1; }
.save-indicator.saved   { background: #eafaf1; color: #27ae60; opacity: 1; }
.save-indicator.error   { background: #fdeaea; color: #e74c3c; opacity: 1; }
.save-indicator.syncing { background: #eef2ff; color: #5b6fcc; opacity: 1; }
.save-indicator.retry   { background: #fef5ea; color: #e67e22; opacity: 1; }

/* ── AVATAR ── */
/* Ten fixed classes assigned by `id % 10`, so a student keeps the same color everywhere
   in the app without anything being stored. Instanced by WO-1.7. */
.avatar {
  width: 32px; height: 32px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 11px; font-weight: 800; color: #fff; flex-shrink: 0;
}
.av0{background:#5b6fcc}.av1{background:#27ae60}.av2{background:#e67e22}
.av3{background:#e74c3c}.av4{background:#8e44ad}.av5{background:#0097a7}
.av6{background:#c0392b}.av7{background:#1a6b3a}.av8{background:#2471a3}.av9{background:#d4ac0d}

/* ── MODAL ── */
/* z-index ladder, kept from Roll Call! so fixes stay portable across the suite:
   content banners 90 · loading 999 · modals 1000 · setup screens 1001 · hero 1100.
   Planbook adds one rung Roll Call! does not have: the save indicator at 1050, above the
   modal layer. Roll Call! never needed it because its saves are announced by its own
   dialogs; Planbook edits students and guardians inside modals, and the confirmation has
   to be legible from in there (WO-1.7 iPad sitting). If this ladder is ever synced back to
   the suite, that rung is the deliberate divergence — not drift. */
/* The overlay scrolls, and the panel is centered by `margin: auto` rather than by
   align-items alone. Centering a too-tall flex child clips its top edge with no way to
   scroll back to it — an iPad in landscape is 704px of usable height, which any modal
   with a form in it will pass. This is an addition to the lift, not part of it. */
.modal-overlay {
  position: fixed; inset: 0; background: rgba(0,0,0,0.5);
  z-index: 1000; display: flex; align-items: center; justify-content: center;
  padding: 20px; overflow-y: auto; -webkit-overflow-scrolling: touch;
}
.modal-panel {
  background: #fff; border-radius: 14px; width: 480px; max-width: 95vw;
  box-shadow: 0 8px 32px rgba(0,0,0,0.25); overflow: hidden;
  margin: auto; animation: srIn 0.18s ease;
}
@keyframes srIn {
  from { opacity: 0; transform: scale(0.96) translateY(8px); }
  to   { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-header {
  background: linear-gradient(135deg, #0d2137 0%, #1a3c5e 100%);
  padding: 16px 20px; display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.modal-header h2 { color: #fff; font-size: 15px; font-weight: 700; }
.modal-close {
  background: rgba(255,255,255,0.12); border: none; color: #fff;
  width: 28px; height: 28px; border-radius: 6px; cursor: pointer;
  font-size: 13px; font-weight: 700; flex-shrink: 0;
}
.modal-close:hover { background: rgba(255,255,255,0.22); }
.modal-body { padding: 20px; }
.modal-body p { font-size: 13px; color: #6b7a8d; line-height: 1.6; }
.modal-body p + p { margin-top: 10px; }
.modal-section-label {
  font-size: 10px; font-weight: 700; color: #8a9bb0;
  letter-spacing: 0.8px; text-transform: uppercase; margin-bottom: 8px;
}
/* Roll Call! spaces this with an inline `style="margin-top:18px"` at each use site
   (dashboard.html ~1394). Here it is a rule, because a label following prose always wants
   the gap and a stylesheet is a better place to say so once. */
.modal-body p + .modal-section-label { margin-top: 16px; }
/* Left-aligned, because that is what `.modal-actions` is in Roll Call! (dashboard.html
   ~651). The right-aligned footer over there is `.config-actions` and belongs to the
   settings modal tier, which Planbook has no instance of yet. Shared class names are what
   make a fix portable between the two apps, so the name keeps its meaning and only the
   top margin is added. */
.modal-actions { display: flex; gap: 8px; margin-top: 18px; margin-bottom: 4px; }

/* ── YEAR PICKER ── */
/* The contents of #yearModal. A row per year document on this device, and a field to start
   another. Rows are full-width buttons rather than a <select>: on an iPad a select is a
   spinning wheel over the whole screen, and there are never more than a handful of years.
   The open one takes the interactive wash + tinted border rather than the solid fill, because
   solid is reserved for a primary action and this row is not one — it is where you already
   are (style guide §1). */
.year-list { display: flex; flex-direction: column; gap: 6px; }
.year-row {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  width: 100%; text-align: left; cursor: pointer; transition: all 0.15s;
  padding: 10px 12px; border-radius: 8px;
  border: 1.5px solid #e0e4ea; background: #fff;
  font-size: 13px; font-weight: 600; color: #1a1a2e;
}
.year-row:hover { border-color: #5b6fcc; color: #5b6fcc; }
.year-row.current { background: #eef2ff; border-color: #b0bcf0; color: #5b6fcc; }
.year-row-note { font-size: 11px; font-weight: 700; color: #8a9bb0; }
.year-row.current .year-row-note { color: #5b6fcc; }
.year-form { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.year-input {
  flex: 1 1 auto; min-width: 0;
  border: 1.5px solid #e0e4ea; border-radius: 8px; padding: 8px 11px;
  font-size: 13px; color: #1a1a2e; background: #fff;
}
.year-input::placeholder { color: #a0aab8; }
/* Both of these are <p> inside `.modal-body`, whose own rule is more specific than a bare
   class — so they are qualified rather than left to lose the cascade. */
.modal-body .year-hint { font-size: 11px; color: #8a9bb0; margin-top: 10px; line-height: 1.5; }
.modal-body .year-error {
  margin-top: 10px; padding: 8px 11px; border-radius: 8px;
  background: #fdeaea; color: #c0392b; font-size: 12px; line-height: 1.5;
}

/* ── CLASSES & TERMS ── */
/* The contents of #classesModal, #termsModal and #classDeleteModal.
   `.class-list` / `.class-row` / `.class-row-name` / `.class-row-actions` / `.class-empty` /
   `.rename-input` are lifted from Roll Call!'s class manager (dashboard.html ~626-667) rather
   than designed here, names included, so that a fix to the row grammar stays portable between the
   two apps — which is the whole point of design/portable-components.md §5.

   The row wraps. Five action buttons at 44px each is 244px of the 440px inside a modal panel, and
   the name beside them is teacher-typed and arbitrarily long; letting the actions drop to a second
   line is what keeps a class called "Honors Biology — Period 3 (Lab)" readable instead of
   squeezed into a column two words wide. */
.class-list { display: flex; flex-direction: column; gap: 4px; }
.class-row {
  display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
  padding: 8px 10px; border-radius: 8px;
  border: 1.5px solid #eef0f4; background: #f8f9fb;
}
/* An archived row is quieter and dashed, and still fully legible: it is a class the teacher chose
   to set aside, not a disabled control. */
.class-row.archived { background: #fff; border-style: dashed; }
.class-row-name { flex: 1 1 120px; min-width: 0; font-size: 13px; font-weight: 600; color: #1a1a2e; }
.class-row-note { font-size: 11px; font-weight: 700; color: #8a9bb0; white-space: nowrap; }
/* WO-3.1's warning, on the screen a teacher sets five classes up from. It is a BADGE in a row
   rather than the banner the categories panel carries — same shape and same measurements as
   `.dayoff-kind` below, in the warn palette from design/style-guide.md §1 (`#fff3cd` wash,
   `#c0700a` text) — because a full-width amber strip per class would turn a list of five into a
   wall of amber and stop being read by the second one. It is drawn only when the weights do not
   add up, so its presence is the signal and its text is the number. */
.class-row-warn {
  flex: 0 0 auto; padding: 2px 8px; border-radius: 20px;
  border: 1.5px solid #f5c98a; background: #fff3cd; color: #c0700a;
  font-size: 10px; font-weight: 700; letter-spacing: 0.4px; white-space: nowrap;
}
.class-row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-left: auto; }
.class-empty { font-size: 13px; color: #a0aab8; padding: 4px 2px 6px; line-height: 1.5; }

/* Rename happens in place, in the row, rather than in a modal on top of a modal. The border is
   the interactive indigo so it is obvious which name is being edited. */
.class-rename-form { display: flex; align-items: center; gap: 6px; flex: 1 1 200px; min-width: 0; }
.rename-input {
  flex: 1 1 auto; min-width: 0;
  border: 1.5px solid #5b6fcc; border-radius: 6px; padding: 5px 9px;
  font-size: 13px; font-weight: 600; color: #1a1a2e; background: #fff;
}

/* "Add a class": the same field-plus-button row as the year picker's, written out separately for
   the reason the install banner and the backup nag are written out separately — two components
   that happen to look alike, until the day one of them changes. */
.class-form { display: flex; align-items: center; gap: 8px; }
.class-input {
  flex: 1 1 auto; min-width: 0;
  border: 1.5px solid #e0e4ea; border-radius: 8px; padding: 8px 11px;
  font-size: 13px; color: #1a1a2e; background: #fff;
}
.class-input::placeholder { color: #a0aab8; }
/* The term-structure choice for a new class: `.pill` chips in a `data-pill-group`, which shell.js
   already single-selects. Pills rather than a <select> for the reason the year rows are buttons —
   on an iPad a select is a spinning wheel over the whole screen. */
.class-preset-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.class-preset-label {
  font-size: 10px; font-weight: 700; color: #8a9bb0;
  letter-spacing: 0.8px; text-transform: uppercase;
}

/* The term editor. One row per term: name, two dates, remove. The row wraps for the same reason
   the class row does, and the dates wrap as a pair so "Starts" and its field never separate. */
.term-list { display: flex; flex-direction: column; gap: 6px; }
.term-row {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
  padding: 8px 10px; border-radius: 8px;
  border: 1.5px solid #eef0f4; background: #f8f9fb;
}
.term-label-input {
  flex: 1 1 140px; min-width: 0;
  border: 1.5px solid #e0e4ea; border-radius: 6px; padding: 6px 9px;
  font-size: 13px; font-weight: 600; color: #1a1a2e; background: #fff;
}
.term-dates { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.term-date-field { display: flex; align-items: center; gap: 5px; }
.term-date-label {
  font-size: 10px; font-weight: 700; color: #8a9bb0;
  letter-spacing: 0.8px; text-transform: uppercase;
}
.term-date {
  border: 1.5px solid #e0e4ea; border-radius: 6px; padding: 5px 7px;
  font-size: 12px; color: #1a1a2e; background: #fff;
}
.term-presets { display: flex; flex-wrap: wrap; gap: 6px; }
.term-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }

/* What a deletion destroys, in the danger wash — the one place in this feature that gets the red,
   because it is the one thing here that cannot be undone. The counts inside are read off the open
   document by src/classes.js. */
.class-delete-facts {
  display: flex; flex-direction: column; gap: 4px;
  margin-top: 4px; padding: 10px 12px; border-radius: 8px;
  border: 1.5px solid #f0b0b0; background: #fdeaea;
}
.class-delete-line { font-size: 12px; line-height: 1.5; color: #c0392b; }

/* ── CATEGORIES & WEIGHTS (WO-3.1) ── */
/* The contents of #categoriesModal and #categoryRemoveModal. Almost everything in those panels is
   a component this sheet already owns — `.class-action-btn` for every button, `.class-empty` for
   the empty list, `.class-hint`, `.class-error`, `.modal-section-label`, and
   `.class-delete-facts` / `.class-delete-line` for what a removal destroys — which is the point:
   a sixth dialog inventing a sixth grammar would be a sixth thing to keep in step. What is written
   out here is the five things with no counterpart.

   The row is `.term-row`'s shape with a number where the dates are, and it wraps for the same
   reason that one does: three action buttons at 44px apiece is 132px of the 440px inside a modal
   panel, and the name beside them is teacher-typed and arbitrarily long. */
.category-list { display: flex; flex-direction: column; gap: 6px; }
.category-row {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
  padding: 8px 10px; border-radius: 8px;
  border: 1.5px solid #eef0f4; background: #f8f9fb;
}
.category-name-input {
  flex: 1 1 140px; min-width: 0;
  border: 1.5px solid #e0e4ea; border-radius: 6px; padding: 6px 9px;
  font-size: 13px; font-weight: 600; color: #1a1a2e; background: #fff;
}
/* Roll Call!'s `.config-num` + `.config-num-wrap` pair (dashboard.html ~711-716), lifted with its
   measurements rather than re-derived: a 58px centred numeric field with its unit beside it in the
   secondary grey. The two are one flex item so that "%" can never wrap away from the number it
   belongs to — and the unit is a sibling rather than a character in the field, because a percent
   sign inside the box would make the stored value a string somebody has to parse. */
.category-weight-field { display: flex; align-items: center; gap: 4px; flex: 0 0 auto; }
.category-weight {
  width: 58px; text-align: center; padding: 4px 6px;
  border: 1.5px solid #e0e4ea; border-radius: 6px;
  font-size: 13px; color: #1a1a2e; background: #fff;
}
.category-weight-unit { font-size: 13px; font-weight: 700; color: #6b7a8d; }
.category-row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-left: auto; }
.category-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }

/* THE PERSISTENT TOTAL, drawn in both states.
   The warning is Roll Call!'s inline notice banner (`#noSchoolBanner`, dashboard.html:1294) copied
   with its own values — `#fef5ea` on an `#e67e22` border with `#7a4f10` text — rather than
   re-derived from the palette table, because that strip IS this app's counterpart to it and
   portable-components.md §6 names it as the thing to reuse for exactly this.
   The balanced state takes `.dayoff-keeps`'s positive wash, and for the same reason that one has
   it: the two are the same sentence from opposite ends, and a green line saying the weights are
   right must not look like the amber line saying they are not.
   Qualified with `.modal-body` because it is a <p> in there, and `.modal-body p` is more specific
   than a bare class — the same reason `.year-hint` and `.class-hint` are qualified. */
.modal-body .category-total {
  margin-top: 10px; padding: 10px 14px; border-radius: 8px;
  border: 1.5px solid #a3e4bc; background: #eafaf1; color: #1a6b3a;
  font-size: 12px; font-weight: 700; line-height: 1.5;
}
.modal-body .category-total.warn { border-color: #e67e22; background: #fef5ea; color: #7a4f10; }

/* ── LETTER GRADES (WO-3.2) ── */
/* The contents of #letterScaleModal, plus the one door to it in the class manager. Almost
   everything in that panel is a component this sheet already owns — `.class-action-btn` for every
   button, `.pill` for the subject row, `.class-empty` for an empty scale, `.class-hint` and
   `.modal-section-label` — which is the point: a seventh dialog inventing a seventh grammar would
   be a seventh thing to keep in step. What is written out here is the eight things with no
   counterpart.

   The row is `.category-row`'s shape with a letter where the name is and a derived range chip after
   the number, and it wraps for the same reason that one does: two action buttons and a Remove at
   44px apiece is most of the 440px inside a modal panel, and the range chip beside them is as long
   as its two boundaries make it. */
.scale-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
/* Which scale is being looked at, and the door to a per-class override. `.pill`s in the
   `.dayoff-kinds` shape rather than a <select>, for the reason that row gives: a <select> on an iPad
   is a spinning wheel over the whole screen, and this is a short list whose consequence is drawn
   immediately below it. */
.scale-subjects { display: flex; flex-wrap: wrap; gap: 6px; }
/* Whose bands these are, said in a sentence with the switch beside it. A flex row rather than a
   washed panel: `.dayoff-keeps` and `.class-delete-facts` are for what a tap keeps or destroys, and
   this is a statement of fact with a control in it. */
.scale-override { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.scale-override-note { flex: 1 1 200px; min-width: 0; font-size: 11px; color: #8a9bb0; line-height: 1.5; }

.band-list { display: flex; flex-direction: column; gap: 6px; }
.band-row {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
  padding: 8px 10px; border-radius: 8px;
  border: 1.5px solid #eef0f4; background: #f8f9fb;
}
/* A band that applies to the selected class but is edited under "Every class". Dashed and on white,
   which is `.class-row.archived`'s grammar for the same meaning — this row is real and is not
   yours to change here. */
.band-row.inherited { background: #fff; border-style: dashed; }
/* A letter, not a word: two or three characters, centred, and bold because it is the thing the row
   is about. 64px rather than the 58px `.category-weight` uses — "A−" plus a caret is narrower than
   three digits and a spinner, but the field is the row's first target and the extra 6px is what
   keeps it from reading as an afterthought beside the number. */
.band-letter-input {
  width: 64px; flex: 0 0 auto; text-align: center; padding: 6px 8px;
  border: 1.5px solid #e0e4ea; border-radius: 6px;
  font-size: 13px; font-weight: 700; color: #1a1a2e; background: #fff;
}
/* The same letter as text, on an inherited row. */
.band-letter { width: 64px; flex: 0 0 auto; text-align: center; font-size: 13px; font-weight: 700; color: #1a1a2e; }
/* `.category-weight-field`'s pair, which lifted Roll Call!'s `.config-num` + `.config-num-wrap`
   (dashboard.html ~711-716): the number and its unit as one flex item, so "%" can never wrap away
   from the field it belongs to. Written out rather than shared for the reason the install banner and
   the backup nag are written out separately — two components that happen to look alike, until the
   day one of them changes. */
.band-min-field { display: flex; align-items: center; gap: 4px; flex: 0 0 auto; }
.band-min {
  width: 66px; text-align: center; padding: 4px 6px;
  border: 1.5px solid #e0e4ea; border-radius: 6px;
  font-size: 13px; color: #1a1a2e; background: #fff;
}
.band-min-unit { font-size: 13px; font-weight: 700; color: #6b7a8d; }
/* THE DERIVED RANGE, and the fourth deliverable: what this band actually covers, on the row, so a
   scale's shape is legible without doing eleven subtractions. Quiet by default — it is a
   consequence, not a control — in `.class-row-note`'s secondary grey. The warn state is the
   `.class-row-warn` badge in full (the `#fff3cd` wash on `#f5c98a` with `#c0700a` text, from
   design/style-guide.md §1), because "never reached" is the same class of fact as "weights 95%": a
   thing to fix, not a thing that destroys anything, so it is amber and never red. */
.band-range { flex: 1 1 120px; min-width: 0; font-size: 11px; font-weight: 700; color: #8a9bb0; }
.band-range.warn {
  flex: 0 0 auto; padding: 2px 8px; border-radius: 20px;
  border: 1.5px solid #f5c98a; background: #fff3cd; color: #c0700a;
  font-size: 10px; letter-spacing: 0.4px; white-space: nowrap;
}
.band-row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-left: auto; }
.band-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }

/* THE STANDING NOTE, drawn in both states, and the same two washes `.category-total` carries: the
   positive one from `.dayoff-keeps` and the warning from Roll Call!'s inline notice banner
   (`#noSchoolBanner`, dashboard.html:1294). Written out rather than sharing that class, for the
   reason `.band-min-field` is: they are two sentences about two different things, and the day one of
   them changes is the day sharing costs more than it saved.
   Qualified with `.modal-body` because it is a <p> in there, and `.modal-body p` is more specific
   than a bare class — the same reason `.year-hint` and `.category-total` are qualified. */
.modal-body .scale-note {
  margin-top: 10px; padding: 10px 14px; border-radius: 8px;
  border: 1.5px solid #a3e4bc; background: #eafaf1; color: #1a6b3a;
  font-size: 12px; font-weight: 700; line-height: 1.5;
}
.modal-body .scale-note.warn { border-color: #e67e22; background: #fef5ea; color: #7a4f10; }

/* ── DAYS OFF & PLANNED DROPS (WO-2.3) ── */
/* The contents of #daysOffModal and #daysOffConfirmModal. Almost everything in that panel is a
   component this sheet already owns — `.pill`, `.class-input`, `.term-date`, `.toggle-btn`,
   `.roster-list`, `.class-action-btn`, `.modal-section-label` — which is the point: a fifth dialog
   that invented a fifth grammar would be a fifth thing to keep in step. What is written out here is
   the four things that have no counterpart.

   The kind row is `.pill`s in a `data-pill-group`-shaped row rather than a <select>, for the reason
   src/classes.js gives about term presets: a <select> on an iPad is a spinning wheel over the whole
   screen, and this is a two-way choice with a consequence the teacher can see below it. */
.dayoff-kinds { display: flex; flex-wrap: wrap; gap: 6px; }
/* The form wraps rather than squeezes — a title, two date fields and a button do not fit one line
   at 390px, and the alternative is a date input the width of its own picker glyph. */
.dayoff-form { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.dayoff-form .class-input { flex: 1 1 180px; }
.dayoff-dates { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.dayoff-classes { display: flex; flex-wrap: wrap; gap: 6px; }
/* The kind, said again on the row, so the list reads in the same words the registry's column head
   does. Two washes for two kinds: the neutral one for a whole-school closure, and the interactive
   indigo for a planned drop, which is the kind that names classes and is therefore the kind a
   teacher scans this list looking for. */
.dayoff-kind {
  flex: 0 0 auto; padding: 3px 9px; border-radius: 20px;
  border: 1.5px solid #d0d8e4; background: #f5f7fa; color: #6b7a8d;
  font-size: 10px; font-weight: 700; letter-spacing: 0.4px; white-space: nowrap;
}
.dayoff-kind.drop { border-color: #b0bcf0; background: #eef2ff; color: #5b6fcc; }
/* What SURVIVES the exception being added — `.class-delete-facts` shape in the positive wash. The
   two dialogs are the same gesture from opposite ends: that one counts what a tap destroys, this
   one counts what it cannot. A red panel under a sentence promising the marks are safe would be
   the app contradicting itself in the one place a teacher is deciding whether to trust it. */
.dayoff-keeps {
  display: flex; flex-direction: column; gap: 4px;
  margin-top: 4px; padding: 10px 12px; border-radius: 8px;
  border: 1.5px solid #a3e4bc; background: #eafaf1;
}
.dayoff-keeps-line { font-size: 12px; line-height: 1.5; color: #27ae60; }

/* Both of these are <p> inside `.modal-body`, whose own rule is more specific than a bare class —
   so they are qualified rather than left to lose the cascade, exactly as `.year-hint` and
   `.year-error` are above. */
.modal-body .class-hint { font-size: 11px; color: #8a9bb0; margin-top: 10px; line-height: 1.5; }
.modal-body .class-error {
  margin-top: 10px; padding: 8px 11px; border-radius: 8px;
  background: #fdeaea; color: #c0392b; font-size: 12px; line-height: 1.5;
}

/* ── ROSTER & CONTACTS ── */
/* The contents of #rosterModal, #rosterPasteModal, #studentModal, #studentDeleteModal and
   #teacherModal.

   `.roster-row` is the class row's grammar with a student in it — avatar, name, quiet note,
   actions pushed right, and the whole thing wrapping rather than squeezing, for the reason
   `.class-row` gives above. It is written out rather than sharing that class because the two hold
   different things and the day one of them changes is the day sharing cost more than it saved:
   the same call the install banner and the backup nag make. */
.roster-list { display: flex; flex-direction: column; gap: 4px; }
.roster-row {
  display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
  padding: 8px 10px; border-radius: 8px;
  border: 1.5px solid #eef0f4; background: #f8f9fb;
}
.roster-row-name { flex: 1 1 140px; min-width: 0; font-size: 13px; font-weight: 600; color: #1a1a2e; }
/* "also in Period 5" — the one fact about a roster row that cannot be seen from anywhere else. */
.roster-row-note { font-size: 11px; font-weight: 700; color: #8a9bb0; }
.roster-row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-left: auto; }
.roster-empty { font-size: 13px; color: #a0aab8; padding: 4px 2px 6px; line-height: 1.5; }
.modal-body .roster-count { font-size: 11px; font-weight: 700; color: #8a9bb0; margin-bottom: 6px; }
.roster-form { display: flex; align-items: center; gap: 8px; }
.roster-input {
  flex: 1 1 auto; min-width: 0;
  border: 1.5px solid #e0e4ea; border-radius: 8px; padding: 8px 11px;
  font-size: 13px; color: #1a1a2e; background: #fff;
}
.roster-input::placeholder { color: #a0aab8; }
.roster-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
/* Both are <p> inside `.modal-body`, whose own rule is more specific than a bare class — so they
   are qualified rather than left to lose the cascade, exactly as `.class-hint` is above. */
.modal-body .roster-hint { font-size: 11px; color: #8a9bb0; margin-top: 10px; line-height: 1.5; }
.modal-body .roster-error {
  margin-top: 10px; padding: 8px 11px; border-radius: 8px;
  background: #fdeaea; color: #c0392b; font-size: 12px; line-height: 1.5;
}

/* An on/off button with `aria-pressed` moving with the class — used for a guardian's "contact
   first", for which classes a student is in, and for the copy-me toggle. Not `.pill`, which is
   single-select inside a `data-pill-group` (shell.js) and would fight these; and not a checkbox,
   which is 16px of target that no padding makes bigger. Active takes the interactive indigo wash
   rather than a solid fill, because solid is reserved for a primary action (style guide §1). */
.toggle-btn {
  padding: 5px 12px; border-radius: 20px; font-size: 11px; font-weight: 700;
  border: 1.5px solid #e0e4ea; background: #fff; color: #6b7a8d;
  cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.toggle-btn:hover { border-color: #5b6fcc; color: #5b6fcc; }
.toggle-btn.active { background: #eef2ff; border-color: #b0bcf0; color: #5b6fcc; }

/* The paste box and its preview. The textarea is a real one rather than a growing div: a paste of
   thirty names has to be scrollable, selectable and undoable, and every one of those comes free. */
.paste-box {
  display: block; width: 100%; margin-top: 10px;
  border: 1.5px solid #e0e4ea; border-radius: 8px; padding: 10px 11px;
  font-family: inherit; font-size: 13px; line-height: 1.5; color: #1a1a2e; background: #fff;
  resize: vertical;
}
.paste-box::placeholder { color: #a0aab8; }
/* Capped and scrolled: twenty-five rows at 44px each is taller than an iPad in landscape, and a
   preview you have to scroll the whole dialog to reach is one nobody reads to the end of. */
.paste-list {
  display: flex; flex-direction: column; gap: 4px;
  max-height: 320px; overflow-y: auto; -webkit-overflow-scrolling: touch;
}
.paste-row {
  display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
  padding: 6px 8px; border-radius: 8px;
  border: 1.5px solid #eef0f4; background: #f8f9fb;
}
/* Amber for a row Planbook guessed at or already has — the same caution wash as the two strips at
   the top of the page, so "look at this one" reads the same everywhere. */
.paste-row.warn { background: #fff8e6; border-color: #f0dfa8; }
.paste-fields { display: flex; flex: 1 1 220px; min-width: 0; gap: 6px; }
.paste-field { display: flex; flex-direction: column; gap: 2px; flex: 1 1 0; min-width: 0; }
.paste-input {
  width: 100%; min-width: 0;
  border: 1.5px solid #e0e4ea; border-radius: 6px; padding: 5px 8px;
  font-size: 13px; color: #1a1a2e; background: #fff;
}
/* The note wraps onto its own line under the fields rather than squeezing them into two words. */
.paste-row-note { flex-basis: 100%; font-size: 11px; line-height: 1.45; color: #8a6d1a; }
.modal-body .paste-count { font-size: 12px; font-weight: 700; color: #6b7a8d; margin: 10px 0 6px; }

/* The student editor. Two columns of labelled fields that become one below ~420px, which is what
   `flex: 1 1 180px` buys — a first-name field two words wide is worse than a stacked form. */
.student-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.student-field { display: flex; flex-direction: column; gap: 3px; flex: 1 1 180px; min-width: 0; }
.student-label {
  font-size: 10px; font-weight: 700; color: #8a9bb0;
  letter-spacing: 0.8px; text-transform: uppercase;
}
.student-input {
  width: 100%; min-width: 0;
  border: 1.5px solid #e0e4ea; border-radius: 6px; padding: 7px 9px;
  font-family: inherit; font-size: 13px; color: #1a1a2e; background: #fff;
}
.student-input::placeholder { color: #a0aab8; }
.student-notes {
  display: block; width: 100%;
  border: 1.5px solid #e0e4ea; border-radius: 8px; padding: 8px 10px;
  font-family: inherit; font-size: 13px; line-height: 1.5; color: #1a1a2e; background: #fff;
  resize: vertical;
}
.student-classes { display: flex; flex-wrap: wrap; gap: 6px; }
.guardian-list { display: flex; flex-direction: column; gap: 8px; }
.guardian-card {
  padding: 10px; border-radius: 8px;
  border: 1.5px solid #eef0f4; background: #f8f9fb;
}
.guardian-head { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.guardian-label { flex: 1 1 120px; min-width: 0; font-size: 12px; font-weight: 700; color: #1a1a2e; }
/* The support indicator on a roster row: one dot, one size, one colour, for every student who has
   anything on file at all.

   NOTHING ABOUT IT VARIES, and that is the rule rather than a simplification. This list gets
   projected onto a classroom wall; an amber dot for a 504 beside an indigo one for an IEP is a
   legible key that discloses thirty students' plan status in one glance. So no variant class
   exists to be added later — see src/roster.js's decision 5 and docs/data-model.md § Accommodations.

   Quiet on purpose: the hairline border and the secondary-text glyph of a `.roster-row-note`, not
   an accent. It brightens to the interactive indigo on hover like every other control here, which
   is what says it can be tapped. */
.support-dot {
  flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; padding: 0;
  border: 1.5px solid #d0d8e4; border-radius: 50%;
  background: #fff; color: #8a9bb0;
  font-family: inherit; font-size: 15px; line-height: 1;
  cursor: pointer; transition: all 0.15s;
}
.support-dot:hover { border-color: #5b6fcc; color: #5b6fcc; background: #eef2ff; }

/* The support panel inside the student editor. The subdued card from the style guide (§1) rather
   than a caution or a danger wash: amber means "act on this" everywhere else in this app and red
   means "this destroys something", and neither is what a 504 plan is. What this box says is "these
   are not the same kind of field as the ones above", which is the work order's "clearly separated
   from ordinary fields". */
.supports-panel {
  margin-top: 4px; padding: 12px; border-radius: 10px;
  border: 1.5px solid #e0e4f0; background: #f8f9fc;
}
/* Qualified for the reason `.roster-hint` is: it is a <p> inside `.modal-body`, whose own rule is
   more specific than a bare class. */
.modal-body .supports-hint { font-size: 12px; color: #6b7a8d; line-height: 1.5; }
.accommodation-list { display: flex; flex-direction: column; gap: 8px; }
.accommodation-card {
  padding: 10px; border-radius: 8px;
  border: 1.5px solid #e0e4f0; background: #fff;
}
.accommodation-head { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.accommodation-label { flex: 1 1 120px; min-width: 0; font-size: 12px; font-weight: 700; color: #1a1a2e; }
/* The kind picker and the review date. Both are shaped like `.student-input` and are separate
   classes rather than qualified variants of it, because a <select> and an <input type=date> are
   two controls a browser draws its own way and the touch pass has to name each of them. */
.student-select {
  width: 100%; min-width: 0;
  border: 1.5px solid #e0e4ea; border-radius: 6px; padding: 7px 9px;
  font-family: inherit; font-size: 13px; color: #1a1a2e; background: #fff;
}
.student-date {
  width: 100%; min-width: 0;
  border: 1.5px solid #e0e4ea; border-radius: 6px; padding: 6px 9px;
  font-family: inherit; font-size: 13px; color: #1a1a2e; background: #fff;
}

/* What deleting a student destroys, in the danger wash — the same grammar as the class delete
   confirm, because it is the same kind of sentence and has to read the same way. */
.student-delete-facts {
  display: flex; flex-direction: column; gap: 4px;
  margin-top: 4px; padding: 10px 12px; border-radius: 8px;
  border: 1.5px solid #f0b0b0; background: #fdeaea;
}
.student-delete-line { font-size: 12px; line-height: 1.5; color: #c0392b; }

/* ── BACKUP & RESTORE ── */
/* The contents of #backupModal and #restoreConfirmModal. Two things here are not decoration:
   the notice block, which is where the app says out loud that the file contains accommodation
   and medical data, and the two-column compare in the confirm, which is what turns "restore"
   from a gesture into a decision. */
.backup-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* The sentence under "Back up all N years" (WO-1.11). Quiet grey like `.backup-last` rather than
   the amber `.backup-other-years` gets: it explains what a control does, it is not asking for
   anything. Tighter to the buttons than `.backup-last` is, because it belongs to them. */
.modal-body .backup-all-note { font-size: 11px; color: #8a9bb0; line-height: 1.5; margin-top: 6px; }
.modal-body .backup-last { font-size: 11px; color: #8a9bb0; margin-top: 8px; }
/* Louder than .backup-last and quieter than .backup-notice: it names something not done yet,
   which is a nudge rather than the standing warning about what the file holds. Same amber the
   nag and the install banner use, so "act on this" reads the same everywhere. */
.modal-body .backup-other-years {
  font-size: 12px; line-height: 1.5; color: #8a6d1a; margin-top: 8px;
  padding: 8px 10px; border-radius: 8px; background: #fff8e6; border: 1px solid #f0dfa8;
}
/* Caution wash, not danger: the file is not a problem, it is a thing to handle carefully. Same
   amber as the two strips at the top of the page, so "pay attention to this" reads the same
   everywhere in the app. */
.backup-notice {
  margin-top: 12px; padding: 10px 12px; border-radius: 8px;
  background: #fff8e6; border: 1.5px solid #f0dfa8;
}
.modal-body .backup-notice p { color: #8a6d1a; font-size: 12px; line-height: 1.55; }
.modal-body .backup-notice p + p { margin-top: 6px; }
/* The drop target. Dashed, because a dashed rectangle is the one shape every teacher already
   reads as "put a file here", and it stays visible rather than appearing on dragover — a target
   that only exists once you are already dragging is a target you never knew to aim for. */
.backup-drop {
  margin-top: 10px; padding: 14px; border-radius: 10px;
  border: 2px dashed #e0e4ea; background: #f8f9fb; transition: all 0.15s;
}
.backup-drop.active { border-color: #5b6fcc; background: #eef2ff; }
.modal-body .backup-drop-hint { font-size: 12px; color: #8a9bb0; margin-bottom: 8px; }
.backup-file { display: block; width: 100%; font-size: 12px; color: #6b7a8d; }
/* The file input's own button, styled as an outline button so it belongs to the same family as
   everything else on this panel. Native, so tapping it opens the iPad's Files sheet. */
.backup-file::file-selector-button {
  border: 1.5px solid #e0e4ea; border-radius: 6px; background: #fff; color: #6b7a8d;
  padding: 4px 12px; margin-right: 10px;
  font-family: inherit; font-size: 11px; font-weight: 700; cursor: pointer; transition: all 0.15s;
}
.backup-file::file-selector-button:hover { border-color: #5b6fcc; color: #5b6fcc; }
/* One status line, two tones — a refusal and a confirmation land in the same place, so the eye
   only has one place to go back to. */
.modal-body .backup-status {
  margin-top: 12px; padding: 8px 11px; border-radius: 8px; font-size: 12px; line-height: 1.5;
}
.modal-body .backup-status.error { background: #fdeaea; color: #c0392b; }
.modal-body .backup-status.ok { background: #eafaf1; color: #27ae60; }

/* The before/after in the restore confirm. `flex: 1 1 200px` rather than two fixed columns:
   below about 440px the two cards stack instead of squeezing the counts onto three lines each,
   and the incoming one is second in the markup so stacking puts it underneath, in reading
   order, where "replaced by" belongs. */
.restore-compare { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
.restore-side {
  flex: 1 1 200px; min-width: 0;
  padding: 10px 12px; border-radius: 8px;
  border: 1.5px solid #e0e4ea; background: #f8f9fb;
}
.restore-side-label {
  font-size: 10px; font-weight: 700; color: #8a9bb0;
  letter-spacing: 0.8px; text-transform: uppercase; margin-bottom: 6px;
}
.restore-side-year { font-size: 15px; font-weight: 700; color: #1a1a2e; }
.restore-side-line { font-size: 12px; color: #6b7a8d; margin-top: 3px; }
.restore-side-note { font-size: 11px; color: #a0aab8; margin-top: 3px; }
.restore-side-empty { font-size: 12px; color: #a0aab8; margin-top: 4px; line-height: 1.5; }
.modal-body .restore-note {
  margin-top: 12px; padding: 8px 11px; border-radius: 8px;
  background: #eef2ff; color: #5b6fcc; font-size: 12px; line-height: 1.5;
}

/* ── RESPONSIVE ── */
/* Declaration order is from design/style-guide.md §6 and is not alphabetical or
   arbitrary: coarse pointer first, then 1024, then 640. */

/* The touch pass. EVERY interactive control appears here with a 44px minimum, added in
   the same pass that adds the control — there is no later touch-up pass. The bare
   `button` rule is a floor for anything that slips through, not a substitute for
   listing the control by class. */
@media (pointer: coarse) {
  button { min-height: 44px; }
  .header-top { min-height: 60px; }
  .header-bottom { min-height: 52px; }
  .hdr-icon-btn { width: 44px; height: 44px; border-radius: 10px; }
  .hdr-icon-btn svg { width: 18px; height: 18px; }
  /* Presentation mode's button takes its 44x44 from `.hdr-icon-btn` above, which it also wears.
     It is named here anyway, with the radius the coarse pass gives every icon button, so that the
     control appears in this block by name — the rule this stylesheet's own header states is that
     every interactive control does, and a control that is only covered by another class's rule is
     a control the next reader has to go and prove is covered. */
  .hdr-mode-btn { width: 44px; height: 44px; border-radius: 10px; }
  .presentation-strip { font-size: 13.5px; }
  .presentation-strip-btn { padding: 8px 16px; font-size: 13px; min-height: 44px; }
  .hdr-empty { font-size: 13px; }
  /* `min-width` alongside `min-height` on these three, added at WO-1.6 when they stopped being
     shelf specimens with two-word labels: a class called "A", a term called "1", and a reorder
     arrow are all one glyph wide, and 44px of height around a 30px-wide target is half a touch
     target. The guideline is 44 in both directions. */
  .cls-tab { padding: 10px 16px; font-size: 14px; min-height: 44px; min-width: 44px; }
  .cls-tab-add { padding: 10px 16px; font-size: 14px; min-height: 44px; min-width: 44px; }
  /* The way back takes its 44px from `.cls-tab` above, which it also wears. Named here anyway, for
     the reason `.hdr-mode-btn` is: every control appears in this block by its own name, and one
     covered only by another class's rule is one the next reader has to go and prove is covered. */
  .cls-tab-home { padding: 10px 16px; font-size: 14px; min-height: 44px; min-width: 44px; }
  .q-btn { padding: 10px 16px; font-size: 14px; min-height: 44px; min-width: 44px; }
  .pill { padding: 10px 16px; font-size: 12px; min-height: 44px; }
  .batch-btn { padding: 8px 14px; font-size: 12px; min-height: 44px; }
  .class-action-btn { padding: 8px 14px; font-size: 12px; min-height: 44px; min-width: 44px; }
  .class-action-btn.move { padding: 8px 10px; font-size: 15px; }
  /* The home screen's header row, which is not a touch target itself — it is the flex row that
     holds the title and the Days off button. Named here for two reasons the sweep is right to ask
     about. The gap goes up for the reason every other row's does: the button beside it is 44px on
     this pointer and a paragraph running into it is a mis-tap. And the button is pinned again, in
     this block as well as in the base rule, because THIS is the block that hands it the
     `min-width: 44px` which took its `min-width: auto` away — the two belong within sight of each
     other or the next person to add a control here re-opens the 2026-08-08 defect. */
  .panel-title-row { gap: 14px; }
  .panel-title-row > .class-action-btn { flex: 0 0 auto; }
  .hdr-term-nav { gap: 6px; }
  .install-banner { font-size: 13.5px; }
  .install-banner-dismiss { padding: 8px 16px; font-size: 13px; min-height: 44px; }
  .hdr-year-btn { padding: 10px 14px; font-size: 14px; min-height: 44px; }
  .year-row { padding: 12px 14px; font-size: 14px; min-height: 44px; }
  .year-row-note { font-size: 12px; }
  /* The height belongs to the <input>, not to a wrapper around it — the WO-1.2 `.search-box`
     defect. Vertical padding goes to zero so the 44px is all caret. */
  .year-input { min-height: 44px; font-size: 14px; padding: 0 12px; }
  .modal-body .year-hint { font-size: 12px; }
  .modal-body .year-error { font-size: 13px; }
  /* Classes & terms. Every input here carries the 44px itself with its vertical padding zeroed,
     which is the `.search-box` defect's lesson: a 44px wrapper around a 19px field looks tappable
     above and below the text, does nothing there, and the teacher taps twice. */
  .class-row-name { font-size: 14px; }
  .class-row-note { font-size: 12px; }
  .class-empty { font-size: 14px; }
  .class-input { min-height: 44px; font-size: 14px; padding: 0 12px; }
  .rename-input { min-height: 44px; font-size: 14px; padding: 0 10px; }
  .class-preset-label { font-size: 11px; }
  .term-label-input { min-height: 44px; font-size: 14px; padding: 0 10px; }
  /* The date fields on both screens this class serves — the term editor's Starts and Ends, and the
     days-off form's From and To. The `min-height` was here from WO-1.6 and never reached the glass:
     a natively drawn WebKit date widget paints at its own height and walks straight past it, which
     is what the owner photographed on 2026-08-10. What makes this line true on the device is the
     `input[type="date"]` reset in the BASE section at the top of this file (WO-2.23), and the
     reasoning for putting it there rather than here is written at it.

     THE `min-width` IS PART OF WHAT THAT RESET COSTS AND NOT A RESTYLE OF THE FIELD. This class
     sets no width anywhere, in either screen, and has always been laid out at whatever the NATIVE
     widget drew itself — content-sized items in `flex-wrap: wrap` rows, so the widget's intrinsic
     width WAS its layout width. Take the native painting away and that intrinsic width goes with
     it, and an empty date field on iOS draws no placeholder text to hold the box open. The 44px
     this line used to carry is a tap-target floor, never a legible-date width, so the field could
     have come out of this fix a 44px square showing a sliver of a date — full height and unusable,
     which is the fix winning the pixels and losing the point.
     160px is copied rather than re-derived per CLAUDE.md — but be exact about what it was copied
     FROM, because the first draft of this comment was not and a reader acting on it would have been
     misled. Roll Call!'s `#dateJumpInput` (its src/dashboard.html:437-440) carries the same two
     reset declarations at the same `min-width: 160px`, and it is a date-PICKING `<select>`, its
     options day labels — NOT an `<input type="date">`. Roll Call! has no reset date input anywhere:
     its own date fields (`.config-date` at :696, `.wiz-term-row input[type="date"]` at :103) carry
     neither an `appearance` reset nor any `min-width`. So there is no sibling precedent for the
     control this rule governs — only for a width that has held a date open under these same two
     reset declarations, on one real control, for a year of classroom use. That is the whole of the
     borrowing.
     AND NOTHING MORE IS BEING CLAIMED FOR IT — no headroom, no comparison of the two controls, no
     measurement. Three drafts of this comment reached for one and each was checkably false; the
     third asserted a disclosure arrow on a `<select>` whose arrow `appearance: none` is exactly
     what removes, two sentences after correctly saying the reset ends the browser's own drawing.
     The controls do not render alike — 12px there against 14px here, a select's label against a
     rendered date — and a desktop browser's number for either says nothing about what iOS draws,
     which is the entire reason this defect reached the glass unseen. So: do not re-derive this
     floor from a laptop measurement, and do not narrow it because it looks generous in Chrome. It
     is loose on purpose. A width tuned until it looks right at a desk is how this comes back on the
     next device. Both rows this class sits in wrap (`.term-dates`, `.dayoff-dates`), so a wide
     floor costs a wrapped line and never an overflow. (Roll Call! narrows that select to 130px under `max-width: 640px` at :990. Not copied
     — and not for the reason an earlier draft of this comment gave, which was checkable and wrong.
     `#dateJumpInput` sits in `.date-jump-ctrl` inside `.search-row`, the roster panel's search row
     (its :1273-1291) — a panel header, not the app's header bar (`.header-top` at :149 and
     `.header-bottom` at :153), and it does NOT hold a single line: :977 gives
     it `flex-wrap: wrap` from ≤1024px down, so by the time the 130px applies the row has been
     wrapping for 384px. The drop is a phone-width trim — it lets the `Jump to` label, the select and
     the Today button share one narrow line once :989 stops pushing that cluster right. There is no
     equivalent step to hang it on here: this rule lives in `@media (pointer: coarse)`, a pointer
     query and not a width one, so a single floor serves the iPad and a phone alike. Given one
     number for both, it is the wide end that is safe to pick, for the reason set out above.) */
  .term-date { min-height: 44px; min-width: 160px; font-size: 14px; padding: 0 8px; }
  .term-date-label { font-size: 11px; }
  .class-delete-line { font-size: 13px; }
  /* Categories & weights (WO-3.1), in the same pass that adds them — there is no later one. The
     buttons in that panel are all `.class-action-btn`, whose coarse floor is above; what is named
     here is the two fields, the two containers and the three pieces of text.
     Both fields carry the 44px themselves with their vertical padding zeroed, which is the WO-1.2
     `.search-box` lesson: a 44px row around a 19px input looks tappable above and below the text,
     does nothing there, and the teacher taps twice. The weight field keeps its 58px width — wider
     than the 44px floor, because three digits and a spinner have to fit — and is named with a
     `min-width` anyway so the next reader does not have to go and prove it is covered.
     `.category-row-actions` gets 8px for the reason `.roster-row-actions` does: Remove sits
     shoulder to shoulder with an arrow, and 6px between two 44px targets is a mis-tap that costs
     a category's worth of assignments. */
  .category-list { gap: 8px; }
  .category-row { gap: 10px; }
  .category-weight-field { gap: 6px; }
  .category-name-input { min-height: 44px; font-size: 14px; padding: 0 10px; }
  .category-weight { min-height: 44px; min-width: 58px; font-size: 14px; padding: 0 6px; }
  .category-weight-unit { font-size: 14px; }
  .category-row-actions { gap: 8px; }
  .category-actions { gap: 10px; }
  /* Text, not targets — a badge and a standing line. Named here for the size bump only, exactly as
     `.class-row-note` and `.dayoff-kind` are: 10-12px is unreadable at arm's length on a tablet,
     and a 44px floor on a label is 44px of a dialog spent on something nobody taps. */
  .class-row-warn { font-size: 11px; }
  .modal-body .category-total { font-size: 13px; padding: 12px 14px; }
  /* Letter grades (WO-3.2), in the same pass that adds them — there is no later one. The buttons in
     that panel are `.class-action-btn` and the subject row is `.pill`, both of which have their own
     floor above; what is named here is the two fields, the five containers and the three pieces of
     text.
     Both fields carry the 44px themselves with their vertical padding zeroed, which is the WO-1.2
     `.search-box` lesson: a 44px row around a 19px input looks tappable above and below the text,
     does nothing there, and the teacher taps twice. Each keeps its own width — wider than the 44px
     floor, because "A−" wants a caret beside it and a boundary can be 89.5 with a spinner after
     it — and both are named with a `min-width` anyway so the next reader does not have to go and
     prove they are covered.
     `.pill` carries a height and no width of its own, which is right for a chip with a class name in
     it and wrong for the one that might read "A": the subject row pins it, in this block, because
     this is the block that makes every other target in the panel 44px wide.
     `.band-row-actions` gets 8px for the reason `.category-row-actions` does: Remove sits shoulder
     to shoulder with an arrow, and 6px between two 44px targets is a mis-tap. */
  .scale-actions { gap: 10px; }
  .scale-subjects { gap: 8px; }
  .scale-subjects .pill { min-width: 44px; }
  .scale-override { gap: 10px; }
  .band-list { gap: 8px; }
  .band-row { gap: 10px; }
  .band-min-field { gap: 6px; }
  .band-letter-input { min-height: 44px; min-width: 64px; font-size: 15px; padding: 0 8px; }
  .band-min { min-height: 44px; min-width: 66px; font-size: 14px; padding: 0 6px; }
  .band-min-unit { font-size: 14px; }
  .band-row-actions { gap: 8px; }
  .band-actions { gap: 10px; }
  /* Text, not targets — a letter on a read-only row, a derived range, a sentence and a standing
     line. Named here for the size bump only, exactly as `.class-row-note` and `.dayoff-kind` are:
     10-12px is unreadable at arm's length on a tablet, and a 44px floor on a label is 44px of a
     dialog spent on something nobody taps. */
  .band-letter { font-size: 15px; }
  .band-range { font-size: 12px; }
  .band-range.warn { font-size: 11px; }
  .scale-override-note { font-size: 12px; }
  .modal-body .scale-note { font-size: 13px; padding: 12px 14px; }
  /* Days off & planned drops (WO-2.3), in the same pass that adds them — there is no later one.
     The four controls in that panel that are not already covered by a rule in this block: the two
     kind pills wear `.pill`'s own coarse floor, the title field wears `.class-input`'s, the two
     dates wear `.term-date`'s and the class buttons wear `.toggle-btn`'s, so what is named here is
     the containers' spacing and the two text sizes. The gaps go to 8px for the reason the roster's
     do: two 44px targets 6px apart is a mis-tap, and every one of these rows puts them shoulder to
     shoulder. */
  .dayoff-kinds { gap: 8px; }
  .dayoff-form { gap: 10px; }
  .dayoff-dates { gap: 10px; }
  .dayoff-classes { gap: 8px; }
  /* These three are text rather than targets — a badge, a panel and a line in it — and they are
     named here for the size bump only, exactly as `.class-row-note` and `.class-delete-line` above
     them are. A 44px floor on a label is 44px of a dialog spent on something nobody taps. */
  .dayoff-kind { font-size: 11px; }
  .dayoff-keeps { padding: 12px; }
  .dayoff-keeps-line { font-size: 13px; }
  .modal-body .class-hint { font-size: 12px; }
  .modal-body .class-error { font-size: 13px; }
  /* Roster & contacts. Every field here carries the 44px itself with its vertical padding zeroed,
     which is the `.search-box` defect's lesson again — and there are more fields in this feature
     than in every other one put together, which is exactly how one of them gets missed. The two
     multi-line boxes get more than 44: a paste of a class list and a note about a student are both
     things a teacher has to be able to see while she types them. */
  .roster-row-name { font-size: 14px; }
  .roster-row-note { font-size: 12px; }
  .roster-empty { font-size: 14px; }
  /* The two rows in this feature that put a destructive control shoulder to shoulder with a
     harmless one — Edit beside Remove, and "Contact first" beside the button that deletes a
     guardian. 6px between two 44px targets is a mis-tap that costs a guardian's phone number, so
     they get the 8px `.paste-fields` and `.student-classes` already take below. Every other
     container in this block is left alone: a wrapper is not a touch target, and putting the
     height on one instead of on the control inside it is the WO-1.2 `.search-box` defect. */
  .roster-row-actions { gap: 8px; }
  .guardian-head { gap: 8px; }
  .roster-input { min-height: 44px; font-size: 14px; padding: 0 12px; }
  .modal-body .roster-count { font-size: 12px; }
  .modal-body .roster-hint { font-size: 12px; }
  .modal-body .roster-error { font-size: 13px; }
  .toggle-btn { padding: 8px 16px; font-size: 12px; min-height: 44px; min-width: 44px; }
  .paste-box { min-height: 176px; font-size: 14px; }
  .paste-list { max-height: 360px; }
  .paste-fields { gap: 8px; }
  .paste-input { min-height: 44px; font-size: 14px; padding: 0 10px; }
  .paste-row-note { font-size: 12px; }
  .modal-body .paste-count { font-size: 13px; }
  .student-label { font-size: 11px; }
  .student-input { min-height: 44px; font-size: 14px; padding: 0 10px; }
  .student-notes { min-height: 88px; font-size: 14px; }
  .student-classes { gap: 8px; }
  .guardian-label { font-size: 13px; }
  /* Support details. The dot is an icon button, so it takes 44 in both directions rather than only
     in height — a 22px circle inside a 44px-tall row is half a touch target, which is the same
     lesson `.cls-tab` learned at WO-1.6. The picker and the date field carry their own 44 with the
     vertical padding zeroed, the `.search-box` defect's lesson again: a <select> and an
     <input type=date> are drawn by the browser, and neither inherits a height from the box it sits
     in. That sentence was written here before anything acted on it, and the date half of it was
     false on the device until WO-2.23 put the `input[type="date"]` reset in the BASE section — the
     review date was the seventh field short of its own 44px, on a screen the 👤 list of that work
     order never named. Height and appearance are the whole of what changed on this panel: the
     reset touches no plan data, no export and no merge field, and nothing here may.
     `.accommodation-head` gets the 8px `.guardian-head` gets, and for the same reason — it is
     the other place in this feature where a Remove sits shoulder to shoulder with something
     harmless. */
  .support-dot { width: 44px; height: 44px; font-size: 18px; }
  .modal-body .supports-hint { font-size: 13px; }
  .accommodation-head { gap: 8px; }
  .accommodation-label { font-size: 13px; }
  .student-select { min-height: 44px; font-size: 14px; padding: 0 10px; }
  .student-date { min-height: 44px; min-width: 44px; font-size: 14px; padding: 0 8px; }
  .student-delete-line { font-size: 13px; }
  .modal-close { width: 44px; height: 44px; font-size: 15px; }
  .loading-error-btn { min-height: 44px; padding: 12px 20px; font-size: 14px; }
  .backup-nag { font-size: 13.5px; }
  .backup-nag-btn { padding: 8px 16px; font-size: 13px; min-height: 44px; }
  .modal-body .backup-notice p { font-size: 13px; }
  .modal-body .backup-drop-hint { font-size: 13px; }
  /* Both halves: the input carries the height so the whole row is a target, and the native
     button inside it carries its own — a 44px input around a 20px button is the WO-1.2
     `.search-box` defect wearing a different control. */
  .backup-file { min-height: 44px; font-size: 13px; }
  .backup-file::file-selector-button { min-height: 44px; padding: 8px 14px; font-size: 12px; }
  .modal-body .backup-last { font-size: 12px; }
  /* Prose, not a control — the 44px it sits next to belongs to `.class-action-btn` above, which
     both backup buttons are. This is here for the same reason every other line in this block is:
     11px is unreadable at arm's length on a tablet. */
  .modal-body .backup-all-note { font-size: 12px; }
  .modal-body .backup-other-years { font-size: 13px; }
  .modal-body .backup-status { font-size: 13px; }
  .modal-body .restore-note { font-size: 13px; }
  .restore-side-line { font-size: 13px; }
  .restore-side-note, .restore-side-empty { font-size: 12px; }
  /* The 44px belongs to the <input>, not to the box around it. Giving `.search-box` the
     min-height and leaving its 7px vertical padding in place makes a 44px box holding a
     19px input: the strip above and below the text looks tappable, does nothing, and the
     teacher taps twice. So the padding goes horizontal-only and the input carries the
     height, which is also what makes the caret land on the first tap. */
  .search-box { min-height: 44px; padding: 0 11px; }
  .search-box input { min-height: 44px; font-size: 14px; }
  .date-batch-bar { font-size: 13px; }
  .modal-body p { font-size: 14px; }
  /* `.panel-title p` rather than the `.shelf-note` that used to be here: the shelf's notes went
     with it at WO-1.10, and the panel subtitle is the 11px line that a screen now carries in the
     same place — the home screen's is a sentence rather than a caption. */
  .panel-title p { font-size: 13px; }
}
@media (max-width: 1024px) {
  .search-box { flex: 1 1 auto; max-width: none; }
  .pills { position: static; transform: none; flex-wrap: wrap; }
  .search-row { flex-wrap: wrap; gap: 6px; }
}
@media (max-width: 640px) {
  .main {
    padding: 10px;
    padding-left: max(10px, env(safe-area-inset-left, 0px));
    padding-right: max(10px, env(safe-area-inset-right, 0px));
  }
  .modal-panel { width: 95vw; }
  .header-top {
    padding-left: max(12px, env(safe-area-inset-left, 0px));
    padding-right: max(12px, env(safe-area-inset-right, 0px));
  }
  .header-bottom {
    padding-left: max(8px, env(safe-area-inset-left, 0px));
    padding-right: max(8px, env(safe-area-inset-right, 0px));
  }
  .install-banner {
    padding-left: max(12px, env(safe-area-inset-left, 0px));
    padding-right: max(12px, env(safe-area-inset-right, 0px));
  }
  .presentation-strip {
    padding-left: max(12px, env(safe-area-inset-left, 0px));
    padding-right: max(12px, env(safe-area-inset-right, 0px));
  }
  /*
    The whole title block steps aside at phone width, not just the strapline it used to be.

    That is a measurement taken at WO-1.9 rather than a taste: at 390px under a coarse pointer the
    top row measured 375px of content against 390px of viewport, so it had 15px of slack and the
    presentation-mode button needs 52 (44 plus the row's 8px gap). Something had to give, and the
    word "Planbook" on the one screen where the app is already the only thing on the display is the
    cheapest thing in that row — the 📓 beside it keeps the identity, and every other control there
    is a control. Anything narrower than 640px is a phone; the iPad this app is built for never
    reaches this rule.

    Taken out of the LAYOUT and not out of the document: these are `.sr-only`'s own declarations,
    written out again because there is no JavaScript involved in this and a class cannot be added
    by a media query. `display: none` would have been one line and would also have left the page
    with no <h1> at all on a phone, which is a screen reader arriving at an app that will not say
    what it is.
  */
  .header-title {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }
}
