/* The shell's design system.
 *
 * Everything before this was semantic markup with a hundred lines of styling on
 * top — the right base, deliberately, because an accessible interface is built
 * on real labels and a real focus order and none of that has to be undone. But
 * a base is not an experience, and a pilot that looks like a prototype is read
 * as one by the students and by the institutions both.
 *
 * Three rules hold the whole thing together:
 *
 *   1. Every colour, size and space is a custom property. Nothing below the
 *      token block names a hex or a pixel that could have been a token. That is
 *      what makes per-institution branding a stylesheet at /brand.css that
 *      overrides four properties, instead of a fork of this file.
 *
 *   2. Both themes are defined together, in one place, from `prefers-color-scheme`.
 *      A theme added later as an afterthought is a theme that is wrong on the
 *      screens nobody remembered.
 *
 *   3. Contrast is a property of the tokens, not of each rule. The pairs below
 *      clear WCAG 2.2 AA and a test asserts it, so a component author cannot
 *      accidentally fail it by choosing `--muted` on `--surface`.
 *
 * No inline styles anywhere: the app origin's CSP forbids them, which is what
 * makes that policy worth having.
 */

/* ── tokens ──────────────────────────────────────────────────────────────────
 *
 * Day and Night, carried over from Polygon Trace so the two products read as
 * one family: `Palette.cool` in PolygonGlass/Theme/Theme.swift is the source,
 * and the hexes below are its `canvas`, `card`, `sheet`, `title`, `body` and
 * `meta` pairs.
 *
 * Three values are deliberately not the Swift's. `meta` is #8a909c there, which
 * is 2.98:1 on this paper — legible on a tablet held at arm's length, and a
 * WCAG failure for the caption under every figure on a laptop in a lecture
 * hall. It is moved down the same hue until it clears AA, in both themes, and
 * the same is true of the two hairlines: the app draws those as an alpha tint
 * over the surface, which CSS could do too, but a solid value is what the
 * contrast test can read out of this file and hold to 3:1.
 *
 * The accent is Cobalt (#4F7CFF), the app's default, corrected for text the
 * same way an institution's colour is — see lib/brand.js.
 * ------------------------------------------------------------------------- */

:root {
  color-scheme: light dark;

  /* Paper, ink, and the two steps between. `--surface` is what sits *on* paper
     — a card, a form — and is deliberately barely different: a page of boxes
     with strong edges reads as an application, and this is a place to read. */
  --paper:    #f3f7fb;
  --surface:  #ffffff;
  --sunken:   #eef3f8;
  --ink:      #1a1f29;
  --ink-2:    #5b6470;
  /* The palette's `meta` is #8a909c, which is 2.98:1 on this paper. Same hue,
     moved down until it clears AA — a caption nobody can read is not a caption,
     and this is text rather than a hairline. */
  --muted:    #686f7b;
  --rule:     #dde5ee;
  /* Darker than it looks like it wants to be, and deliberately. A field border
     is not decoration — it is the only thing saying where the box is, so 1.4.11
     asks it for 3:1 against the surface behind it. The first value here was
     1.63:1: a form that looks tidy and, to somebody with low vision on a bright
     screen, has no boxes on it at all.
     Neutral rather than warm. The first value that cleared 3:1 was a tan, taken
     by darkening the warm hairline above it — correct, and it read as a border
     from a different decade sitting around every input on the platform. This is
     the same contrast on the grey axis. */
  --rule-2:   #8296b5;

  /* The bar that says you are previewing somebody else's colours.
   *
   * Its own pair of tokens, and the same values under both themes, which is
   * the one place in this file that is deliberate rather than an oversight.
   * This bar is the platform speaking *about* a theme, so it must not be
   * wearing one: in the institution's colours it reads as part of the design
   * being previewed rather than as a frame around it, and in the page's own
   * surface colours it disappears into the header. Constant and slightly
   * foreign is the point.
   *
   * 12.5:1 — chrome that carries a way out of a mode has no excuse to sit at
   * the minimum. */
  --chrome-bar:     #2b2f38;
  --chrome-bar-ink: #f4f6f9;

  /* Set by /brand.css per institution; these are the platform's own. Both are
     also declared here so the app is complete without that request — a
     stylesheet that fails to load must not take the colours with it. */
  --brand:      #4f7cff;
  /* Black, not white, and the platform did not choose it — `inkOn()` in
     brand.js did, and it is right: white on Cobalt is 3.71:1, which passes for
     large text and fails for a 14px button label. Every institution's fill is
     decided the same way, so the default is not a special case. */
  --brand-ink:  #000000;
  /* The same colour, moved until it clears 3:1 against the page. A filled
     control needs a boundary (1.4.11) and this is it — so the fill can stay
     exactly the colour an institution gave us. */
  --brand-edge: #4f7cff;
  --accent:     #2b61ff;

  /* Said in words, so a component asks for a meaning rather than a colour. */
  --good:  #1b7b54;
  --warn:  #8a6112;
  --bad:   #c0342a;
  --good-wash: #e6f4ee;
  --warn-wash: #f9f1e2;
  --bad-wash:  #fbecea;

  /* A fourth-based scale. Enough steps to compose with, few enough that two
     things a designer would want the same size cannot end up one step apart. */
  --text-xs: 0.75rem;
  --text-sm: 0.875rem;
  --text:    0.9375rem;
  --text-lg: 1.0625rem;
  --text-xl: 1.375rem;
  --text-2xl: 1.75rem;
  --text-3xl: 2.25rem;

  --lead-tight: 1.15;
  --lead-snug:  1.35;
  --lead:       1.55;

  /* Headings set tight; small text set loose. One tracking value per end of the
     scale, rather than a guess per rule. */
  --track-tight: -0.021em;
  --track-wide:   0.02em;

  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --space-6: 2rem;
  --space-7: 3rem;

  /* Polygon Trace's: card 12, field 10, chip 8. The web has no sheet, so the
     scale stops at three. */
  --radius:     12px;
  --radius-sm:  10px;
  --radius-xs:   8px;
  --radius-full: 999px;

  /* One duration and one curve. Every transition in the file uses them, so
     "does this feel right" is one number to change rather than nine. */
  --ease: cubic-bezier(0.32, 0.72, 0, 1);
  --quick: 130ms;

  /* Two, and they are the whole set. A shadow is elevation, and this interface
     has exactly two levels: on the page, and above it. */
  /* Barely there, and layered rather than blurred: a single soft shadow reads
     as a smudge at small sizes, where a hairline plus a short drop reads as an
     edge. */
  --shadow:    0 1px 1px rgba(16, 19, 23, .04), 0 2px 6px rgba(16, 19, 23, .04);
  --shadow-up: 0 1px 1px rgba(16, 19, 23, .04), 0 12px 32px rgba(16, 19, 23, .10);

  /* System faces, tuned rather than replaced. A webfont on the shell is a
     render-blocking request on every page for text that is mostly labels and
     names — and the reader's own typography, which is the part that matters, is
     set inside the frame by the section's stylesheet. */
  --serif: ui-serif, "Iowan Old Style", "Palatino Linotype", Georgia, serif;
  --sans:  ui-sans-serif, system-ui, -apple-system, "Segoe UI Variable Text",
           "Segoe UI", Inter, Helvetica, Arial, sans-serif;
  --mono:  ui-monospace, SFMono-Regular, "Cascadia Mono", Menlo, Consolas, monospace;

  /* The reading measure for the shell's own prose. The reader's column is the
     section's business and is set inside the frame. */
  --measure: 38rem;
  --measure-wide: 74rem;
  --gutter: clamp(1rem, 4vw, 2rem);
}

/* Night, chosen two ways.
 *
 * The media query is the OS setting; `[data-theme="night"]` is the reader
 * saying so on this platform specifically. Both have to outrank the Day block
 * above, which is a plain `:root` at (0,1,0) — so both carry an attribute
 * selector and land at (0,2,0), and the explicit one wins over the media query
 * by being later in the file.
 *
 * These were briefly wrapped in `:where()`, on the theory that equal
 * specificity would let source order decide. It does — but `:where()` is zero,
 * so both lost to `:root` instead, and pressing Night set an attribute that
 * changed nothing. Caught by clicking the button in a browser and reading the
 * computed background, which is the only place that particular mistake is
 * visible.
 *
 * `:not([data-theme="day"])` is what lets a reader choose Day on a machine set
 * to dark: it takes the media query out of the running entirely rather than
 * relying on the Day block below it, which does not exist. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="day"]) {
    --paper:   #10171f;
    --surface: #1d2530;
    --sunken:  #161d28;
    --ink:     #f3f5f8;
    --ink-2:   #9ba1ad;
    --muted:   #868c96;
    --rule:    #2a3542;
    --rule-2:  #657180;

    --brand:      #4f7cff;
    --brand-ink:  #000000;
    --brand-edge: #4f7cff;
    /* Cobalt, lifted until it clears AA on the *card* as well as the page — a
       link inside a panel sits on --surface, which is lighter than --paper and
       is therefore the harder of the two. */
    --accent:     #5984ff;

    --good: #36b37e;
    --warn: #e0a33e;
    --bad:  #e5604d;
    --good-wash: #12261f;
    --warn-wash: #272012;
    --bad-wash:  #2b1a18;

    --shadow:    0 1px 1px rgba(0, 0, 0, .3), 0 2px 6px rgba(0, 0, 0, .25);
    --shadow-up: 0 1px 1px rgba(0, 0, 0, .3), 0 14px 36px rgba(0, 0, 0, .45);
  }
}

/* The same values again, for the reader who chose Night on a machine that is
   not. Repeated rather than shared, because CSS has no way to name a block and
   apply it under two conditions — and a preprocessor to avoid typing it twice
   is a build step for one duplicated list. A test asserts the two agree. */
:root[data-theme="night"] {
  --paper:   #10171f;
  --surface: #1d2530;
  --sunken:  #161d28;
  --ink:     #f3f5f8;
  --ink-2:   #9ba1ad;
  --muted:   #868c96;
  --rule:    #2a3542;
  --rule-2:  #657180;

  --brand:      #4f7cff;
  --brand-ink:  #000000;
  --brand-edge: #4f7cff;
  --accent:     #5984ff;

  --good: #36b37e;
  --warn: #e0a33e;
  --bad:  #e5604d;
  --good-wash: #12261f;
  --warn-wash: #272012;
  --bad-wash:  #2b1a18;

  --shadow:    0 1px 1px rgba(0, 0, 0, .3), 0 2px 6px rgba(0, 0, 0, .25);
  --shadow-up: 0 1px 1px rgba(0, 0, 0, .3), 0 14px 36px rgba(0, 0, 0, .45);
}

/* So a form control, a scrollbar and the flash before first paint match the
   theme the page is actually in rather than the one the OS is in. */
:root[data-theme="day"]   { color-scheme: light; }
:root[data-theme="night"] { color-scheme: dark; }

/* ── the page ────────────────────────────────────────────────────────────── */

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

html {
  -webkit-text-size-adjust: 100%;
  /* An anchor jump lands under the sticky header without this. */
  scroll-padding-top: 5rem;
}

body {
  margin: 0;
  padding: 0 0 var(--space-7);
  background: var(--paper);
  color: var(--ink);
  font: var(--text)/var(--lead) var(--sans);
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

main {
  max-width: var(--measure);
  margin-inline: auto;
  padding-inline: var(--gutter);
  padding-top: var(--space-6);
}

/* A page holding a section is a different kind of page, and it is the only one
   with no measure at all.
 *
 * The section brings its own column and the reader sets its width in the
 * section's own reading options — anything from 48 to 90 characters. A ceiling
 * out here silently caps that: the control moves a number and, past the cap,
 * changes nothing on the screen. It was 74rem, which is generous and still a
 * cap, and on a wide monitor it left the reader in a letterboxed strip with the
 * width control doing nothing.
 *
 * So the frame gets the window, less a gutter, and the *content* inside it
 * stays as narrow or wide as the reader asked for. Scaling both up and down
 * then falls out of it having no fixed dimension at all.
 *
 * `:has()` rather than a class on <body>, because `page()` takes no such
 * argument and adding one would put a layout decision in every caller's hands.
 * A browser without it falls back to the narrow measure — worse, not broken. */
main:has(> .section) {
  max-width: none;
  padding-inline: var(--space-3);
}

/* A console is not prose either.
 *
 * `--measure` is 38rem because that is where a paragraph is comfortable, and
 * every page on this platform inherited it — including the ones that are a list
 * of people with a name, an email, two badges and a control on each row. At
 * 38rem that row has about 100 pixels for the name, so "Jun-Ray Macairan" wraps
 * in the middle of itself and the address beside it truncates to nothing, with
 * two-thirds of a 1100-pixel window empty on the right.
 *
 * The tab strip is the signal: it is on the admin console and on nothing else,
 * so a page that has one is a page of rows rather than sentences and gets the
 * wide measure. Same `:has()` reasoning as the reader above — `page()` takes no
 * layout argument, and a browser without support falls back to the narrow
 * measure, which is worse rather than broken. */
main:has(> .tabs) { max-width: var(--measure-wide); }

/* The furniture around the frame keeps a measure: a breadcrumb and a
   prev/next pair stretched across a 27-inch monitor is two words at one edge
   and two at the other. */
main:has(> .section) > :not(.section) {
  max-width: var(--measure-wide);
  margin-inline: auto;
}

/* ── type ────────────────────────────────────────────────────────────────── */

h1, h2, h3 { letter-spacing: var(--track-tight); }

/* The one editorial note in an otherwise plain interface, and it is on purpose:
   the page title is the only serif on the screen. It says courseware rather
   than dashboard in the half second before anybody reads a word of it. */
h1 {
  font-family: var(--serif);
  font-size: var(--text-3xl);
  font-weight: 600;
  line-height: var(--lead-tight);
  margin: 0 0 var(--space-2);
  /* Two words alone on the second line is what a long course title does by
     default. This is one property and it fixes every heading on the platform. */
  text-wrap: balance;
}

h2 {
  font-size: var(--text-xl);
  font-weight: 620;
  line-height: var(--lead-snug);
  margin: var(--space-7) 0 var(--space-3);
}

h3 {
  font-size: var(--text-lg);
  font-weight: 600;
  line-height: var(--lead-snug);
  margin: var(--space-5) 0 var(--space-2);
}

/* The line under a page title: term, instructor, count. Directly after the h1
   so it reads as part of it. */
h1 + p { color: var(--muted); margin-top: calc(var(--space-2) * -1); }

p { margin: 0 0 var(--space-4); text-wrap: pretty; }

a { color: var(--accent); text-underline-offset: .18em; text-decoration-thickness: from-font; }
a:hover { text-decoration-thickness: 2px; }

code, kbd {
  font-family: var(--mono);
  font-size: 0.875em;
  background: var(--sunken);
  border-radius: var(--radius-xs);
  padding: 0.1em 0.35em;
}

blockquote {
  margin: var(--space-3) 0;
  padding-left: var(--space-4);
  border-left: 2px solid var(--rule-2);
  color: var(--ink-2);
}

hr { border: 0; border-top: 1px solid var(--rule); margin: var(--space-6) 0; }

/* Read aloud, never drawn. The clip-rect form rather than `display: none`,
   which removes it from the accessibility tree along with the screen. */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

.muted { color: var(--muted); }
.small { font-size: var(--text-sm); }

/* Visible in every theme and never removed — 2.4.11. Two rings, so it shows
   whether the element sits on paper or on a brand-coloured fill. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius-xs);
}

/* Off-screen until focused: the first tab on every page reaches it. */
.skip-link {
  position: fixed;
  left: var(--gutter);
  top: -4rem;
  z-index: 20;
  padding: var(--space-2) var(--space-4);
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--rule-2);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-up);
  text-decoration: none;
  transition: top var(--quick) var(--ease);
}
.skip-link:focus { top: var(--space-3); }

/* ── the header ──────────────────────────────────────────────────────────── */

/* Sticky, thin, and translucent. Sticky because the way back is the control
   somebody reaches for from the middle of an eight-thousand-pixel section, and
   a header they have to scroll to the top to reach is a header they scroll past
   twice. */
body > header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: color-mix(in srgb, var(--paper) 82%, transparent);
  border-bottom: 1px solid var(--rule);
}

@supports (backdrop-filter: blur(1px)) {
  body > header { backdrop-filter: saturate(180%) blur(12px); }
}

/* A browser without color-mix gets the flat colour rather than a transparent
   bar with text scrolling through it. */
@supports not (background: color-mix(in srgb, red 50%, transparent)) {
  body > header { background: var(--paper); }
}

/* The wide measure, on every page, and this reverses a decision made two
   commits ago — worth saying why rather than leaving it looking like drift.
 *
 * Back then the header was a bare flex row: no background, no border, links
 * floating at the far right of an empty band above a narrow column. At 74rem
 * that read as two unrelated designs stacked, and matching the column fixed it.
 *
 * It is a real bar now — sticky, bordered, its own surface — and a bar is
 * chrome rather than part of the column. Constrained to 38rem the navigation
 * overflowed its own container, because six links and a name do not fit in six
 * hundred pixels; and constraining chrome to the reading measure is what makes
 * an interface look like a document that grew buttons. */
body > header > * {
  max-width: var(--measure-wide);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.bar {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  min-height: 3.25rem;
}

.wordmark {
  font-family: var(--serif);
  font-size: var(--text-lg);
  font-weight: 600;
  letter-spacing: var(--track-tight);
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
}

/* Pushes everything after it to the right. On a phone the nav wraps to its own
   line underneath instead, which is what the second rule is for. */
/* `min-width: 0` so the list may shrink and scroll rather than pushing the
   name off the end of the bar — a flex item refuses to go below its content
   width without it, which is the whole of why the header used to overflow. */
.bar nav { margin-left: auto; min-width: 0; }

.bar nav ul {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  list-style: none;
  margin: 0;
  padding: 0;
  overflow-x: auto;
  scrollbar-width: none;
  /* A fade at the right edge, so a list that continues past the screen looks
     like it continues rather than like it was cut off. Masked rather than
     drawn, so it works over any background including the translucent bar. */
  /* `black` is opacity here, not colour — a mask's channel is alpha, and there
     is no token for "opaque". The no-hard-coded-colour rule is about anything
     that paints, and nothing here does. */
  -webkit-mask-image: linear-gradient(to right, black calc(100% - 2.5rem), transparent);
  mask-image: linear-gradient(to right, black calc(100% - 2.5rem), transparent);
}
.bar nav ul::-webkit-scrollbar { display: none; }

/* Nothing to fade when nothing is cut off. */
@media (min-width: 52rem) {
  .bar nav ul { -webkit-mask-image: none; mask-image: none; overflow: visible; }
}

.bar nav a {
  display: block;
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  color: var(--ink-2);
  font-size: var(--text-sm);
  font-weight: 500;
  text-decoration: none;
  white-space: nowrap;
  transition: background var(--quick) var(--ease), color var(--quick) var(--ease);
}
.bar nav a:hover { background: var(--sunken); color: var(--ink); }

/* A name, not a destination. Set apart with a rule so it does not read as a
   seventh link in a row of six — which it did, being the same size and sitting
   in the same line. */
/* The reader's own name, and the way to their account. A link now rather than
   a label beside one — it was already the thing people aimed at. */
.bar .who {
  flex: none;
  margin: 0;
  padding: var(--space-1) var(--space-2);
  margin-left: var(--space-3);
  border-left: 1px solid var(--rule);
  border-radius: var(--radius-xs);
  font-size: var(--text-sm);
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
}

.bar .who:hover { color: var(--ink); background: var(--sunken); }

@media (max-width: 40rem) {
  /* Two rows, not three, and tight.
   *
   * It was taking three hundred pixels of an eight-hundred-pixel phone before
   * a word of courseware — a third of the screen, on the device most students
   * read on, spent on furniture. Nothing was wrong with any single rule; the
   * wordmark, the nav and the name each wrapped onto a line of their own and
   * each kept its desktop padding.
   *
   * So: the wordmark and the account share the first row, the nav has the
   * second, and the vertical padding halves. The theme switch goes up beside
   * the name, where it is a 32px target rather than a third row's worth of
   * space. */
  .bar {
    flex-wrap: wrap;
    min-height: 0;
    padding-block: var(--space-2);
    row-gap: var(--space-2);
  }
  .bar .wordmark { flex: 1 1 auto; }
  .bar .who { border-left: 0; margin-left: 0; order: 2; }
  .theme { order: 3; }
  /* Full width and first in reading order after the two above, so the links
     are a row of their own rather than wrapping around the name. */
  .bar nav { order: 4; width: 100%; margin-left: 0; }
  /* And they wrap rather than scrolling off the edge. The list scrolls
     horizontally by design on a wide screen, where it never needs to; at 390px
     with five links it put "Feedback" half off the screen and "Admin" beyond
     it, with no indication either was there. A hidden scrollbar and a cut word
     is a link nobody knows exists. */
  .bar nav ul { flex-wrap: wrap; overflow-x: visible; row-gap: var(--space-1); }
}

/* Spans the header rather than sitting in the row: it is about the whole
   account, not about one link. */
body > header > .notice { margin: 0 auto var(--space-3); }

/* ── the doorway pages ───────────────────────────────────────────────────────
 *
 * Sign in, sign up, the second factor, forgotten password, a link that expired.
 * They are a different kind of page from the rest of the platform: one thing to
 * do, nothing to navigate to, and often the first screen anybody sees.
 *
 * Detected by the absence of the account navigation, which is not a trick — a
 * page with no way to your classes is a page you are not signed in to, and that
 * is exactly the set. `page()` takes no argument for this, so nothing has to be
 * remembered at seventeen call sites; a doorway page added tomorrow is one
 * automatically.
 */
body:not(:has(.bar nav)) main {
  max-width: 26rem;
  margin-block: min(11vh, 5rem) var(--space-7);
  padding: var(--space-6);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: var(--shadow-up);
}

body:not(:has(.bar nav)) main > h1 {
  font-size: var(--text-2xl);
  margin-bottom: var(--space-5);
}

/* A form in a card wants its last field flush with the button under it. */
body:not(:has(.bar nav)) main form > .field:last-of-type { margin-bottom: var(--space-5); }
body:not(:has(.bar nav)) main button { width: 100%; }
body:not(:has(.bar nav)) main form { margin-bottom: var(--space-4); }

/* A rule with a word in it, between two ways of doing the same thing. Without
   it the passkey button reads as the step after signing in rather than as
   instead of it. */
.or {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin: var(--space-4) 0;
  font-size: var(--text-sm);
  color: var(--muted);
}
.or::before, .or::after {
  content: "";
  flex: 1;
  border-top: 1px solid var(--rule);
}
body:not(:has(.bar nav)) main > p:last-child { margin-bottom: 0; }

/* On a phone the card is the screen, and a card the size of the screen is a
   border nobody asked for. */
@media (max-width: 30rem) {
  body:not(:has(.bar nav)) main {
    margin-block: var(--space-5) var(--space-7);
    padding: 0;
    background: none;
    border: 0;
    box-shadow: none;
    max-width: none;
  }
  body:not(:has(.bar nav)) main { padding-inline: var(--gutter); }
}

/* A browser without `:has()` gets the ordinary page: narrower than the reading
   measure would be, and entirely usable. */

/* ── cards ───────────────────────────────────────────────────────────────── */

.cards {
  list-style: none;
  margin: 0 0 var(--space-5);
  padding: 0;
  display: grid;
  gap: var(--space-3);
}

.card {
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  transition: border-color var(--quick) var(--ease),
              box-shadow var(--quick) var(--ease),
              transform var(--quick) var(--ease);
}

.card:has(.card__link[href]):hover {
  border-color: var(--rule-2);
  box-shadow: var(--shadow-up);
  transform: translateY(-1px);
}

.card__link {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-4) var(--space-5);
  color: inherit;
  text-decoration: none;
}

.card__icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--radius-sm);
  background: var(--sunken);
  font-size: var(--text-lg);
  line-height: 1;
}

.card__body { display: grid; gap: 0.125rem; min-width: 0; }

.card__title {
  font-weight: 600;
  color: var(--ink);
  letter-spacing: var(--track-tight);
}

.card__meta {
  font-size: var(--text-sm);
  color: var(--muted);
  /* A long instructor name plus a term must shorten rather than push the badge
     off the card. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Whatever a card carries after its link — a form, a QR, a set of controls. */
.card > form,
.card > .card__foot {
  padding: 0 var(--space-5) var(--space-4);
  margin: 0;
}

/* The code off a lecture slide. Set like the code itself — monospace, spaced,
   upper — so what is typed looks like what is on the screen at the front of the
   room, and a transposed character is visible before it is submitted.
 *
 * This was `#f-code`, which worked only because `field({ name: 'code' })`
 * happens to generate that id: style by coincidence of naming, invisible from
 * the call site, and gone the day a second code field is called anything else.
 * It is asked for now — `look: 'code'` — and the landing page asks for it too,
 * which is how the two ended up looking the same. */
.input--code {
  font-family: var(--mono);
  font-size: var(--text-xl);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  text-align: center;
  min-height: 3.25rem;
}
.input--code::placeholder { letter-spacing: normal; text-transform: none; }

/* ── pills ───────────────────────────────────────────────────────────────── */

.pill {
  flex: none;
  display: inline-block;
  padding: 0.15rem var(--space-2);
  border-radius: var(--radius-full);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  border: 1px solid transparent;
  white-space: nowrap;
}
.pill--neutral { background: var(--sunken); color: var(--muted); border-color: var(--rule); }
.pill--info    { background: var(--sunken); color: var(--accent); border-color: var(--rule); }
.pill--good    { background: var(--good-wash); color: var(--good); }
.pill--warn    { background: var(--warn-wash); color: var(--warn); }
.pill--bad     { background: var(--bad-wash);  color: var(--bad); }

/* ── empty states ────────────────────────────────────────────────────────── */

.empty {
  border: 1px dashed var(--rule-2);
  border-radius: var(--radius);
  padding: var(--space-7) var(--space-5);
  text-align: center;
  color: var(--muted);
  margin: 0 0 var(--space-5);
}
.empty__icon { font-size: 1.75rem; margin: 0 0 var(--space-2); line-height: 1; }
.empty__title { color: var(--ink); font-weight: 600; margin: 0 0 var(--space-1); }
.empty__body { margin: 0 auto var(--space-4); max-width: 28rem; }
.empty > p:last-child { margin-bottom: 0; }

/* ── lists that stayed lists ─────────────────────────────────────────────── */

main ul, main ol { padding-left: var(--space-5); }
main li { margin: var(--space-2) 0; }
main .cards li { margin: 0; }

/* The section list inside a unit: rows on a surface, not bullets on a page.
   A surface even at one row — a single unbordered line under a heading reads as
   a stray sentence rather than as the first item of a list. */
.toc {
  list-style: none;
  padding: var(--space-2);
  margin: 0 0 var(--space-5);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}
.toc li { margin: 0; border-bottom: 1px solid var(--rule); }
.toc li:last-child { border-bottom: 0; }
.toc a {
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
  padding: var(--space-3);
  border-radius: var(--radius-sm);
  color: var(--ink);
  text-decoration: none;
  font-weight: 500;
  transition: background var(--quick) var(--ease);
}
.toc a:hover { background: var(--sunken); }
/* Where the section sits, written rather than counted.
 *
 * It was a CSS counter reset on every `.toc`, and a book of thirteen units
 * with one section each renders thirteen lists — so every row was numbered
 * "1". The number a reader wants is which unit they are looking at, and only
 * the server knows that, so the server writes it. */
.toc__where {
  flex: none;
  min-width: 3.5rem;
  font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}
.toc__title { flex: 1 1 auto; min-width: 0; }

/* Smaller than a page heading, because it is a divider inside a list rather
   than a section of the page. Only rendered when a unit holds more than one
   section — see `contentsList` in app/read.js. */
.toc__unit {
  font-size: var(--text-md);
  margin: var(--space-5) 0 var(--space-2);
}
.toc .pill { margin-left: auto; }

/* ── forms ───────────────────────────────────────────────────────────────── */

.field { margin: 0 0 var(--space-5); border: 0; padding: 0; }

.field > label,
.field > legend {
  display: block;
  font-weight: 600;
  font-size: var(--text-sm);
  color: var(--ink);
  margin-bottom: var(--space-2);
  padding: 0;
}

.hint { font-size: var(--text-sm); color: var(--muted); margin: calc(var(--space-2) * -1) 0 var(--space-2); }
.error { font-size: var(--text-sm); color: var(--bad); margin: 0 0 var(--space-2); font-weight: 500; }

input[type="text"], input[type="email"], input[type="password"], input[type="tel"],
input[type="url"], input[type="number"], input[type="search"], input[type="date"],
select, textarea {
  width: 100%;
  min-height: 2.75rem;              /* 44px — 2.5.8, a target big enough to hit */
  padding: var(--space-2) var(--space-3);
  font: inherit;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule-2);
  border-radius: var(--radius-sm);
  transition: border-color var(--quick) var(--ease), box-shadow var(--quick) var(--ease);
}
textarea { min-height: 0; line-height: var(--lead); resize: vertical; }

input:focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: none; }
[aria-invalid="true"] { border-color: var(--bad); }

/* A radio or a checkbox with the whole label as its target — nobody aims at a
   14px circle, they aim at the words. */
.choice {
  display: flex;
  gap: var(--space-3);
  align-items: baseline;
  padding: var(--space-2) var(--space-3);
  margin: 0 0 var(--space-1);
  border-radius: var(--radius-sm);
  transition: background var(--quick) var(--ease);
}
.choice:hover { background: var(--sunken); }
/* `--accent`, not `--brand`: this is a small mark that has to be *seen*
   against the page, which is the contrast job `--accent` is corrected for.
   `--brand` is the fill colour and on a dark page it is a dark green dot on a
   dark grey field. */
.choice input { flex: none; min-height: 0; width: auto; accent-color: var(--accent); }
.choice label { font-weight: 400; cursor: pointer; }

button, .button {
  font: inherit;
  font-size: var(--text-sm);
  font-weight: 600;
  min-height: 2.75rem;
  padding: var(--space-2) var(--space-5);
  border-radius: var(--radius-sm);
  border: 1px solid var(--brand-edge);
  background: var(--brand);
  color: var(--brand-ink);
  cursor: pointer;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  transition: filter var(--quick) var(--ease), transform var(--quick) var(--ease);
}
button:hover, .button:hover { filter: brightness(1.07); }
button:active, .button:active { transform: translateY(1px); }

button.secondary {
  background: var(--surface);
  color: var(--ink);
  border-color: var(--rule-2);
}
button.secondary:hover { filter: none; background: var(--sunken); }

/* Really a link: it changes something on this page rather than going anywhere,
   so it must be a <button> for the keyboard and a screen reader, and look like
   a link because that is what it does. */
.linkish {
  border: 0; background: none; padding: 0; min-height: 0;
  font-size: inherit; font-weight: 400;
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: .18em;
  display: inline;
}
.linkish:hover { filter: none; background: none; text-decoration-thickness: 2px; }
.linkish:active { transform: none; }

/* ── saying something went wrong, or right ───────────────────────────────── */

.notice {
  margin: 0 0 var(--space-4);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-sm);
  border: 1px solid var(--rule);
  border-left: 3px solid var(--rule-2);
  background: var(--surface);
  font-size: var(--text-sm);
}
.notice > p { margin: 0; }
.notice > p + p { margin-top: var(--space-2); }
.notice--info    { border-left-color: var(--accent); }
.notice--success { border-left-color: var(--good); background: var(--good-wash); }
.notice--warning { border-left-color: var(--warn); background: var(--warn-wash); }

.error-summary {
  margin: 0 0 var(--space-5);
  padding: var(--space-4);
  border: 1px solid var(--bad);
  border-left-width: 3px;
  border-radius: var(--radius-sm);
  background: var(--bad-wash);
}
.error-summary h2 { margin: 0 0 var(--space-2); font-size: var(--text-lg); }
.error-summary ul { margin: 0; padding-left: var(--space-5); }
.error-summary li { margin: var(--space-1) 0; }
.error-summary a { color: var(--bad); }

/* ── the reader ──────────────────────────────────────────────────────────── */

/* The section arrives in a frame from the content origin, and static/reader.js
 * grows that frame to the height of its content so the page has one scroller
 * instead of two. `min-height` is what a reader gets when that conversation does
 * not happen: an edition published before the bridge existed, or JavaScript
 * turned off. Generous rather than exact, because a frame that guesses short
 * cuts a page in half and one that guesses long only adds white space.
 *
 * No border and no background of its own: at the height of a whole section a
 * border is a rule running down both sides of the page, and the section paints
 * its own paper — including in dark mode, where a white box would be a hole. */
.section {
  display: block;
  width: 100%;
  min-height: 78vh;
  border: 0;
  background: transparent;
}

.reader-nav {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-4);
  margin: var(--space-3) 0 var(--space-5);
  font-size: var(--text-sm);
}
.reader-nav + .reader-nav { margin: var(--space-5) 0 0; }
.reader-nav a { text-decoration: none; font-weight: 500; }
.reader-nav a:hover { text-decoration: underline; }

/* ── a code on a slide ───────────────────────────────────────────────────── */

/* Sized to be read from the back of a lecture hall and photographed. The
 * letter-spacing is what makes a code dictated aloud land correctly. */
.code {
  display: block;
  font-family: var(--mono);
  font-size: var(--text-3xl);
  font-weight: 600;
  letter-spacing: 0.14em;
  font-variant-numeric: tabular-nums;
  background: none;
  padding: 0;
  margin-bottom: var(--space-3);
}

.code-card {
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--shadow);
  padding: var(--space-5);
  margin: 0 0 var(--space-4);
  max-width: 22rem;
}
.code-card svg {
  display: block;
  height: auto;
  max-width: 11rem;
  border-radius: var(--radius-xs);
  margin-bottom: var(--space-3);
}
.code-card p { margin: 0 0 var(--space-2); font-size: var(--text-sm); color: var(--muted); }
.code-card form { margin-top: var(--space-3); }

/* ── reporting a problem, and the queue it lands in ──────────────────────── */

/* A <details> rather than a link to another page: the report is about what is
 * on the screen, and a reader who has to navigate away to write it loses the
 * thing they were pointing at — which is the context that makes it worth
 * having. */
.report {
  margin: var(--space-6) 0 0;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--surface);
}
.report > summary {
  cursor: pointer;
  padding: var(--space-3) var(--space-5);
  font-size: var(--text-sm);
  font-weight: 600;
  border-radius: var(--radius);
  list-style-position: inside;
}
.report > summary:hover { background: var(--sunken); }
.report[open] > summary { border-bottom: 1px solid var(--rule); border-radius: var(--radius) var(--radius) 0 0; }
.report > *:not(summary) { padding-inline: var(--space-5); }
.report > form { padding-bottom: var(--space-5); }

#report-about { font-size: var(--text-sm); padding-top: var(--space-4); }
#report-about q { color: var(--ink-2); }

.report-card {
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--shadow);
  padding: var(--space-5);
  margin: 0 0 var(--space-4);
}
.report-card h2 {
  font-size: var(--text-lg);
  margin: 0 0 var(--space-1);
  font-family: var(--sans);
}
.report-card > p.muted:first-of-type { font-size: var(--text-sm); }
.report-card blockquote { font-size: var(--text-sm); }
.report-card form { margin-top: var(--space-4); padding-top: var(--space-4); border-top: 1px solid var(--rule); }

/* ── motion ──────────────────────────────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
  .card:hover { transform: none; }
}

/* ── stacked sections ────────────────────────────────────────────────────────
 *
 * The account page is six unrelated things — how you sign in, your passkeys,
 * your authenticator, your recovery codes, your devices — and as a run of h2s
 * down one column it reads as one long thing with headings in it. Each is its
 * own surface, so the eye can find the one it came for.
 */
.panel {
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  padding: var(--space-5);
  margin: 0 0 var(--space-4);
}
.panel > h2 { margin-top: 0; font-size: var(--text-lg); }
.panel > :last-child { margin-bottom: 0; }
.panel ul { margin: 0 0 var(--space-4); padding-left: var(--space-5); }

/* ── the roster ──────────────────────────────────────────────────────────── */

/* Rows rather than cards: a name and two controls is not a thing you scan for,
   it is a thing you act on, and thirty of them as cards is a page of boxes. */
.roster { list-style: none; margin: 0 0 var(--space-5); padding: 0; }

.roster__row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin: 0;
  padding: var(--space-3) var(--space-2);
  border-bottom: 1px solid var(--rule);
}
.roster__row:last-child { border-bottom: 0; }

/* The name takes the row's spare width, and what qualifies it — an address, a
   term, a course — goes underneath rather than trailing after it. Without the
   `flex` this column shrinks to its content's minimum and a name wraps in the
   middle of itself, with the email starting on the same line it ended on:
   "Jun-Ray Macairan jun-" / "ray@trentu.ca", which is how it read until
   somebody looked at it. */
.roster__who { flex: 1 1 12rem; min-width: 0; }
.roster__who .muted {
  display: block;
  font-size: var(--text-sm);
  /* One line. A long address truncates rather than pushing the row's controls
     into a second row of their own. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The controls sit right, and the forms inside them stop being block-level so
   two buttons share a line. */
.roster__acts { margin-left: auto; display: flex; gap: var(--space-2); flex: none; }
.roster__acts form { margin: 0; }
.roster__acts button { min-height: 2.25rem; padding-inline: var(--space-4); }
.roster__acts p { margin: 0; }

@media (max-width: 34rem) {
  .roster__row { flex-wrap: wrap; }
  .roster__acts { margin-left: 0; width: 100%; }
}

/* ── tabs ────────────────────────────────────────────────────────────────── */

/* A filter across the top of a queue. Real links, so each state is a URL
   somebody can bookmark and send — which is what an instructor does when they
   want a colleague to look at the same list. */
.tabs {
  display: flex;
  gap: var(--space-1);
  margin: 0 0 var(--space-5);
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--rule);
  overflow-x: auto;
  scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }

/* Wrapped rather than scrolled on a narrow screen.
 *
 * A hidden horizontal scrollbar is fine where it never comes into play and a
 * trap where it does: six admin tabs at 390px showed four, with Appearance and
 * Activity beyond the edge and nothing on screen suggesting they existed. It
 * is the same fault the account navigation had, in a different component, so
 * it gets the same answer. */
@media (max-width: 40rem) {
  .tabs {
    flex-wrap: wrap;
    overflow-x: visible;
    row-gap: var(--space-1);
  }
}

.tab {
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
  transition: background var(--quick) var(--ease), color var(--quick) var(--ease);
}
.tab:hover { background: var(--sunken); color: var(--ink); }
.tab--on { background: var(--sunken); color: var(--ink); font-weight: 600; }

/* Sign out sits below the panels rather than in one: it is about the session,
   not about any of the things above it. */
.signout { margin-top: var(--space-5); }
.signout form { display: inline-block; }

/* A form is block-level by default, so two in a row stack. Inside a panel they
   are alternatives — "turn off" beside "set up" — and belong on one line. */
.panel form { display: inline-block; margin: 0 var(--space-2) 0 0; }
.panel p:has(> button) { margin: 0; }
.panel li { margin: var(--space-2) 0; }
.panel li form { margin-left: var(--space-2); }
.panel li button { min-height: 2rem; padding-inline: var(--space-3); font-size: var(--text-xs); }

/* A toc row that carries a control rather than being one. */
.toc__row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
}
.toc__row > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.toc__row form { margin: 0 0 0 auto; }
.toc__row .pill + form { margin-left: var(--space-3); }
.toc__row button { min-height: 2.25rem; padding-inline: var(--space-4); }
.toc__row p { margin: 0; }

/* ── the theme switch ────────────────────────────────────────────────────── */

/* Three states in one control: Day, Night, and whatever the machine says. The
   third is not a fallback — it is the right answer for most people most of the
   time, and a two-state toggle quietly takes it away from them. */
/* ── appearance ───────────────────────────────────────────────────────────
 *
 * One button that cycles Day → Night → Auto, showing the state it is in. It
 * was three buttons in a segmented group, which is the more explicit control
 * and was the wrong trade here: three of anything in a header is a third of
 * the bar spent on a setting most people touch once, and the group's own
 * chrome — a well, a border, a raised pressed state — was more pixels than the
 * icons inside it. */
.theme { display: flex; }

.theme__cycle {
  display: grid;
  place-items: center;
  /* Above the 24px minimum for a pointer target (2.5.8), which is what decides
     this rather than how small an 18px icon could be squeezed. */
  width: 2rem;
  height: 2rem;
  min-height: 0;
  padding: 0;
  background: none;
  border: 1px solid transparent;
  color: var(--muted);
  border-radius: var(--radius-sm);
}
.theme__cycle:hover {
  background: var(--sunken);
  border-color: var(--rule);
  color: var(--ink);
  filter: none;
}
.theme__cycle:active { transform: none; }
/* `currentColor` throughout the icons, so they follow the button rather than
   being three fixed pictures that have to work on two backgrounds. */
.theme__cycle svg { display: block; }

@media (max-width: 40rem) { .theme { order: 3; } }

/* ── numbers at a glance ─────────────────────────────────────────────────── */

.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--space-3);
  margin: 0 0 var(--space-6);
}

.stat {
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  padding: var(--space-4);
}
.stat__n {
  margin: 0;
  font-size: var(--text-2xl);
  font-weight: 650;
  line-height: 1;
  letter-spacing: var(--track-tight);
  font-variant-numeric: tabular-nums;
}
.stat__l { margin: var(--space-2) 0 0; font-size: var(--text-sm); font-weight: 600; }
.stat__note { margin: var(--space-1) 0 0; font-size: var(--text-xs); color: var(--muted); }

/* ── a colour, shown ─────────────────────────────────────────────────────── */

/* The chip's colour comes from a data attribute rather than a style attribute,
   because the app origin's CSP forbids inline style and that policy is only
   worth having if nothing is the exception. A handful of institutions is a
   handful of rules; when it stops being a handful this becomes a generated
   block in /brand.css, which is already a route. Until then the chip carries
   the hex as text beside it, which is the part somebody checking a brand
   actually reads. */
.swatches {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  font-size: var(--text-sm);
  color: var(--muted);
}
.swatch { display: inline-flex; align-items: center; gap: var(--space-2); }
/* An <svg> now, with the colour as a `fill` attribute — see `chip()` in
   app/admin.js for why it cannot be a background. The size and the border live
   in the SVG too; what is left here is only how it sits in the line. */
.swatch__chip { flex: none; display: block; }
.swatch code { background: none; padding: 0; }

/* ── giving somebody a role ──────────────────────────────────────────────── */

/* Closed by default. Nine roles, each with its own reach, is a lot of screen
   for something an administrator does occasionally — and the page's actual
   subject is what this person already has. */
.grant {
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--surface);
  margin-top: var(--space-4);
}
.grant > summary {
  cursor: pointer;
  padding: var(--space-3) var(--space-4);
  font-weight: 600;
  font-size: var(--text-sm);
  border-radius: var(--radius);
}
.grant > summary:hover { background: var(--sunken); }
.grant[open] > summary { border-bottom: 1px solid var(--rule); }

.grant__role { padding: var(--space-4); border-bottom: 1px solid var(--rule); }
.grant__role:last-child { border-bottom: 0; }
.grant__role h3 { margin: 0; font-size: var(--text); text-transform: capitalize; }
.grant__role > p { margin: var(--space-1) 0 var(--space-3); }
.grant__role .field { margin-bottom: var(--space-3); }
.grant__role form { margin: 0; }

/* A stat that answers a question rather than only stating one. */
.stat--link {
  display: block;
  color: inherit;
  text-decoration: none;
  transition: border-color var(--quick) var(--ease),
              box-shadow var(--quick) var(--ease),
              transform var(--quick) var(--ease);
}
.stat--link:hover {
  border-color: var(--rule-2);
  box-shadow: var(--shadow-up);
  transform: translateY(-1px);
}
.stat--link .stat__l { color: var(--accent); }

/* ── review cards ─────────────────────────────────────────────────────────
 *
 * A question somebody got wrong, with the explanation attached. It is closer
 * to a flashcard than to a table row: the stem is the thing being read, the
 * reasoning is the payoff, and everything else is a footnote. */
.review { list-style: none; margin: 0 0 var(--space-5); padding: 0; }

.review__item {
  padding: var(--space-4);
  margin-bottom: var(--space-3);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
}

.review__stem { margin: 0 0 var(--space-2); font-weight: 600; }

/* The sentence explaining why. Indented against a rule rather than boxed:
   these sit inside a card already, and a box inside a box is a form. */
.review__why {
  margin: 0 0 var(--space-3);
  padding-left: var(--space-3);
  border-left: 3px solid var(--accent);
  color: var(--ink-2);
}

.review__quote {
  margin: 0 0 var(--space-3);
  padding-left: var(--space-3);
  border-left: 3px solid var(--accent);
  font-style: italic;
}

.review__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  margin: 0;
  font-size: var(--text-sm);
}

/* ── meter ────────────────────────────────────────────────────────────────
 *
 * Thirty rows of "62%" is a column nobody reads. The same thirty with a bar
 * behind them is a shape, and the three students who need help are visible
 * without reading a figure.
 *
 * An <svg> rather than two nested spans — see `bar()` in app/teach.js. The
 * width of the filled part is a presentation attribute there, because the
 * inline style it used to be was dropped by the CSP and left every bar looking
 * full. Only the colours are here.
 *
 * `role="img"` with a label rather than `<progress>`: this is a picture of a
 * number that is also written next to it, and a screen reader announcing both
 * is announcing it twice. */
.meter {
  display: inline-block;
  vertical-align: middle;
  flex: none;
}
.meter__track { fill: var(--rule); }
.meter__fill { fill: var(--accent); }

/* ── previewing another institution's colours ─────────────────────────────
 *
 * Above the header, full width, and it does not scroll away. A preview
 * somebody has forgotten they are in looks exactly like the platform changing
 * colour on its own, and the person who reports that is somebody else — who
 * cannot see this bar, because it is not their preview.
 *
 * Deliberately not in the brand colour. This bar is the platform speaking
 * about the theme, so it must not be wearing it: in the institution's own
 * colours it reads as part of the design being previewed rather than as a
 * frame around it. */
.previewing {
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-4);
  background: var(--chrome-bar);
  color: var(--chrome-bar-ink);
  font-size: var(--text-sm);
}
.previewing form { margin: 0; }
.previewing button {
  min-height: 2rem;
  padding-inline: var(--space-3);
  background: transparent;
  color: inherit;
  border-color: currentColor;
}
.previewing button:hover { background: rgba(255, 255, 255, 0.12); }

/* ── the reading panel ────────────────────────────────────────────────────
 *
 * Beside the section, in the shell's chrome, because it governs the section's
 * type but is not part of the section. It used to be inside the frame, where
 * its settings could not follow anybody to a second device and its theme
 * argued with the one in the header. */
.reading { position: relative; margin-left: auto; }

/* Quiet, deliberately. It inherited the primary button's accent fill and was
   the loudest thing on a page whose entire purpose is the prose underneath it —
   a reading-options button competing with the reading. It is chrome: the same
   weight as the theme switch beside it, because it does the same kind of job. */
.reading__btn {
  min-height: 2.25rem;
  padding-inline: var(--space-3);
  background: none;
  border: 1px solid transparent;
  color: var(--muted);
  /* "aA" is the label, so it is worth showing in the face it is about. */
  font-family: var(--serif);
  font-size: var(--text-md);
  letter-spacing: 0.02em;
}
.reading__btn:hover {
  background: var(--sunken);
  border-color: var(--rule);
  color: var(--ink);
  filter: none;
}
.reading__btn[aria-expanded="true"] {
  background: var(--sunken);
  border-color: var(--rule);
  color: var(--ink);
}

.reading__panel {
  position: absolute;
  right: 0;
  top: calc(100% + var(--space-2));
  z-index: 15;
  width: min(20rem, calc(100vw - 2 * var(--gutter)));
  padding: var(--space-4);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: var(--shadow-up);
}

.reading__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}
.reading__label { font-size: var(--text-sm); color: var(--muted); }

.reading__faces, .reading__stepper { display: flex; gap: var(--space-1); }

.reading__face { min-height: 2rem; padding-inline: var(--space-2); font-size: var(--text-sm); }
.reading__face[data-font="serif"]    { font-family: var(--serif); }
.reading__face[data-font="sans"]     { font-family: var(--sans); }
.reading__face[data-font="dyslexic"] { font-family: var(--sans); font-weight: 600; }

.reading__face[aria-pressed="true"] {
  background: var(--accent);
  color: var(--surface);
  border-color: var(--accent);
}

.reading__stepper button {
  min-width: 2.25rem;
  min-height: 2rem;
  padding: 0;
  font-size: var(--text-md);
  line-height: 1;
}
/* Wide enough that the readout does not shift the buttons as it changes from
   "1.35" to "2.10" — a control that moves under your finger between presses is
   a control you press wrong. */
.reading__value {
  min-width: 5.5rem;
  text-align: center;
  font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
}

.reading__note {
  margin: var(--space-3) 0 0;
  font-size: var(--text-sm);
  color: var(--muted);
}

/* A control that belongs on one row of a list rather than in a form of its
   own — a select and its button, side by side, not stacked. */
.inline-form { display: flex; align-items: center; gap: var(--space-2); margin: 0; }
.inline-form select { min-height: 2.25rem; padding-block: 0; }
.inline-form p { margin: 0; }

/* Present to a screen reader, gone from the page. For a label whose control
   is unmistakable in context and would be noise written out. */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* One institution's published palette, on the appearance page. Separated by a
   rule rather than boxed: these sit inside a panel already, and a card inside
   a card is how a settings page turns into a filing cabinet. */
.brand-preset { padding-top: var(--space-4); }
.brand-preset + .brand-preset { border-top: 1px solid var(--rule); }
.brand-preset h3 { margin: 0 0 var(--space-2); }
.brand-preset .swatches { margin-bottom: var(--space-3); }

/* ── search ───────────────────────────────────────────────────────────────
 *
 * The box on the class page and at the top of its own results. One component,
 * because a reader who searches from the contents and then refines from the
 * results should not meet two different controls doing one job. */
.search {
  display: flex;
  gap: var(--space-2);
  margin: 0 0 var(--space-5);
  max-width: var(--measure);
}
.search input {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
}
.search button { flex: none; }

.results { list-style: none; margin: 0 0 var(--space-5); padding: 0; }
.results__hit {
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--rule);
}
.results__hit:last-child { border-bottom: 0; }
.results__hit a { font-weight: 600; }
.results__hit .small { margin-left: var(--space-2); }

/* The passage, with the matched words marked. `--mark-*` is the section's
   vocabulary for a reader's own highlight and this is not that — a search hit
   is the platform pointing, not the reader keeping — so it takes the accent at
   low strength instead, which also means it wears the institution's colour. */
.results__snippet {
  margin: var(--space-2) 0 0;
  color: var(--ink-2);
}
.results__snippet mark {
  background: color-mix(in oklab, var(--accent) 18%, transparent);
  color: inherit;
  border-radius: 2px;
  padding: 0 1px;
}

/* ── the door ─────────────────────────────────────────────────────────────
 *
 * The landing page, for somebody with no account. Almost everybody arriving
 * here is a student holding a code, so the code box is the page rather than a
 * link to a page — and everything else is smaller than it. */
.lede {
  font-size: var(--text-lg);
  color: var(--ink-2);
  max-width: var(--measure);
  margin-bottom: var(--space-6);
}

.door {
  padding: var(--space-5);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  max-width: var(--measure);
}
.door h2 { margin-top: 0; }

.door__code { display: flex; gap: var(--space-2); margin: var(--space-4) 0 0; }
/* `field()` wraps its input in a .field div, so the flex child is that. The
   look of the input itself is `.input--code`, shared with the join page. */
.door__code .field { flex: 1 1 auto; min-width: 0; margin: 0; }
.door__code button { flex: none; }

.door__back {
  margin: var(--space-4) 0 var(--space-7);
  color: var(--muted);
  max-width: var(--measure);
}

/* Below the fold, for the one visitor in twenty who is deciding rather than
   arriving. A description list, because that is what it is: three things with
   a name and an explanation, not a feature grid pretending to be one. */
.door__what { max-width: var(--measure); }
.door__what dl { margin: 0; }
.door__what dt {
  font-weight: 600;
  margin-top: var(--space-5);
}
.door__what dd {
  margin: var(--space-2) 0 0;
  color: var(--ink-2);
}

@media (max-width: 34rem) {
  .door__code { flex-wrap: wrap; }
  .door__code button { width: 100%; }
}


/* ── written answers ──────────────────────────────────────────────────────
 *
 * A class's constructed responses to one prompt, folded. Thirty answers across
 * nine modules is not a page anybody scrolls to the bottom of, so the summary
 * line has to carry enough — the question, the count, and how the class graded
 * itself — that an instructor can choose what to open. */
.answers {
  margin: 0 0 var(--space-3);
  padding: var(--space-3) var(--space-4);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
}
.answers > summary {
  cursor: pointer;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2) var(--space-3);
}
.answers__prompt { font-weight: 600; }

.answers__one {
  padding-top: var(--space-3);
  margin-top: var(--space-3);
  border-top: 1px solid var(--rule);
}
.answers__who {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0 0 var(--space-2);
  font-size: var(--text-sm);
}
/* The answer itself, indented against a rule rather than boxed — it is already
   inside a card, and a box in a box is a form. Whitespace preserved: somebody
   who wrote three short paragraphs wrote three paragraphs. */
.answers blockquote {
  margin: 0;
  padding-left: var(--space-3);
  border-left: 3px solid var(--rule-2);
  white-space: pre-wrap;
  color: var(--ink-2);
}
