/* ===========================================================================
   SchoolSaaS — design system
   ===========================================================================

   ONE stylesheet, served from our own origin. That is not laziness, it is the
   Content-Security-Policy: `style-src 'self' 'nonce-…'` means a stylesheet has
   to come from us, and `style-src` without 'unsafe-inline' means the browser
   SILENTLY DISCARDS every `style="…"` attribute. A layout built with inline
   styles renders wrong with nothing in any log — it cost us a whole afternoon
   once (see tools/gates.php gate 13), so every value a view needs is a class
   in here.

   Fonts are self-hosted for the same reason: `font-src 'self'` forbids Google
   Fonts, and a school on a slow line — or a self-hosted install behind a
   school firewall with no internet at all — must still get the right type.
   Both faces are variable and subset to latin: 54 KB for every weight.

   Structure:
     1. Fonts
     2. Tokens          light, dark, and the per-school accent
     3. Reset
     4. Layout          the shell: sidebar, topbar, page
     5. Typography
     6. Components      buttons, fields, cards, tables, tags, empty states…
     7. Utilities       a deliberately short list
     8. Print
   =========================================================================== */

/* ── 1. Fonts ───────────────────────────────────────────────────────────── */

@font-face {
  font-family: 'Jakarta';
  src: url('/fonts/jakarta-latin.woff2') format('woff2');
  font-weight: 200 800;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC,
                 U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Jakarta';
  src: url('/fonts/jakarta-latin-ext.woff2') format('woff2');
  font-weight: 200 800;
  font-display: swap;
  unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'PublicSans';
  src: url('/fonts/publicsans-latin.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC,
                 U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'PublicSans';
  src: url('/fonts/publicsans-latin-ext.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
  unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ── 2. Tokens ──────────────────────────────────────────────────────────── */
/*
   --accent is the ONE value a school controls. Every other colour is fixed, so
   a school can brand the product without being able to make it unreadable. It
   is written into the page by the layout as an inline <style nonce>, which the
   CSP does allow; see App\Libraries\Ui\Branding for why the value is validated
   rather than escaped.

   THIS COMMENT USED TO SAY "the accent never carries text on its own, only a
   white-on-accent surface whose contrast we control". THAT WAS NOT TRUE, and
   had not been since the reset block below was written: `a { color:
   var(--accent) }` makes the accent the colour of every link in the product.

   Which was survivable in light mode and wrong in dark, because the dark
   blocks below redefine every neutral and every semantic colour AND NOT THIS
   ONE. So a link in dark mode was a colour chosen to sit on white, printed on
   #0e1117. The console's fixed violet #6d28d9 came out at about 2.6:1 against
   its own card — visible if you knew it was there, and not otherwise. Srikanth
   found it on the Overview by eye, and it was every link on every page.

   --accent-ui IS THE ACCENT AS TEXT, and everything that sets `color:` takes
   it; backgrounds and fills keep --accent. It cannot simply be --accent
   redefined in the dark block, because the accent arrives as an INLINE style
   on :root and an inline style beats a stylesheet in both themes. Mixing
   toward white raises the luminance of whatever a school picked without
   touching its hue, so a school still gets its own colour and cannot make its
   own text unreadable.

   `contrast.mjs` measures it, on every page, in both themes. A rule that lives
   in a comment is a rule the next colour does not have.
*/
:root {
  color-scheme: light dark;

  --accent:        #0f766e;
  --accent-ink:    #ffffff;
  /* 88% of the school's colour, 12% black. The accent is legible as text on
     white and NOT on --accent-wash-2, which is what `.avatar` and the active
     tab paint on; a school picking a mid-blue came out at 3.3:1 on its own
     initials. The hue is untouched, so the school still gets its colour. */
  --accent-ui:     color-mix(in srgb, var(--accent) 88%, #000000);
  --accent-wash:   color-mix(in srgb, var(--accent) 8%,  transparent);
  --accent-wash-2: color-mix(in srgb, var(--accent) 14%, transparent);
  --accent-line:   color-mix(in srgb, var(--accent) 32%, transparent);

  /* Neutrals, biased very slightly cool so they sit with the accent rather
     than fighting it. A pure grey reads as unconsidered. */
  --bg:        #f5f6f8;
  --surface:   #ffffff;
  --surface-2: #f8f9fb;
  --sunken:    #f1f3f6;
  --line:      #e5e8ed;
  --line-2:    #d3d8e0;
  --ink:       #14181f;
  --ink-2:     #3d4551;
  --muted:     #5f6775;
  --faint:     #646b79;

  /* The sidebar is its own small dark world, in both themes. It separates
     navigation from work far more decisively than a white-on-white rail, and
     it makes the content area feel brighter. */
  --nav-bg:     #171b23;
  --nav-line:   #262c37;
  --nav-ink:    #b8c0cd;
  --nav-ink-hi: #ffffff;
  --nav-muted:  #99a2b1;
  --nav-group:  #8e98a8;
  --nav-hover:  #ffffff0f;

  /* Semantic. Separate from the accent on purpose: "good" must not change
     meaning because a school picked green as its brand colour. */
  --good:     #0f6b32;  --good-bg:  #0f6b3214;  --good-line: #0f6b3233;
  --warn:     #a16207;  --warn-bg:  #a1620714;  --warn-line: #a1620733;
  --bad:      #b42318;  --bad-bg:   #b4231812;  --bad-line:  #b4231833;
  --info:     #175cd3;  --info-bg:  #175cd312;  --info-line: #175cd333;

  --focus: #2563eb;

  --font:    'PublicSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --display: 'Jakarta', 'PublicSans', -apple-system, BlinkMacSystemFont, sans-serif;
  --mono:    ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;

  /* Type scale — 1.2 minor third, rounded to whole pixels at the small end
     where rounding error is visible. */
  --t-xs:  0.6875rem;   /* 11 — eyebrows, table headers */
  --t-sm:  0.8125rem;   /* 13 — secondary text, table body */
  --t-md:  0.9375rem;   /* 15 — body */
  --t-lg:  1.0625rem;   /* 17 — card titles */
  --t-xl:  1.375rem;    /* 22 — page title */
  --t-2xl: 1.75rem;     /* 28 — stat figures */
  --t-3xl: 2.25rem;     /* 36 — auth headline */

  /* Space — 4px base. */
  --s-1: .25rem; --s-2: .5rem;  --s-3: .75rem; --s-4: 1rem;
  --s-5: 1.25rem; --s-6: 1.5rem; --s-8: 2rem;  --s-10: 2.5rem; --s-12: 3rem;

  --r-sm: .3rem; --r: .5rem; --r-lg: .75rem; --r-xl: 1rem; --r-full: 999px;

  /* Shadow is spent to lift a thing off the page, never as decoration. */
  --shadow-sm: 0 1px 2px rgba(16, 24, 40, .05);
  --shadow:    0 1px 3px rgba(16, 24, 40, .07), 0 1px 2px rgba(16, 24, 40, .04);
  --shadow-lg: 0 12px 28px -10px rgba(16, 24, 40, .22), 0 2px 6px rgba(16, 24, 40, .05);

  --nav-w: 15rem;
  --ease: cubic-bezier(.32, .72, 0, 1);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    /* 42% of the school's colour, 58% white. Enough to clear 4.5:1 on both
       --bg and --surface for every accent the validator admits, including the
       console's violet, and not so much that a teal school reads as grey. */
    --accent-ui: color-mix(in srgb, var(--accent) 42%, #ffffff);

    --bg:        #0e1117;
    --surface:   #161a22;
    --surface-2: #1b2029;
    --sunken:    #12161d;
    --line:      #242a34;
    --line-2:    #333a46;
    --ink:       #e7eaef;
    --ink-2:     #b9c0cb;
    --muted:     #9aa2b0;
    --faint:     #8d95a3;

    --nav-bg:     #0b0e13;
    --nav-line:   #1c222b;
    --nav-ink:    #aab2c0;
    --nav-ink-hi: #ffffff;
    --nav-muted:  #99a2b1;
    --nav-group:  #8e98a8;
    --nav-hover:  #ffffff0d;

    --good: #4ade80;  --good-bg: #4ade8016;  --good-line: #4ade8033;
    --warn: #fbbf24;  --warn-bg: #fbbf2416;  --warn-line: #fbbf2433;
    --bad:  #f87171;  --bad-bg:  #f8717116;  --bad-line:  #f8717133;
    --info: #60a5fa;  --info-bg: #60a5fa16;  --info-line: #60a5fa33;
    --focus: #60a5fa;

    --shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);
    --shadow:    0 1px 3px rgba(0, 0, 0, .45);
    --shadow-lg: 0 12px 28px -10px rgba(0, 0, 0, .6);
  }
}

:root[data-theme="dark"] {
  --accent-ui: color-mix(in srgb, var(--accent) 42%, #ffffff);
  --bg:#0e1117; --surface:#161a22; --surface-2:#1b2029; --sunken:#12161d;
  --line:#242a34; --line-2:#333a46; --ink:#e7eaef; --ink-2:#b9c0cb;
  --muted:#9aa2b0; --faint:#8d95a3;
  --nav-bg:#0b0e13; --nav-line:#1c222b; --nav-ink:#aab2c0; --nav-ink-hi:#fff;
  --nav-muted:#99a2b1; --nav-group:#8e98a8; --nav-hover:#ffffff0d;
  --good:#4ade80; --good-bg:#4ade8016; --good-line:#4ade8033;
  --warn:#fbbf24; --warn-bg:#fbbf2416; --warn-line:#fbbf2433;
  --bad:#f87171;  --bad-bg:#f8717116;  --bad-line:#f8717133;
  --info:#60a5fa; --info-bg:#60a5fa16; --info-line:#60a5fa33;
  --focus:#60a5fa;
  --shadow-sm:0 1px 2px rgba(0,0,0,.4); --shadow:0 1px 3px rgba(0,0,0,.45);
  --shadow-lg:0 12px 28px -10px rgba(0,0,0,.6);
}

/* ── 3. Reset ───────────────────────────────────────────────────────────── */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--t-md);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

h1, h2, h3, h4 { margin: 0; font-family: var(--display); text-wrap: balance; }
p  { margin: 0; }
ul, ol { margin: 0; padding: 0; }
img, svg { max-width: 100%; }
a { color: var(--accent-ui); text-decoration: none; }
a:hover { text-decoration: underline; }

:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    transition-duration: .01ms !important;
  }
}

/* ── 4. Layout ──────────────────────────────────────────────────────────── */

.shell { display: grid; grid-template-columns: var(--nav-w) 1fr; min-height: 100vh; }

/* Sidebar ---------------------------------------------------------------- */
.sidebar {
  background: var(--nav-bg);
  color: var(--nav-ink);
  border-right: 1px solid var(--nav-line);
  display: flex;
  flex-direction: column;
  padding: var(--s-3);
  gap: 2px;
  position: sticky;
  top: 0;
  height: 100vh;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.brand {
  display: flex; align-items: center; gap: var(--s-3);
  padding: var(--s-2) var(--s-2) var(--s-4);
  min-width: 0;
}
.brand__crest {
  width: 2rem; height: 2rem; border-radius: var(--r);
  background: var(--accent); color: var(--accent-ink);
  display: grid; place-items: center;
  font-family: var(--display); font-weight: 800; font-size: var(--t-sm);
  flex: none; letter-spacing: -.02em;
}
/* A real logo, not initials. `contain` rather than `cover`: a school crest is
   usually not square, and cropping somebody's coat of arms to fit a rounded
   square is the sort of detail they notice immediately. The accent background
   is dropped for the same reason -- a logo arrives with its own. */
.brand__crest--logo {
  background: transparent; object-fit: contain; padding: 0;
}
.brand__logo-preview {
  width: 4rem; height: 4rem; object-fit: contain; border-radius: var(--r);
  background: var(--surface-2); border: 1px solid var(--line);
}
.brand__name {
  font-family: var(--display); font-weight: 700; font-size: var(--t-md);
  color: var(--nav-ink-hi); line-height: 1.2; letter-spacing: -.01em;
  display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.brand__sub { display: block; font-size: var(--t-xs); color: var(--nav-muted); }

.navgroup {
  font-size: var(--t-xs); font-weight: 600; letter-spacing: .1em;
  text-transform: uppercase; color: var(--nav-group);
  margin: var(--s-5) 0 var(--s-2); padding: 0 var(--s-2);
}
.navgroup:first-of-type { margin-top: var(--s-2); }

.navlink {
  display: flex; align-items: center; gap: var(--s-3);
  padding: .45rem var(--s-2); border-radius: var(--r);
  color: var(--nav-ink); font-size: var(--t-sm); font-weight: 500;
  text-decoration: none; position: relative;
  transition: background .12s var(--ease), color .12s var(--ease);
}
.navlink:hover { background: var(--nav-hover); color: var(--nav-ink-hi); text-decoration: none; }
.navlink .icon { width: 1.125rem; height: 1.125rem; flex: none; opacity: .7; }
.navlink[aria-current="page"] {
  background: var(--accent); color: var(--accent-ink); font-weight: 600;
}
.navlink[aria-current="page"] .icon { opacity: 1; }
.navlink__count {
  margin-left: auto; font-size: var(--t-xs); font-weight: 600;
  background: var(--nav-hover); color: var(--nav-muted);
  padding: 0 .35rem; border-radius: var(--r-full);
}
.navlink[aria-current="page"] .navlink__count {
  background: #ffffff2e; color: var(--accent-ink);
}

.sidebar__foot {
  margin-top: auto; padding-top: var(--s-3);
  border-top: 1px solid var(--nav-line);
  display: flex; align-items: center; gap: var(--s-2); min-width: 0;
}
/* Your own name is a link to your own account (slice W1): the place a person
   looks for their settings is their name, and until W1 the staff shell had no
   link to /account/password at all. It must still read as text, not as a
   button -- so only the hover state says it is pressable. */
.sidebar__me { min-width: 0; flex: 1; border-radius: var(--r); padding: var(--s-1) var(--s-2); margin-left: calc(var(--s-2) * -1); }
a.sidebar__me:hover { background: var(--nav-hover); }
a.sidebar__me:hover b { color: var(--nav-ink-hi); }
.sidebar__me b {
  display: block; font-size: var(--t-sm); font-weight: 600; color: var(--nav-ink-hi);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sidebar__me span { display: block; font-size: var(--t-xs); color: var(--nav-muted); }
.sidebar__out {
  color: var(--nav-muted); padding: var(--s-2); border-radius: var(--r);
  display: grid; place-items: center; flex: none;
}
.sidebar__out:hover { background: var(--nav-hover); color: var(--nav-ink-hi); }
.sidebar__out .icon { width: 1.05rem; height: 1.05rem; }

/* Main ------------------------------------------------------------------- */
.main { display: flex; flex-direction: column; min-width: 0; }

.topbar {
  display: flex; align-items: center; gap: var(--s-3);
  padding: var(--s-4) var(--s-6);
  background: var(--surface);
  border-bottom: 1px solid var(--line);
  position: sticky; top: 0; z-index: 10;
  min-height: 4.25rem;
}
.topbar__title { min-width: 0; }
.topbar__title h1 {
  font-size: var(--t-xl); font-weight: 800; letter-spacing: -.022em; line-height: 1.2;
}
.topbar__crumb {
  display: block; font-family: var(--font); font-size: var(--t-sm);
  font-weight: 400; color: var(--muted); letter-spacing: 0; margin-top: 1px;
}
.topbar__actions { margin-left: auto; display: flex; gap: var(--s-2); align-items: center; flex: none; }

.page {
  padding: var(--s-6);
  display: flex; flex-direction: column; gap: var(--s-5);
  flex: 1; min-width: 0;
}
/*
   NOTHING ON A PAGE GROWS DOWNWARDS.

   `.page` is a COLUMN flex container that itself grows to fill the window.
   Several classes here — `.filters`, `.field--grow`, `.profile__id` — carry
   `flex: 1` because they live inside a ROW, where it means "take the
   remaining width". Put one of them straight on the page and the same
   declaration means "take the remaining HEIGHT": the element stretches to
   the bottom of the window and, being `align-items: center`, floats its
   contents in the middle of a field of nothing.

   That is what the staff change-request screen did — a row of four filter
   chips marooned halfway down an empty page. It is the same shape as the
   payroll-run bug, where `.card__body.row` inherited a column direction.
   Both are a class whose meaning depends on which way its parent runs.

   So the page settles it once, for every child it will ever have, rather
   than waiting to be surprised by the next class somebody reaches for.
   Sections size to their content and the leftover space stays at the
   bottom, where it belongs.
*/
.page > * { flex: 0 0 auto; }
/*
   `hidden` MEANS HIDDEN, EVEN INSIDE A FLEX OR GRID CONTAINER.

   The browser default is `[hidden] { display: none }`, which any later rule
   setting `display: flex` on the same element silently beats -- and the
   element then stays on screen with the attribute set, which is the worst of
   both: assistive technology is told it is hidden and the eye is told it is
   not. `data-reveal-for` in auto.js toggles this attribute, so it has to win.
*/
[hidden] { display: none !important; }
.page--narrow { max-width: 46rem; }
.page--form   { max-width: 38rem; }

/* Phone: the sidebar becomes a drawer opened by a checkbox, so navigation
   works with CSS alone. Scripts are nonce-gated by the CSP and a menu that
   needs JavaScript is a menu that breaks when the nonce is wrong. */
.drawer-toggle { position: absolute; opacity: 0; pointer-events: none; }
.drawer-open, .drawer-scrim { display: none; }

@media (max-width: 60rem) {
  .shell { grid-template-columns: 1fr; }
  .sidebar {
    position: fixed; inset: 0 auto 0 0; width: min(17rem, 84vw); z-index: 60;
    transform: translateX(-102%); transition: transform .22s var(--ease);
    height: 100dvh;
  }
  .drawer-toggle:checked ~ .shell .sidebar { transform: none; box-shadow: var(--shadow-lg); }
  .drawer-toggle:checked ~ .shell .drawer-scrim {
    display: block; position: fixed; inset: 0; z-index: 50; background: #0009;
  }
  .drawer-open {
    display: grid; place-items: center; flex: none;
    width: 2.25rem; height: 2.25rem; border-radius: var(--r);
    border: 1px solid var(--line); background: var(--surface); color: var(--ink-2);
    cursor: pointer;
  }
  .drawer-open .icon { width: 1.15rem; height: 1.15rem; }
  .topbar { padding: var(--s-3) var(--s-4); min-height: 3.5rem; }
  .topbar__title h1 { font-size: var(--t-lg); }
  .page { padding: var(--s-4); gap: var(--s-4); }
}

/* Auth layout ------------------------------------------------------------ */
.auth {
  min-height: 100vh; display: grid; grid-template-columns: 1fr 1fr;
  background: var(--surface);
}
.auth__aside {
  background: var(--nav-bg); color: #fff; padding: var(--s-12);
  display: flex; flex-direction: column; justify-content: space-between; gap: var(--s-8);
  position: relative; overflow: hidden;
}
.auth__aside::after {
  content: ""; position: absolute; right: -18%; bottom: -28%;
  width: 34rem; height: 34rem; border-radius: 50%;
  background: radial-gradient(circle at 30% 30%, var(--accent), transparent 62%);
  opacity: .3;
}
.auth__mark { display: flex; align-items: center; gap: var(--s-3); position: relative; z-index: 1; }
.auth__mark .brand__crest { width: 2.5rem; height: 2.5rem; font-size: var(--t-md); }
.auth__mark b { font-family: var(--display); font-weight: 700; font-size: var(--t-lg); }
.auth__pitch { position: relative; z-index: 1; display: flex; flex-direction: column; gap: var(--s-4); }
.auth__pitch h2 {
  font-size: var(--t-3xl); font-weight: 800; letter-spacing: -.03em; line-height: 1.12;
  max-width: 16ch;
}
.auth__pitch p { color: #ffffffb0; max-width: 40ch; font-size: var(--t-md); }
.auth__facts { display: flex; gap: var(--s-8); position: relative; z-index: 1; }
.auth__facts div { display: flex; flex-direction: column; }
.auth__facts b { font-family: var(--display); font-size: var(--t-lg); font-weight: 700; }
.auth__facts span { font-size: var(--t-xs); color: #ffffff8c; }

.auth__panel {
  display: grid; place-items: center; padding: var(--s-8) var(--s-6);
  background: var(--surface);
}
.auth__form { width: min(23rem, 100%); display: flex; flex-direction: column; gap: var(--s-5); }
.auth__head { display: flex; flex-direction: column; gap: var(--s-1); }
.auth__head h1 { font-size: var(--t-xl); font-weight: 800; letter-spacing: -.022em; }
.auth__head p  { color: var(--muted); font-size: var(--t-sm); }
.auth__foot { font-size: var(--t-sm); color: var(--muted); text-align: center; }

/* ── The sign-in page on a big screen ────────────────────────────────────
   At 1440px the 1fr 1fr split gives the form a 720px column to hold a 368px
   form, centred, with about 280px of nothing above it and the same below. The
   composed half looks deliberate and the half a person actually uses looks
   like a placeholder -- which is the wrong way round on the one page every
   single user sees before anything else.

   Three changes, none of them decoration:

   1. The panel stops growing. Past 90rem the form column is capped and the
      dark half takes the rest, so the composition holds instead of stretching
      into two empty halves. Capped rather than given a ratio, because a ratio
      keeps producing more empty space as the screen gets wider.
   2. The form column has a top and a bottom. It is a three-row grid -- a thin
      header rule, the form, a footer -- so the eye has somewhere to start and
      the white space reads as margin rather than as absence.
   3. The form itself is a little wider and its fields taller, because 23rem
      of controls in a 45rem column is what made it look like an afterthought.
   ──────────────────────────────────────────────────────────────────────── */
@media (min-width: 75rem) {
  .auth { grid-template-columns: 1fr minmax(30rem, 45rem); }
  .auth__panel {
    grid-template-rows: auto 1fr auto;
    place-items: stretch;
    padding: var(--s-8) clamp(var(--s-8), 8vw, var(--s-12));
  }
  .auth__form {
    width: min(26rem, 100%);
    justify-self: center;
    /* Not dead centre. Optically centred content sits slightly above the
       middle -- a form pinned to the exact centre of a 900px column reads as
       low, and the eye has to travel past a third of a blank page to find the
       first field on the one page everybody uses. */
    align-self: start;
    padding-top: clamp(var(--s-8), 14vh, 10rem);
    gap: var(--s-6);
  }
  .auth__panel > .auth__form { grid-row: 2; }
}

/* The rule and the footer exist at every width; above they are what the three
   rows are made of, below they are simply a header and a footer. */
.auth__panel-top {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-4); font-size: var(--t-xs); color: var(--muted);
}
.auth__panel-foot {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2) var(--s-5);
  font-size: var(--t-xs); color: var(--muted);
}
.auth__panel-foot a { color: var(--muted); }
.auth__panel-foot a:hover { color: var(--ink); }

@media (max-width: 74.99rem) {
  /* Below the wide layout the panel is a single centred column again, so the
     header and footer would float with nothing to align to. */
  .auth__panel-top, .auth__panel-foot { display: none; }
}

/* ── One panel and no dark half ──────────────────────────────────────────
   `auth/where.php` is the page served to the WHOLE INTERNET -- somebody who
   has typed the bare application domain and has no school yet. It has no
   tenant, so it has no aside, and it was never updated when the wide layout
   landed: at 1440px its single panel was dropping into the `1fr` column that
   the dark half is sized for, and stretching, while the 45rem column meant for
   it sat empty beside it.

   Found on 2026-09-23 while clearing the "sign-in page at 1440px" debt, which
   had already been fixed for the page with a school and never for this one.
   The `layout` check could not have caught it: it signs in first, so it has
   never measured a signed-out page at all. It does now.

   AFTER the wide-layout media query on purpose. `.auth--solo` and `.auth` have
   the same specificity, so source order is what decides -- and a rule that
   only wins by accident of ordering is worth saying so about. */
.auth--solo { grid-template-columns: 1fr; }
.auth--solo .auth__panel {
  place-items: center; grid-template-rows: none;
  padding: var(--s-10) var(--s-6);
}
.auth--solo .auth__panel > * { width: min(34rem, 100%); }

@media (max-width: 56rem) {
  .auth { grid-template-columns: 1fr; }
  .auth__aside { display: none; }
  .auth__panel { align-items: start; padding-top: var(--s-10); }
}

/* ── 5. Typography ──────────────────────────────────────────────────────── */

.eyebrow {
  font-size: var(--t-xs); font-weight: 600; letter-spacing: .09em;
  text-transform: uppercase; color: var(--faint);
}
.lede    { color: var(--muted); max-width: 62ch; }
.muted   { color: var(--muted); font-size: var(--t-sm); }
.hint    { color: var(--muted); font-size: var(--t-sm); max-width: 60ch; }
.tiny    { color: var(--faint); font-size: var(--t-xs); }
.mono    { font-family: var(--mono); font-size: .85em; }
.num     { font-variant-numeric: tabular-nums; }
.break   { word-break: break-all; }

code {
  font-family: var(--mono); font-size: .85em;
  background: var(--sunken); border: 1px solid var(--line);
  padding: .05em .35em; border-radius: var(--r-sm);
}

/* ── 6. Components ──────────────────────────────────────────────────────── */

/* Icons — an inline sprite, so no extra request and no external host. */
.icon { width: 1rem; height: 1rem; display: inline-block; vertical-align: -.15em; flex: none; }

/* Buttons ---------------------------------------------------------------- */
.btn {
  font: inherit; font-size: var(--t-sm); font-weight: 600;
  padding: .45rem .85rem; border-radius: var(--r);
  border: 1px solid var(--line-2); background: var(--surface); color: var(--ink);
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s-2);
  cursor: pointer; white-space: nowrap; text-decoration: none;
  transition: background .12s var(--ease), border-color .12s var(--ease), box-shadow .12s var(--ease);
}
.btn:hover { background: var(--surface-2); text-decoration: none; }
.btn:active { transform: translateY(.5px); }
.btn[disabled], .btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }

.btn--primary {
  background: var(--accent); border-color: transparent; color: var(--accent-ink);
  box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.btn--primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); }

.btn--danger { color: var(--bad); border-color: var(--bad-line); background: var(--surface); }
.btn--danger:hover { background: var(--bad-bg); }

.btn--ghost { border-color: transparent; background: transparent; color: var(--ink-2); }
.btn--ghost:hover { background: var(--sunken); }

.btn--sm { font-size: var(--t-xs); padding: .28rem .55rem; }
.btn--lg { font-size: var(--t-md); padding: .6rem 1.1rem; }
.btn--block { width: 100%; }

.btn-row { display: flex; gap: var(--s-2); align-items: center; flex-wrap: wrap; }

/* Fields ----------------------------------------------------------------- */
.field { display: flex; flex-direction: column; gap: var(--s-2); }
/* `.field__label` on a span too, for the heading above a GROUP of controls --
   a set of checkboxes has no single control to point a `for` at, and a
   `<label>` without one is a label that reads as attached to nothing. */
.field > label, .field > .field__label { font-size: var(--t-sm); font-weight: 600; color: var(--ink-2); }
.field__hint { font-size: var(--t-sm); color: var(--muted); }
.field__error { font-size: var(--t-sm); color: var(--bad); font-weight: 500; }

/* A section somebody may READ and may not CHANGE. (Slice Z8.)

   `<fieldset disabled>` is the only thing in HTML that turns a group of
   controls off in one attribute, including the submit button inside it -- so
   a person without the update permission sees the values, cannot type into
   them, and is never offered a Save that answers 403.

   A fieldset carries a border, a margin and a min-width of min-content by
   default, and that last one is the dangerous half: it stops a grid inside it
   shrinking, which on a narrow screen pushes the page wider than the window.
   Z2a-fix was exactly that bug from a different cause. */
.gated { border: 0; margin: 0; padding: 0; min-width: 0; }
.gated[disabled] { opacity: 0.72; }
.gated[disabled] .input, .gated[disabled] .select, .gated[disabled] .textarea { cursor: not-allowed; }

.input, .select, .textarea {
  font: inherit; font-size: var(--t-md); width: 100%;
  padding: .5rem .7rem; border-radius: var(--r);
  border: 1px solid var(--line-2); background: var(--surface); color: var(--ink);
  transition: border-color .12s var(--ease), box-shadow .12s var(--ease);
}
.input::placeholder, .textarea::placeholder { color: var(--faint); }
.input:hover, .select:hover, .textarea:hover { border-color: var(--faint); }
.input:focus, .select:focus, .textarea:focus {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-wash-2);
}
.input[aria-invalid="true"] { border-color: var(--bad); }
.textarea { min-height: 6rem; resize: vertical; }
.select {
  appearance: none; padding-right: 2rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%23697181' stroke-width='1.8' stroke-linecap='round'%3E%3Cpath d='M5.5 8 10 12.5 14.5 8'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right .55rem center; background-size: 1.05rem;
}

.input-group { display: flex; gap: var(--s-2); }
.input-group .input { flex: 1; }

/* A search box that looks like one. */
.searchbox { position: relative; display: flex; align-items: center; }
.searchbox .icon {
  position: absolute; left: .6rem; color: var(--faint);
  width: 1rem; height: 1rem; pointer-events: none;
}
.searchbox .input { padding-left: 2.1rem; }

/* Checkbox / radio rows -------------------------------------------------- */
.check {
  display: flex; align-items: flex-start; gap: var(--s-3);
  padding: .55rem .7rem; border-radius: var(--r);
  border: 1px solid transparent; cursor: pointer;
}
.check:hover { background: var(--surface-2); }
.check input { margin: .2rem 0 0; accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; }
.check input:disabled { cursor: not-allowed; }
.check__body { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.check__body b { font-weight: 500; font-size: var(--t-sm); }
.check__body span { font-size: var(--t-xs); color: var(--muted); font-family: var(--mono); }
.check:has(input:checked) { background: var(--accent-wash); border-color: var(--accent-line); }

/* A table column holding nothing but a tick. Narrow, and the whole cell is a
   comfortable target rather than the 16px box alone. */
.check-col { width: 1px; white-space: nowrap; }

/* Many ticks at once — twenty-five classes, say.
   A single column of twenty-five is a page of scrolling for a choice made at a
   glance; `auto-fill` gives as many columns as the width allows and one on a
   phone, without anybody choosing a number. */
.checks {
  display: grid; gap: var(--s-1);
  grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
}
.checks .check { align-items: center; }

/* The column ticks on a table element's inspector. A grid rather than a list
   because six of them in a sidebar is six lines of scrolling for a choice made
   at a glance — the same reasoning as `.checks` above, at a smaller size. */
.insp-cols {
  display: grid; gap: 2px;
  grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
  margin: var(--s-2) 0;
}
.insp-cols__label {
  grid-column: 1 / -1;
  font-size: var(--t-xs); font-weight: 600; letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted);
}
.insp-cols label { display: flex; align-items: center; gap: var(--s-2); font-size: var(--t-sm); }
.insp-cols input { accent-color: var(--accent); }
.check-col input { accent-color: var(--accent); width: 1rem; height: 1rem; margin: 0; cursor: pointer; }
tr:has(.check-col input:checked) { background: var(--accent-wash); }
.check:has(input:disabled) { opacity: .6; cursor: not-allowed; }

/* Cards ------------------------------------------------------------------ */
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-lg); box-shadow: var(--shadow-sm);
  min-width: 0; overflow: hidden;
}
.card__head {
  display: flex; align-items: center; gap: var(--s-3);
  padding: var(--s-4) var(--s-5); border-bottom: 1px solid var(--line);
}
.card__head h2 { font-size: var(--t-md); font-weight: 700; letter-spacing: -.01em; }
.card__head .muted { margin-top: 1px; }
.card__head > :last-child.card__action { margin-left: auto; }
.card__body { padding: var(--s-5); display: flex; flex-direction: column; gap: var(--s-4); }
.card__foot {
  padding: var(--s-4) var(--s-5); border-top: 1px solid var(--line);
  background: var(--surface-2); display: flex; gap: var(--s-2); align-items: center;
}
.card--flush .card__body { padding: 0; }

/* A FORM INSIDE A FLUSH CARD. (Phase 8, slice Z2a.)

   `card--flush` exists so a table can run edge to edge, and it does that by
   zeroing the body padding -- which is right for a table and wrong for
   everything else in the same card. The money card holds both: a table of what
   was agreed, and two forms for changing it, and without this the labels and
   inputs sat hard against the card's border while the table rows above them
   were inset.

   The separator is the second half of it. Two forms stacked with no line
   between them read as one long form with a button in the middle. */
.card--flush .card__body--inset {
  padding: var(--s-5);
  border-top: 1px solid var(--line);
}

/* A ROW INSIDE A CARD BODY.
 *
 * `.card__body` is a COLUMN flex and `.row` never declares a direction, so
 * `class="card__body row"` quietly stayed a column: the two halves of a
 * header stacked and centred, and with `row--between` the column stretched
 * and pushed them to opposite ends of a card half a screen tall. Srikanth
 * sent a screenshot of the payroll run looking exactly like that.
 *
 * Fixed here rather than in ten views, because the next person to write
 * `class="card__body row"` — the obvious thing to write — would hit it again.
 */
.card__body.row { flex-direction: row; align-items: center; flex-wrap: wrap; }
.card--danger { border-color: var(--bad-line); }
.card--danger .card__head { background: var(--bad-bg); border-bottom-color: var(--bad-line); }

/* Stat tiles ------------------------------------------------------------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: var(--s-4); }
.tile {
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: var(--s-4) var(--s-5); box-shadow: var(--shadow-sm);
  display: flex; flex-direction: column; gap: var(--s-1); min-width: 0;
}
.tile__label {
  font-size: var(--t-sm); color: var(--muted); font-weight: 500;
  display: flex; align-items: center; gap: var(--s-2);
}
.tile__label .icon { width: .9rem; height: .9rem; opacity: .6; }
.tile__value {
  font-family: var(--display); font-size: var(--t-2xl); font-weight: 800;
  letter-spacing: -.03em; line-height: 1.08; font-variant-numeric: tabular-nums;
}
.tile__note { font-size: var(--t-sm); color: var(--muted); }
.tile__note b { font-weight: 600; color: var(--good); }
.tile__note b.is-bad { color: var(--bad); }

/* Tables ----------------------------------------------------------------- */
.table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: var(--t-sm); }
.table th, .table td {
  text-align: left; padding: .6rem var(--s-5);
  border-bottom: 1px solid var(--line); vertical-align: middle;
}
.table thead th {
  font-size: var(--t-xs); font-weight: 600; letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted);
  background: var(--surface-2); white-space: nowrap;
}
/* Today's column on a weekly grid. Colour alone would not be enough — the
   underline carries it for anybody who cannot tell the two apart. */
.table thead th.is-today {
  color: var(--accent-ui); background: var(--accent-wash);
  box-shadow: inset 0 -2px 0 var(--accent);
}
.table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover { background: var(--surface-2); }
.table .right { text-align: right; }

/* Centred cells, for a matrix whose values are a tick rather than a number.
   Added in Z23 with the plan comparison, which had used a `.center` class that
   did not exist -- the same silent nothing as an inline handler the CSP will
   not run, one layer down. */
.table .center, .table th.center { text-align: center; }
.table .nowrap { white-space: nowrap; }
.table td.num, .table th.num { text-align: right; font-variant-numeric: tabular-nums; }

/* A grid of classes against subjects: many narrow numeric columns, and a first
   column that must stay readable while the rest scrolls sideways. Without the
   sticky name a school scrolls to the Music column and can no longer see which
   class each row is. */
.table--matrix th, .table--matrix td { padding: .45rem var(--s-3); }
.table--matrix thead th.num {
  font-size: 10px; letter-spacing: .04em;
  max-width: 4.5rem; overflow: hidden; text-overflow: ellipsis;
}
.table--matrix .matrix__name {
  position: sticky; left: 0; z-index: 1;
  background: var(--surface); white-space: nowrap; font-weight: 600;
}
.table--matrix tbody tr:hover .matrix__name { background: var(--surface-2); }

/* A matrix whose row labels are SENTENCES rather than class codes. `.matrix__name`
   is `nowrap` because a class called "6-A" must never wrap; "Announcements and
   messages" on the same rule took 45% of a 560px screen and left room for two
   plan columns out of six. Wrapping is right when the label is prose. */
.table--sentences .matrix__name { white-space: normal; min-width: 7rem; max-width: 11rem; }
.table__actions { display: flex; gap: var(--s-2); justify-content: flex-end; }

/* A person in a table cell. */
.person { display: flex; align-items: center; gap: var(--s-3); min-width: 0; }
.person b { display: block; font-weight: 600; line-height: 1.3; }
.person span { display: block; font-size: var(--t-xs); color: var(--muted); }

.avatar {
  width: 2rem; height: 2rem; border-radius: var(--r);
  background: var(--accent-wash-2); color: var(--accent-ui);
  display: grid; place-items: center; flex: none;
  font-family: var(--display); font-size: var(--t-xs); font-weight: 700;
}
.avatar--sm { width: 1.6rem; height: 1.6rem; font-size: 10px; }
.avatar--lg { width: 3rem; height: 3rem; font-size: var(--t-md); border-radius: var(--r-lg); }

/*
   A real photograph in the same slot as the initials.

   `object-fit: cover` rather than a stretched image: a portrait photographed on
   a phone is 3:4 and the slot is square, so the alternative is a child with a
   squashed face on their own record.
*/
.avatar--photo { object-fit: cover; background: var(--surface-2); padding: 0; }

/* The upload sits under the picture and must not push the header around. */
.portrait { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; }
.portrait__actions { display: flex; gap: .4rem; align-items: center; flex-wrap: wrap; }
.portrait__actions input[type=file] { max-width: 13rem; font-size: var(--t-xs); }

/*
   Phone tables.
   A register with eight columns does not become usable by growing a
   horizontal scrollbar — a class teacher marking attendance on a phone has to
   be able to read a row without dragging it sideways. Below 48rem each row
   becomes a card, and each cell announces itself with the header text carried
   in data-label. Views supply that attribute; the CSS does the rest.
*/
@media (max-width: 48rem) {
  .table--cards thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
  .table--cards, .table--cards tbody, .table--cards tr, .table--cards td { display: block; width: 100%; }
  .table--cards tr {
    border-bottom: 1px solid var(--line); padding: var(--s-3) var(--s-4);
    display: flex; flex-direction: column; gap: var(--s-1);
  }
  .table--cards tr:last-child { border-bottom: none; }
  .table--cards td {
    border: none; padding: 0; display: flex; gap: var(--s-3);
    align-items: baseline; justify-content: space-between;
  }
  .table--cards td::before {
    content: attr(data-label); font-size: var(--t-xs); font-weight: 600;
    letter-spacing: .05em; text-transform: uppercase; color: var(--muted); flex: none;
  }
  .table--cards td:first-child { padding-bottom: var(--s-2); }
  .table--cards td:first-child::before { display: none; }
  .table--cards td.right, .table--cards td.num { text-align: left; }
  .table--cards .table__actions { justify-content: flex-start; }
}

/* Tags ------------------------------------------------------------------- */
.tag {
  display: inline-flex; align-items: center; gap: .3rem;
  font-size: var(--t-xs); font-weight: 600; letter-spacing: .01em;
  padding: .15rem .5rem; border-radius: var(--r-full);
  background: var(--sunken); color: var(--ink-2); white-space: nowrap;
  border: 1px solid transparent;
}
.tag::before { content: ""; width: .375rem; height: .375rem; border-radius: 50%; background: currentColor; flex: none; }
.tag--plain::before { display: none; }
.tag--good { color: var(--good); background: var(--good-bg); border-color: var(--good-line); }
.tag--warn { color: var(--warn); background: var(--warn-bg); border-color: var(--warn-line); }
.tag--bad  { color: var(--bad);  background: var(--bad-bg);  border-color: var(--bad-line); }
.tag--info { color: var(--info); background: var(--info-bg); border-color: var(--info-line); }
.tag--accent { color: var(--accent-ui); background: var(--accent-wash); border-color: var(--accent-line); }

/* Alerts ----------------------------------------------------------------- */
.alert {
  display: flex; gap: var(--s-3); align-items: flex-start;
  padding: var(--s-3) var(--s-4); border-radius: var(--r);
  font-size: var(--t-sm); border: 1px solid transparent;
}
.alert .icon { width: 1.05rem; height: 1.05rem; flex: none; margin-top: .1rem; }
.alert--good { background: var(--good-bg); color: var(--good); border-color: var(--good-line); }
.alert--bad  { background: var(--bad-bg);  color: var(--bad);  border-color: var(--bad-line); }
.alert--warn { background: var(--warn-bg); color: var(--warn); border-color: var(--warn-line); }
.alert--info { background: var(--info-bg); color: var(--info); border-color: var(--info-line); }
.alert b { font-weight: 700; }

/*
   Empty states.
   A school on day one has eleven blank tables, and a blank table says nothing
   about what to do next. Every list that can be empty gets one of these, and
   it names the action rather than apologising.
*/
.empty {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: var(--s-3); padding: var(--s-10) var(--s-6);
}
.empty__art {
  width: 3rem; height: 3rem; border-radius: var(--r-lg);
  background: var(--accent-wash); color: var(--accent-ui);
  display: grid; place-items: center;
}
.empty__art .icon { width: 1.4rem; height: 1.4rem; }
.empty h3 { font-size: var(--t-md); font-weight: 700; }
.empty p { color: var(--muted); font-size: var(--t-sm); max-width: 44ch; }

/* Activity feed ---------------------------------------------------------- */
.feed { display: flex; flex-direction: column; }
.feed__item {
  display: flex; gap: var(--s-3); padding: var(--s-3) var(--s-5);
  border-bottom: 1px solid var(--line); font-size: var(--t-sm);
}
.feed__item:last-child { border-bottom: none; }
.feed__icon {
  width: 1.75rem; height: 1.75rem; border-radius: var(--r);
  background: var(--sunken); color: var(--muted);
  display: grid; place-items: center; flex: none;
}
.feed__icon .icon { width: .9rem; height: .9rem; }
.feed__body { min-width: 0; }
.feed__body b { font-weight: 600; }
.feed__body time { display: block; font-size: var(--t-xs); color: var(--faint); margin-top: 1px; }


/* Week chart --------------------------------------------------------------
   A table, not a canvas. The numbers are the content; the bars are a reading
   aid laid over them. That way it still says something with CSS disabled, a
   screen reader reads seven values rather than "image", and it needs no
   script -- which matters because every script here is nonce-gated.

   Widths are CLASSES in 5% steps. A `style="width:72%"` would be silently
   dropped by our own CSP and every bar would collapse to nothing, with no
   error anywhere. That is not hypothetical; it is how we lost a whole
   afternoon once (gate 13). */
.chart-week th, .chart-week td { padding: .3rem 0; border: none; }
.chart-week tbody tr:hover { background: none; }
.chart-week th {
  font-size: var(--t-xs); font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .05em; width: 2.5rem;
}
.chart-week td:nth-child(2) { width: 100%; padding: 0 var(--s-3); }
.chart-week td.num { width: 2rem; font-size: var(--t-sm); color: var(--ink-2); }
.bar {
  display: block; height: .55rem; border-radius: var(--r-full);
  background: var(--accent); min-width: .28rem;
  background-image: linear-gradient(90deg, var(--accent-wash-2), var(--accent));
}
/* Zero draws nothing. The min-width above keeps a small value visible, which
   is right for 1 and a lie for 0 -- a quiet Sunday rendered as a stub reads
   as "a few" at a glance, and the point of the chart is the shape. */
.bar--0 { background: none; min-width: 0; }
.bar--0{width:0}
.bar--5{width:5%}
.bar--10{width:10%}
.bar--15{width:15%}
.bar--20{width:20%}
.bar--25{width:25%}
.bar--30{width:30%}
.bar--35{width:35%}
.bar--40{width:40%}
.bar--45{width:45%}
.bar--50{width:50%}
.bar--55{width:55%}
.bar--60{width:60%}
.bar--65{width:65%}
.bar--70{width:70%}
.bar--75{width:75%}
.bar--80{width:80%}
.bar--85{width:85%}
.bar--90{width:90%}
.bar--95{width:95%}
.bar--100{width:100%}

/* Description list ------------------------------------------------------- */
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: var(--s-4) var(--s-6); }
.facts div { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.facts dt { font-size: var(--t-xs); font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.facts dd { margin: 0; font-size: var(--t-sm); }

/* Toolbar above a table -------------------------------------------------- */
.toolbar {
  display: flex; gap: var(--s-2); align-items: center; flex-wrap: wrap;
  padding: var(--s-3) var(--s-5); border-bottom: 1px solid var(--line);
}
.toolbar .searchbox { flex: 1; min-width: 12rem; max-width: 22rem; }
.toolbar__count { margin-left: auto; font-size: var(--t-sm); color: var(--muted); font-variant-numeric: tabular-nums; }

/* Section heading inside a page ------------------------------------------ */
.section-head { display: flex; align-items: baseline; gap: var(--s-3); flex-wrap: wrap; }
.section-head h2 { font-size: var(--t-lg); font-weight: 700; letter-spacing: -.015em; }
.section-head p { color: var(--muted); font-size: var(--t-sm); }

/* Support-session banner -------------------------------------------------- */
/*
   Deliberately loud, at the top of every page, and in its own colours rather
   than the school's accent — a school must not be able to brand it into
   invisibility. Somebody standing in for a user should be unable to forget it.
*/
.impersonation {
  background: #b45309; color: #fff;
  display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap;
  padding: var(--s-2) var(--s-6); font-size: var(--t-sm);
}
.impersonation b { font-weight: 700; }
.impersonation .icon { width: 1.05rem; height: 1.05rem; }
.impersonation__why { opacity: .9; }
.impersonation form { margin-left: auto; }
.impersonation .btn { background: #fff; color: #b45309; border-color: transparent; }
.impersonation .btn:hover { background: #fff; filter: brightness(.94); }

/* Code / key display ----------------------------------------------------- */
.keybox {
  font-family: var(--mono); font-size: var(--t-sm); letter-spacing: .05em;
  background: var(--sunken); border: 1px solid var(--line); border-radius: var(--r);
  padding: var(--s-3) var(--s-4); word-break: break-all; text-align: center;
}
.codes { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: var(--s-2); }
.codes code {
  padding: var(--s-2); text-align: center; letter-spacing: .08em;
  font-size: var(--t-sm); background: var(--sunken);
}

/* Layout helpers --------------------------------------------------------- */
.grid-2 { display: grid; grid-template-columns: 1.6fr 1fr; gap: var(--s-5); align-items: start; }
.grid-half { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-4); }
@media (max-width: 64rem) { .grid-2 { grid-template-columns: 1fr; } }
@media (max-width: 40rem) { .grid-half { grid-template-columns: 1fr; } }

.stack { display: flex; flex-direction: column; gap: var(--s-4); }
.stack--sm { gap: var(--s-2); }
.stack--lg { gap: var(--s-6); }
.row { display: flex; gap: var(--s-3); align-items: center; flex-wrap: wrap; }
.row--between { justify-content: space-between; }
.row--tight { gap: var(--s-2); flex-wrap: nowrap; }
.field--grow { flex: 1 1 18rem; }
.push { margin-left: auto; }

/* Tabs ---------------------------------------------------------------------
   Links carrying `?tab=`, not script. Each tab is a real URL a person can
   bookmark and a form can redirect back to, which is what makes "you just
   added a subject, here is the subjects tab" possible without a line of JS. */
.tabs {
  display: flex; gap: var(--s-1); flex-wrap: wrap;
  border-bottom: 1px solid var(--line); padding: 0 var(--s-1);
}
.tabs a {
  padding: var(--s-3) var(--s-4); font-size: var(--t-sm); font-weight: 600;
  color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px;
  display: flex; align-items: center; gap: var(--s-2);
}
.tabs a:hover { color: var(--ink); text-decoration: none; }
.tabs a[aria-current="page"] { color: var(--accent-ui); border-bottom-color: var(--accent); }
.tabs .count {
  font-size: var(--t-xs); font-weight: 600; padding: 0 .35rem; border-radius: var(--r-full);
  background: var(--sunken); color: var(--muted); font-variant-numeric: tabular-nums;
}
.tabs a[aria-current="page"] .count { background: var(--accent-wash); color: var(--accent-ui); }

/* Inline row editor --------------------------------------------------------
   <details> rather than a script or a second page: a rename is a small edit
   and sending somebody to a form of its own for it is three navigations to
   change one letter. Native, keyboard-operable, and it survives a CSP that
   nonces every script. */
.rowedit > summary {
  /* --ink-2, NOT --muted. This is a control somebody presses, and caption
     grey on a raised button surface measured 3.9:1 in dark. A colour chosen
     for text nobody has to read is the wrong colour for a thing to click. */
  cursor: pointer; font-size: var(--t-xs); font-weight: 600; color: var(--ink-2);
  list-style: none; display: inline-flex; align-items: center; gap: var(--s-1);
  padding: .2rem .45rem; border: 1px solid var(--line); border-radius: var(--r-sm);
}
.rowedit > summary::-webkit-details-marker { display: none; }
.rowedit > summary:hover { color: var(--ink); background: var(--sunken); }
.rowedit[open] > summary { color: var(--accent-ui); border-color: var(--accent-line); }
.rowedit__body {
  display: flex; gap: var(--s-2); align-items: flex-end; flex-wrap: wrap;
  padding: var(--s-3) 0 var(--s-1);
}
.rowedit__body .input, .rowedit__body .select { max-width: 9rem; }
.rowedit__body .field { gap: 2px; }
.rowedit__body .field > label { font-size: var(--t-xs); }

/* Several small actions in one table cell ---------------------------------
   A row used to carry at most one button. An enrolment row now carries "take
   out" or "move", and a portal row carries "new password" and "close" -- each
   its own <form>, because each is its own POST with its own CSRF token and its
   own permission. Forms are block-level, so without this they stack into a
   column three deep and the table grows a ragged right edge. */
.rowactions {
  display: flex; flex-wrap: wrap; gap: var(--s-2); align-items: center;
}
.rowactions form { display: inline; }
.rowactions .rowedit__body, .rowedit form { display: flex; gap: var(--s-2); align-items: flex-end; flex-wrap: wrap; padding-top: var(--s-2); }

/* A FULL EDITOR INSIDE A <details>, RATHER THAN A ROW OF TWO FIELDS.
   ---------------------------------------------------------------------------
   The rule above was written for what `.rowedit` originally held: a couple of
   controls and a button, side by side, bottoms aligned. It is `(0,1,1)` and
   `.stack` is `(0,1,0)`, so **it silently wins over the column direction
   `.stack` promises** — and Z4's change-address form and Z5's plan editor were
   both laid out as a wrapped row hugging the right edge, which is how Srikanth
   saw the plan editor on 2026-09-25.

   Nothing was wrong with either form. A class declaring a direction was
   overruled by a more specific one that did not mention it, which is the
   cascade collision that leaves no trace in either file.

   `layout.mjs` now measures it: an element carrying `stack` whose computed
   `flex-direction` is not `column` fails the build. */
.rowedit--stack form {
  flex-direction: column; align-items: stretch; gap: var(--s-4);
  /* Slice K: a stacked editor opened inside a right-aligned action cell
     (the Applications row) inherited `text-align: right`, and every label
     sat against the far edge of its box. An editor reads left to right
     wherever it is opened. */
  text-align: left;
}
.rowedit--stack form .row { align-items: flex-end; }
.rowedit form .input { max-width: 16rem; }

/* A box that holds a small whole number — periods a week, a quantity in a
   table column. Right-aligned and tabular so a column of them reads as a
   column of numbers rather than a column of boxes. */
/* A list of sentences rather than a list of things: no bullets, real spacing,
   used where each line is a finding somebody has to read. */
ul.plain { list-style: none; padding: 0; margin: 0 0 var(--s-4); display: grid; gap: var(--s-2); }
ul.plain li { font-size: var(--t-sm); color: var(--ink-2); }

.input--tiny {
  max-width: 4.5rem; text-align: right;
  font-variant-numeric: tabular-nums;
}

/* A money box: wide enough for a lakh, narrow enough that a column of them
   reads as figures rather than as a wall of boxes. `input--tiny` is for a
   count of periods, not for 20,000. */
.input--sum {
  max-width: 8rem; text-align: right;
  font-variant-numeric: tabular-nums;
}

/* Checkbox with a label beside it ----------------------------------------- */
.check { display: inline-flex; align-items: center; gap: var(--s-2); font-size: var(--t-sm); }
.check input { width: 1rem; height: 1rem; accent-color: var(--accent); flex: none; }

/* Secondary line inside a table cell --------------------------------------
   A phone number under a name, a department under a job title. Inline, the
   two run together: "Rudra Bhat +91 78386 25169" is one string to the eye
   and two facts to the reader. */
.sub { display: block; font-size: var(--t-xs); color: var(--muted); }

/* Filter bar --------------------------------------------------------------
   A plain GET form, so a filtered list is a URL a receptionist can bookmark
   and a head of year can paste into a message. No script: the submit button
   is real and visible, because a select that filters on change strands
   anybody using a keyboard halfway through choosing. */
.filters { display: flex; gap: var(--s-2); align-items: center; flex-wrap: wrap; flex: 1; }
.filters .searchbox { flex: 1; min-width: 11rem; max-width: 20rem; }
.filters .select { max-width: 12rem; }

/* Pagination --------------------------------------------------------------
   Previous / next and a position, not a row of numbered links. At 25 a page
   a school with 900 students has 36 of them, and a numbered strip that wide
   is a wall of near-identical targets on a phone. */
.pager {
  display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap;
  padding: var(--s-3) var(--s-5); border-top: 1px solid var(--line);
  font-size: var(--t-sm); color: var(--muted);
}
.pager__where { font-variant-numeric: tabular-nums; }
.pager__nav { margin-left: auto; display: flex; gap: var(--s-2); }

/* Profile header ----------------------------------------------------------
   The one place a person's identity is stated in full. The avatar is larger
   than in a list on purpose: it is the confirmation that you opened the
   record you meant to open, which matters most on the screen where you are
   about to change something. */
.profile { display: flex; gap: var(--s-4); align-items: flex-start; flex-wrap: wrap; }
.profile__id { flex: 1; min-width: 14rem; display: flex; flex-direction: column; gap: var(--s-2); }
.profile__name { font-family: var(--display); font-size: var(--t-lg); font-weight: 700; letter-spacing: -.015em; }
.profile__meta { color: var(--muted); font-size: var(--t-sm); }



/* ── Colour picker ──────────────────────────────────────────────────────────
   Choosing the school's accent used to be a text box you typed six hex digits
   into and then saved to find out what you had done. A head teacher does not
   think in hex, and "save and look" is a bad way to learn that your brand
   colour is illegible on a button.

   Three things, in order of how most people will use them: presets, a native
   picker, and the hex field for somebody who has been handed a brand guide.

   The preset colours are CLASSES, not style attributes -- our CSP drops those
   silently, so a swatch row built the obvious way would render as eight
   identical grey squares and nobody would see an error. The live preview is
   driven from script via CSSOM, which `style-src` does not govern, and the
   whole enhancement is hidden unless the script runs: a control that cannot
   work should not be on the page at all.
   -------------------------------------------------------------------------- */
.swatches { display: flex; gap: var(--s-2); flex-wrap: wrap; }
.swatch {
  width: 2rem; height: 2rem; border-radius: var(--r); cursor: pointer;
  border: 2px solid transparent; box-shadow: inset 0 0 0 1px #0000001f;
  padding: 0; flex: none;
}
.swatch:hover { transform: scale(1.06); }
.swatch[aria-pressed="true"] { border-color: var(--ink); }
.swatch-teal   { background: #0f766e }
.swatch-blue   { background: #1d4ed8 }
.swatch-indigo { background: #4338ca }
.swatch-plum   { background: #7e22ce }
.swatch-rose   { background: #be123c }
.swatch-amber  { background: #b45309 }
.swatch-green  { background: #15803d }
.swatch-slate  { background: #334155 }

.picker-row { display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap; }
.picker-row .input { max-width: 9rem; }
input[type="color"] {
  width: 2.75rem; height: 2.5rem; padding: 2px; cursor: pointer;
  border: 1px solid var(--line-2); border-radius: var(--r); background: var(--surface);
}

/* The preview. Deliberately the three surfaces the accent actually paints, so
   what you see is what changes -- not a swatch of the colour on its own, which
   tells you nothing about whether white text will survive on it. */
.preview {
  --accent: var(--preview-accent, var(--accent));
  display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap;
  padding: var(--s-4); border-radius: var(--r-lg);
  background: var(--sunken); border: 1px solid var(--line);
}
.preview__nav {
  display: flex; align-items: center; gap: var(--s-2);
  background: var(--accent); color: #fff; font-size: var(--t-sm); font-weight: 600;
  padding: .4rem .7rem; border-radius: var(--r);
}
.preview__nav .icon { width: 1rem; height: 1rem; }
.preview__hex {
  font-family: var(--mono); font-size: var(--t-sm); color: var(--muted);
  margin-left: auto;
}

/* ── Platform console ───────────────────────────────────────────────────────
   The operator's side. Same components, different world: a violet no school
   can pick, and a bar that never leaves the top of the screen. Somebody who
   has been standing in for a head teacher, then flips back here, then into
   another school, must not be one glance from believing they are somewhere
   else — that is how a change lands in the wrong school's data.
   -------------------------------------------------------------------------- */
.console { background: var(--bg); }

/* NOWRAP ON PURPOSE. The bar used to wrap, and the day "Your account" was
   added to an already-full nav it wrapped: the operator's own name dropped to
   a second row and the bar doubled in height. Three parts now — brand, nav,
   the operator's group — and the NAV is the only one allowed to give way: it
   scrolls sideways rather than pushing anything onto a new line. The standing
   note and the operator's name keep their place, because the whole purpose of
   this bar is that one glance says where you are and as whom. */
.console__bar {
  display: flex; align-items: center; gap: var(--s-3); flex-wrap: nowrap;
  padding: var(--s-3) var(--s-6);
  background: #1b1630; color: #e9e4ff;
  border-bottom: 2px solid var(--accent);
  font-size: var(--t-sm);
  position: sticky; top: 0; z-index: 20;
}
.console__mark {
  width: 1.75rem; height: 1.75rem; border-radius: var(--r);
  background: var(--accent); color: #fff; display: grid; place-items: center; flex: none;
}
.console__mark .icon { width: 1rem; height: 1rem; }
.console__bar b { font-family: var(--display); font-weight: 700; letter-spacing: -.01em; white-space: nowrap; }
.console__note {
  color: #b9aee6; flex: 0 1 auto; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* THE NOTE GOES BEFORE THE NAV DOES. Twelve nav items, the operator's name
   and this sentence do not fit a laptop, and a sentence cut to "affect cust…"
   warns nobody. Below this width the violet bar, the mark and the title still
   say where you are; the sentence returns when there is room for all of it. */
@media (max-width: 90rem) { .console__note { display: none; } }

.console__nav {
  margin-left: auto; display: flex; gap: var(--s-4);
  min-width: 0; overflow-x: auto; white-space: nowrap;
  scrollbar-width: none;
}
.console__nav::-webkit-scrollbar { display: none; }
.console__nav a { color: #cfc6f2; font-weight: 600; }
.console__nav a:hover { color: #fff; }
.console__me {
  flex: none; display: flex; align-items: center; gap: var(--s-4);
  padding-left: var(--s-4); border-left: 1px solid #3a3060;
}
.console__me a { color: #cfc6f2; font-weight: 600; }
.console__me a:hover { color: #fff; }
.console__who { font-weight: 600; color: #fff; }

/* ── Names the markup was already using ─────────────────────────────────────
   A sweep on 2026-10-15 found 53 class names in views that no stylesheet
   defines. Most were in `errors/html/*` and `welcome_message.php`, which carry
   their own <style> and are the framework's, not ours. These are the ones that
   were OURS: written in good faith, shipped, and silently styling nothing --
   bug 67's family again, this time wearing a class attribute.

   Each is defined here rather than deleted because each is a real recurring
   thing the markup needed a name for. The ones that were simply the wrong name
   (`alert--error`, `tag--ok`) were corrected in the views instead, and three
   no-op wrappers in the marketing page lost a class that did nothing.
   -------------------------------------------------------------------------- */

/* The ← Back above an account page. Four uses, none of them styled. */
.backlink {
  font-size: var(--t-sm); font-weight: 600; color: var(--muted);
  display: inline-flex; align-items: center; gap: var(--s-1);
}
.backlink:hover { color: var(--ink); text-decoration: none; }

/* A card carrying a caution rather than a danger: an unreleased invoice, a
   receipt for a payment that has not cleared. `card--danger` already exists and
   is the stronger one; this is the amber sibling it always implied. */
.card--warn { border-color: var(--warn-line); }
.card--warn .card__head { background: var(--warn-bg); border-bottom-color: var(--warn-line); }

/* A group of radio or checkbox rows in a custom field. The rows are `.check`,
   which is styled; the group they sit in was not, so a field with eight options
   ran together. */
.choices { display: flex; flex-direction: column; gap: var(--s-1); }

/* A form inside a row's inline editor. `.rowedit` gives it the disclosure; this
   gives its contents room. */
.fieldform { display: flex; flex-direction: column; gap: var(--s-4); }

/* The controls beside a syllabus note, opposite `.note__body`. */
.note__side { flex: none; display: flex; align-items: flex-start; gap: var(--s-2); }

/* Label-and-value rows: a <dl> whose pairs each sit in their own <div>. */
.pairs { display: flex; flex-direction: column; gap: var(--s-3); margin: 0; }
.pairs > div { display: flex; gap: var(--s-4); align-items: baseline; }
.pairs dt { flex: 0 0 11rem; color: var(--muted); font-size: var(--t-sm); }
.pairs dd { margin: 0; min-width: 0; }

/* A heading inside a card body, below the card's own <h2>. */
.section-heading {
  font-size: var(--t-sm); font-weight: 700; letter-spacing: .02em;
  text-transform: uppercase; color: var(--muted);
}

/* One word in the bad colour, inline. `.tag--bad` is a pill and too loud for a
   word inside a sentence. */
.text-bad { color: var(--bad); }

/* The QR block: a field whose control is a drawing. */
.qr svg { display: block; border-radius: var(--r); background: #fff; }

/* A SCHOOL ASKING FOR HELP, AND WHAT WE WROTE BACK. (Slice I.)
   One block per request rather than a table row: the content is a paragraph
   somebody typed, and a paragraph in a table cell is a table that reflows on
   every row. The answer is indented under the question because that is the
   shape of the thing -- a reply, not a second message. */
.askrow {
  display: flex; flex-direction: column; gap: var(--s-2);
  padding: var(--s-4) 0; border-bottom: 1px solid var(--line);
}
.askrow:first-child { padding-top: 0; }
.askrow:last-child { padding-bottom: 0; border-bottom: 0; }
.askrow__head {
  display: flex; align-items: center; gap: var(--s-2) var(--s-3);
  flex-wrap: wrap; font-size: var(--t-sm);
}
.askrow__answer {
  border-left: 3px solid var(--accent-line); padding-left: var(--s-4);
  display: flex; flex-direction: column; gap: 2px;
}
/* A REPLY FROM THE SCHOOL, in a thread that is mostly ours. The rail changes
   colour rather than the side or the alignment: a two-sided chat layout would
   make a support thread look like a messaging app, and this is a record that
   both sides can be asked to stand behind. (Slice I, db/175.) */
.askrow__answer--theirs { border-left-color: var(--line-2); }
.askrow__answer b { font-weight: 600; font-size: var(--t-sm); }
.askrow__answer b .muted { font-weight: 400; margin-left: var(--s-2); }

/* A LIST YOU PICK ONE OF, with a search box above it. Built for the support
   session picker, which was a <select> of every account in a school: fine at
   fifteen, unusable at a thousand. Scrolls at a fixed height so the form's own
   buttons stay in reach, and the whole row is the target -- a radio dot is a
   small thing to hit and the name beside it is not. */
.picker {
  max-height: 20rem; overflow-y: auto;
  border: 1px solid var(--line-2); border-radius: var(--r);
  background: var(--surface);
}
.picker__row {
  display: flex; align-items: flex-start; gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-bottom: 1px solid var(--line);
  cursor: pointer;
}
.picker__row:last-child { border-bottom: 0; }
.picker__row:hover { background: var(--surface-2); }
.picker__row:has(input:checked) { background: var(--accent-wash); }
.picker__row:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
.picker__row input { margin-top: .2rem; flex: none; }
.picker__who { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.picker__meta { color: var(--muted); font-size: var(--t-sm); }

.console__page {
  max-width: 68rem; margin: 0 auto; padding: var(--s-8) var(--s-6) var(--s-12);
  display: flex; flex-direction: column; gap: var(--s-5);
}
.itemnames { margin-top: .15rem; font-size: var(--t-sm); }
.itemnames a { font-weight: 600; }

.console__page--bare { max-width: 24rem; padding-top: var(--s-12); }
.console__head { display: flex; align-items: flex-start; gap: var(--s-4); flex-wrap: wrap; }
.console__head h1 { font-size: var(--t-xl); font-weight: 800; letter-spacing: -.022em; }
.console__head .topbar__actions { margin-left: auto; }

@media (max-width: 48rem) {
  /* Wrapping comes back on a phone, where there is no width to scroll into. */
  .console__bar { padding: var(--s-2) var(--s-4); flex-wrap: wrap; }
  .console__note, .console__who { display: none; }
  .console__me { padding-left: 0; border-left: 0; margin-left: auto; }
  .console__page { padding: var(--s-5) var(--s-4) var(--s-10); }
}

/* ── 6b. The parent and student portal ──────────────────────────────────────
   A different shell, not a smaller one. A parent opening this on a phone at
   the school gate is not navigating an application: they want their child's
   name, their class, and who to ring. So there is no sidebar to collapse, no
   module navigation, and one column at every width.

   It borrows the same tokens as the staff app, so a school's accent colour is
   the same colour in both, and a parent who later becomes a member of staff is
   not looking at two unrelated products.
   -------------------------------------------------------------------------- */
.portal { background: var(--bg); min-height: 100vh; display: flex; flex-direction: column; }

.portal__bar {
  display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap;
  padding: var(--s-4) var(--s-6);
  background: var(--surface); border-bottom: 1px solid var(--line);
  position: sticky; top: 0; z-index: 20;
}
.portal__bar .brand__crest { width: 2.25rem; height: 2.25rem; font-size: var(--t-sm); }
.portal__name { font-family: var(--display); font-weight: 700; letter-spacing: -.01em; }
.portal__who { margin-left: auto; display: flex; align-items: center; gap: var(--s-3); font-size: var(--t-sm); }
.portal__who b { font-weight: 600; }

/* The portal's sections, under the bar and on every page of it.
   They were a line of text on the front page until 2026-09-22, which made
   school-level links look like part of the child chooser and put them out of
   reach from everywhere else. Tabs, with an active state that is TRUE — the
   same report asked why a filled pill on the child page claimed Fees was open
   when it was not. Wraps rather than scrolls: four items at most, and a row
   that scrolls sideways hides its last item on the phone this is read on. */
.portal__nav {
  display: flex; flex-wrap: wrap; gap: var(--s-1);
  padding: 0 var(--s-6);
  background: var(--surface); border-bottom: 1px solid var(--line);
}
.portal__navlink {
  padding: var(--s-3) var(--s-2);
  font-size: var(--t-sm); font-weight: 600; color: var(--muted);
  border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.portal__navlink:hover { color: var(--ink); text-decoration: none; }
.portal__navlink.is-on { color: var(--accent-ui); border-bottom-color: var(--accent); }

.portal__page {
  width: 100%; max-width: 52rem; margin: 0 auto;
  padding: var(--s-8) var(--s-6) var(--s-12);
  display: flex; flex-direction: column; gap: var(--s-5);
}
/* THE PAGE HEAD HAD NO LAYOUT AT ALL -- only a size for its h1 and its p.
   Twenty portal pages put a back link, a heading and a line of explanation in
   it and got whatever inline flow did with them: uneven gaps, a back link
   sitting on the heading's baseline, and on the child page a row of nine
   links that broke wherever the text happened to run out. (Srikanth, viewing
   a parent account on 2026-10-15.)

   One row that wraps, and the heading and its line always take the full width
   -- so links sit beside each other and text never sits beside a link. */
.portal__head {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--s-2) var(--s-3);
}
.portal__head h1 {
  flex-basis: 100%; font-size: var(--t-xl); font-weight: 800; letter-spacing: -.022em;
}
.portal__head p { flex-basis: 100%; color: var(--muted); font-size: var(--t-sm); }

/* THE CHILD'S OWN SECTIONS. Eight destinations is too many for a line of
   pills: they wrapped ragged on a desktop and ran to three uneven rows on a
   phone, which is exactly where a parent reads this. A grid instead -- the
   columns are whatever fits, every pill is the same width, and every row is
   full. The back link stays above it, because going up is not one of the
   eight. */
.childnav { display: grid; gap: var(--s-2); grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr)); }
.childnav .btn { justify-content: flex-start; }

/* A child, as a card you tap. The whole card is the target: a parent on a
   phone should not have to find a link inside it. */
.kid {
  display: flex; align-items: center; gap: var(--s-4);
  padding: var(--s-5); border-radius: var(--r-lg);
  background: var(--surface); border: 1px solid var(--line);
  box-shadow: var(--shadow-sm); color: inherit;
}
.kid:hover { text-decoration: none; border-color: var(--accent-line); background: var(--surface-2); }
.kid__id { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.kid__name { font-family: var(--display); font-size: var(--t-lg); font-weight: 700; letter-spacing: -.015em; }
.kid__where { color: var(--muted); font-size: var(--t-sm); }
.kid .avatar { width: 3rem; height: 3rem; font-size: var(--t-md); border-radius: var(--r-lg); }
.kid .icon { color: var(--faint); flex: none; }

@media (max-width: 40rem) {
  .portal__bar { padding: var(--s-3) var(--s-4); }
  .portal__nav { padding: 0 var(--s-4); }
  .portal__page { padding: var(--s-5) var(--s-4) var(--s-10); }
  /* THE NAME ONLY, AND BY DIRECT CHILD. Written as a descendant selector it
     also matched `.bell__count` inside the bell's own <a> -- so the unread
     badge was hidden on exactly the device a parent reads this on, while the
     bell itself stayed. (Found 2026-10-15.) */
  .portal__who > span { display: none; }

  /* THE SECOND ROW IS DELIBERATE HERE. There is no width on a phone for the
     school's name and three controls, so the controls take a row of their
     own -- but a full one, spread edge to edge, rather than a huddle pushed
     to the right under an empty half-row. */
  .portal__who { width: 100%; margin-left: 0; justify-content: space-between; }
  .portal__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  /* Two columns of sections, four full rows -- not three ragged ones. */
  .childnav { grid-template-columns: repeat(2, 1fr); }
}

/* ── 6b. The gallery (slice U7b) ────────────────────────────────────────────
   One grid, both shells. A school's photographs and a family's are the same
   pictures; two layouts would be two things to keep straight and the one that
   would rot is the family's, which is the side nobody at the school opens.

   `auto-fill` with a minimum rather than a fixed column count, so a phone gets
   two and a laptop gets five without a media query — and a single photograph
   does not stretch across the page, which is what `minmax` with `1fr` would
   do on its own. */
.photogrid {
  display: grid; gap: var(--s-3);
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
}
.photo {
  margin: 0; display: flex; flex-direction: column; gap: var(--s-2);
  background: var(--surface-2); border: 1px solid var(--line);
  border-radius: var(--r); overflow: hidden; padding-bottom: var(--s-2);
}
.photo__img {
  display: block; width: 100%; height: 9rem; object-fit: cover; background: var(--sunken);
}
.photo__cap { font-size: var(--t-sm); color: var(--muted); padding: 0 var(--s-3); }
.photo .rowedit { padding: 0 var(--s-3); }

/* The picture, shown in place. A <dialog>, so the browser handles the focus
   trap and the escape key rather than a script pretending to. */
.lightbox {
  border: 0; border-radius: var(--r-lg); padding: var(--s-3);
  background: var(--surface); color: var(--ink);
  max-width: min(92vw, 60rem); max-height: 92vh;
}
.lightbox::backdrop { background: rgba(0, 0, 0, .72); }
.lightbox__img { display: block; max-width: 100%; max-height: 80vh; margin: 0 auto; }
.lightbox__close { margin-top: var(--s-3); }

/* ── 7. Utilities (kept short on purpose) ───────────────────────────────── */
.hidden { display: none !important; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ── 8. Print ───────────────────────────────────────────────────────────── */
/* Schools print. Receipts, marksheets, registers — all of it ends up on
   paper, so the shell gets out of the way rather than being photocopied. */
@media print {
  .sidebar, .topbar__actions, .drawer-open, .impersonation, .toolbar { display: none !important; }

  /* AND THE CONSOLE'S OWN BAR, which none of the rules above named because
     every one of them was written for a school. (Slice Z2c.)

     The first document this business issues in its own name is a platform
     invoice, and it prints from the console — where a fixed violet bar across
     the top reads

         Platform console · You are outside every school. Changes here affect
         customers.

     That sentence is exactly right on a screen and is an internal note about
     the customer, printed across the top of the invoice being sent TO them.
     It is the same shape as `printable` and `no-print` themselves: a rule
     written for one shell, in a stylesheet a second shell also loads.

     `.console__head` goes with it, for the reason the `printable` block below
     already gives about `.topbar`: the software's own page heading is a
     breadcrumb, and an invoice carries its own letterhead. Printed, it put the
     words "Draft invoice" above a document whose whole job is to say what it
     is. */
  .console__bar,
  .console__head { display: none !important; }
  .shell { grid-template-columns: 1fr; }
  .card, .tile { box-shadow: none; border-color: #ccc; break-inside: avoid; }
  body { background: #fff; }
  .page { padding: 0; }

  /* Anything a printer has no business reproducing. Buttons, "back to the
     child" links, the hint under a form, the green banner confirming what was
     just saved — all of it is screen furniture, and all of it was coming out
     on the paper because these two classes were written into the views and
     never defined here. A class that nothing implements is indistinguishable,
     from the view's side, from one that works. */
  .no-print,
  .alert,
  .btn,
  .field__hint,
  .drawer-toggle,
  .rowactions,
  details.rowedit { display: none !important; }

  /* ── A DOCUMENT PAGE ──────────────────────────────────────────────────
     `printable` means "this card IS the document". The app's own heading goes
     with the rest of the chrome — a receipt carries its own letterhead and
     does not want the software's breadcrumb printed above it.

     Everything else that should not print says so with `no-print`, rather
     than this trying to guess. A clever selector that hides "every card that
     is not the printable one" also hides the warning that says the receipt
     was reversed, which is the one thing on the page a family most needs to
     see on paper. */
  .topbar { display: none !important; }

  .printable {
    border: 1px solid #000;
    border-radius: 0;
    margin: 0;
    width: 100%;
    max-width: 100%;
    color: #000;
    background: #fff;
  }

  .printable .card__head,
  .printable .card__body,
  .printable .card__foot { border-color: #000; }

  /* The letterhead is two blocks: the school on the left, the receipt's own
     identity on the right. The screen grid does not survive into print media
     on its own, and without this the receipt number lands against the school
     name and the whole head reads as one cramped paragraph. */
  .printable .card__head {
    display: flex; justify-content: space-between; align-items: flex-start; gap: 24px;
  }
  .printable .card__head .right { text-align: right; }
  .printable .card__head .stat { font-size: 1rem; margin: 2px 0; }
  .printable .card__foot { background: none; }

  .printable .muted,
  .printable .card__sub { color: #333; }

  /* A table on paper is ruled. On screen the rows are separated by tone,
     which a mono printer renders as nothing at all and the receipt becomes a
     column of floating numbers. */
  .printable .table { width: 100%; border-collapse: collapse; }
  .printable .table th,
  .printable .table td { border: 1px solid #000; padding: 6px 8px; }
  .printable .table thead th { background: #eee; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
  .printable .table-wrap { overflow: visible; }

  /* Space for a rubber stamp and a signature, which is what a parent at a
     counter in India expects to see on a fee receipt. */
  .printable .sign {
    display: flex; justify-content: space-between; gap: 32px;
    margin-top: 48px; padding: 0 8px 8px;
  }
  .printable .sign > div { border-top: 1px solid #000; padding-top: 4px; min-width: 180px; text-align: center; }

  @page { margin: 14mm; }
}

/* The signature block is print furniture and has no business on the screen,
   where the receipt is a record rather than a piece of paper. */
.sign { display: none; }

/* ---- Charts (slice S5) ----------------------------------------------------
   Server-drawn SVG, single colour. Every colour is a token, so the charts
   follow the theme and the school's accent like the rest of the page; the
   text wears ink tokens, never the bar colour. */
.chart-wrap { overflow-x: auto; }
.chart { width: 100%; max-width: 44rem; height: auto; display: block; }
.chart__bar   { fill: var(--accent); }
.chart__track { fill: var(--sunken); }
.chart__axis  { stroke: var(--line-2); stroke-width: 1; }
.chart__label { fill: var(--ink-2); font-size: 12px; }
.chart__value { fill: var(--ink); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.chart g:hover .chart__bar { fill-opacity: .8; }

/* ---------------------------------------------------------------------------
   A conversation with one family (slice U4b).

   TWO SIDES, TOLD APART BY POSITION AND GROUND, NOT BY COLOUR ALONE. A parent
   reading this on a phone in a corridor has to be able to see at a glance who
   said what, and a school with a green accent and a school with a red one both
   have to work -- so the school's own messages are inset and sit on the
   accent wash, and the other side sits on the plain surface at the full width.

   Every message keeps its author's name above it regardless, because "whose
   side of the screen it is on" is a convention and a name is a fact.
--------------------------------------------------------------------------- */
.msg {
  border: 1px solid var(--line);
  border-radius: var(--r);
  padding: var(--s-3) var(--s-4);
  background: var(--surface);
  margin-bottom: var(--s-3);
  max-width: 44rem;
}
.msg--mine {
  background: var(--accent-wash);
  border-color: var(--accent-line);
  margin-left: auto;
}
.msg__who {
  display: flex; gap: var(--s-2); align-items: baseline; flex-wrap: wrap;
  font-size: var(--t-sm); margin-bottom: var(--s-2);
}
.msg__body { white-space: normal; overflow-wrap: anywhere; }

/* ---------------------------------------------------------------------------
   The bell, and the notifications behind it (slice U4c).

   THE COUNT IS NOT COLOUR ALONE. The pill carries a number, the link carries
   an accessible name that says how many, and `bell--ringing` only changes the
   icon's weight -- so the bell still reads as "something is waiting" to
   somebody who cannot tell the accent colour from the ink.
--------------------------------------------------------------------------- */
.bell {
  position: relative; display: inline-flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem; border-radius: var(--r);
  color: var(--ink-2); flex: none;
}
.bell:hover { background: var(--surface-2); color: var(--ink); }
.bell--ringing { color: var(--accent-ui); }
.bell__count {
  position: absolute; top: .1rem; right: .1rem;
  min-width: 1rem; padding: 0 .2rem; border-radius: var(--r-full);
  background: var(--accent); color: #fff;
  font-size: 10px; line-height: 1rem; font-weight: 700; text-align: center;
  font-variant-numeric: tabular-nums;
}

.notes { list-style: none; margin: 0; padding: 0; }
.note {
  display: flex; gap: var(--s-3); align-items: flex-start;
  padding: var(--s-3) var(--s-4);
  border-top: 1px solid var(--line);
}
.note:first-child { border-top: 0; }
.note--unread { background: var(--accent-wash); }
.note__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.note__title { font-weight: 600; }
.note__when { font-size: var(--t-sm); white-space: nowrap; }

@media (max-width: 40rem) {
  .note { flex-wrap: wrap; }
  .note__when { width: 100%; }
}

/* The card that asks for the browser's notification permission (slice U4d).
   It sits above a page rather than inside a list, so it gets the full card
   frame that `.note` does without -- and a wash rather than a border colour,
   because it is an offer and not a warning. */
.note--ask {
  border: 1px solid var(--line); border-radius: var(--r-2);
  background: var(--accent-wash);
  margin-bottom: var(--s-4);
  align-items: center;
}
.note__actions { display: flex; gap: var(--s-2); flex-shrink: 0; }

@media (max-width: 40rem) {
  .note--ask { align-items: flex-start; }
  .note__actions { width: 100%; }
}

/* THE TRIAL NOTICE. (Phase 8, slice Z2a.)

   Shares the support-session bar's shape and NOT its colour, deliberately.
   That bar is amber because somebody standing in for a user must be unable to
   forget it; this one is a slate note about money, seen only by whoever runs
   the school, and it must not read as an alarm about the product. Nothing has
   stopped working, and the colour should not suggest otherwise. */
.impersonation--notice { background: #334155; }
.impersonation--notice .btn { color: #334155; }

/* ---------------------------------------------------------------------------
   Public pages — the admission form, certificate verification, and a school's
   privacy notice. (Styled in Z28.)

   These three are the only pages a person reaches with no account, and until
   now they carried class names nothing in this file defined: the markup said
   `public__wrap` and the browser rendered a column of unstyled text the full
   width of a desktop screen. It was never wrong, and it was the first thing a
   parent saw of a school that had spent money on this.

   Deliberately plain and deliberately narrow. A privacy notice is read, not
   navigated, and a measure of about seventy characters is the difference
   between a wall and a page.
--------------------------------------------------------------------------- */
.public { background: var(--bg); color: var(--ink); }

.public__wrap {
  max-width: 44rem; margin: 0 auto; padding: var(--s-8) var(--s-5) var(--s-12);
}

.public__head { margin-bottom: var(--s-8); }

.public__school {
  margin: 0 0 var(--s-2); color: var(--muted);
  font-size: .875rem; letter-spacing: .04em; text-transform: uppercase;
}

.public__foot {
  margin-top: var(--s-8); padding-top: var(--s-5);
  border-top: 1px solid var(--line);
}

/* Long-form prose, used by the privacy notice. Nothing here targets an
   element the notice renderer cannot produce -- p, h2, ul, li -- because a
   rule for an element that can never appear is a rule nobody can test. */
.prose { max-width: 40rem; }
.prose p  { margin: 0 0 var(--s-4); line-height: 1.7; }
.prose h2 {
  margin: var(--s-8) 0 var(--s-3); font-size: 1.125rem; line-height: 1.4;
}
.prose h2:first-child { margin-top: 0; }
.prose ul { margin: 0 0 var(--s-4); padding-left: var(--s-5); }
.prose li { margin-bottom: var(--s-2); line-height: 1.7; }

/* The rest of the grammar `LegalDocument` emits (slice Z31). The privacy
   notice's renderer produces only h2/h3/ul/li/p/strong, so everything below is
   for the Terms and the DPA -- which use sub-headings, horizontal rules,
   quoted statute and small tables. Unstyled, those render as browser defaults
   in the middle of a styled page, which reads as a page that failed to load
   its stylesheet rather than as a document. */
.prose h3 { margin: var(--s-6) 0 var(--s-2); font-size: 1rem; line-height: 1.4; }
.prose h4 { margin: var(--s-5) 0 var(--s-2); font-size: .9375rem; line-height: 1.4; }
.prose hr {
  margin: var(--s-6) 0; border: 0; border-top: 1px solid var(--line);
}
.prose blockquote {
  margin: 0 0 var(--s-4); padding: var(--s-3) var(--s-4);
  border-left: 3px solid var(--line); background: var(--surface-2);
  line-height: 1.7;
}
.prose table {
  width: 100%; margin: 0 0 var(--s-4);
  border-collapse: collapse; font-size: .9375rem;
}
.prose th, .prose td {
  padding: var(--s-2) var(--s-3); text-align: left; vertical-align: top;
  border-bottom: 1px solid var(--line); line-height: 1.6;
}
.prose th { font-weight: 600; }

/* A DRAFT DOCUMENT, SAID IN THE BACKGROUND. (Slice Z33.)

   BELT AND BRACES, AND THIS IS THE BRACES. The document's own first paragraph
   says "DRAFT — NOT FOR SIGNATURE", in the text, where it survives printing,
   copy-and-paste, a theme change and a later layout edit. This is the part a
   reader notices from across a desk.

   TILED, AND THE FIRST VERSION WAS NOT. It centred one word with flexbox inside
   the card holding the document — so on a sixteen-page contract the watermark
   was a single faint word somewhere around page eight, invisible on the screen
   anybody actually looks at. The comment beside it claimed it was "repeated on
   every screenful", which it was not: a comment describing something that was
   never built is the defect this project has now found in its own work more
   than once.

   A repeating background image tiles by construction, down a card of any
   height, with no measurement and nothing to keep in step. It is an inline SVG
   data URI in this stylesheet rather than an inline `style` attribute, so the
   Content-Security-Policy is untouched (gate 30).

   `print-color-adjust` because browsers drop background painting when printing
   by default — which would take the whole watermark off the paper, which is the
   one place it is most needed. */
:root { --wm-opacity: .07; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --wm-opacity: .11; }
}

:root[data-theme="dark"] { --wm-opacity: .11; }

.watermark { position: relative; overflow: hidden; }
.watermark > * { position: relative; z-index: 1; }
.watermark::before {
  content: "";
  position: absolute; inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: var(--wm-opacity);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='340' height='200'%3E%3Ctext x='170' y='110' font-family='Helvetica,Arial,sans-serif' font-size='54' font-weight='700' letter-spacing='6' fill='%23b42318' text-anchor='middle' transform='rotate(-24 170 110)'%3EDRAFT%3C/text%3E%3C/svg%3E");
  background-repeat: repeat;
}

@media print {
  .watermark::before {
    opacity: .1;
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
}

.portal__foot {
  margin-top: var(--s-8); padding: var(--s-5);
  border-top: 1px solid var(--line); font-size: .875rem;
}

/* ─── The "find your school" box on /where ───────────────────────────────── */
/* The domain sits INSIDE the field rather than under it, so the box teaches
   the shape of the answer while it asks the question. The person using this
   page is the one person who does not know that shape. */

.finder { display: grid; gap: var(--s-2); margin-block: var(--s-5); }
.finder__label { font-size: var(--t-sm); font-weight: 600; color: var(--ink); }

.finder__box {
  display: flex; align-items: stretch;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
  overflow: hidden;
}
/* The whole box takes the focus ring, not the bare input inside it, because
   what the eye reads as "the field" is the box. */
.finder__box:focus-within { outline: 2px solid var(--accent-ui); outline-offset: 1px; border-color: var(--accent-ui); }
.finder__box--bad { border-color: var(--danger, #dc2626); }

.finder__box input {
  flex: 1 1 auto; min-width: 0;        /* min-width:0 or a long paste pushes the suffix off the end */
  border: 0; background: none; outline: none;
  padding: .8rem .9rem;
  font: inherit; font-size: var(--t-lg); font-weight: 600;
  color: var(--ink);
}
.finder__suffix {
  flex: none; display: flex; align-items: center;
  padding-right: .6rem;
  color: var(--muted); font-size: var(--t-md);
}
.finder__go { flex: none; border-radius: 0; padding-inline: 1.1rem; }

.finder__error {
  display: flex; align-items: flex-start; gap: .45em;
  font-size: var(--t-sm); color: var(--danger, #dc2626);
}

/* On a phone the suffix and the button cannot share a line with the input
   without squeezing the input to nothing, so the button goes underneath. */
@media (max-width: 30rem) {
  .finder__box { flex-wrap: wrap; }
  .finder__box input { flex: 1 1 60%; }
  .finder__go { flex: 1 1 100%; border-radius: 0 0 11px 11px; }
}
